From 6e4fdef30e898e89e8a4291a718d1f42e7b080a8 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 7 Sep 2026 01:21:41 +0500 Subject: [PATCH 01/35] make the API usable in bulk and close two holes in the exception contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by pointing a checker at the public API and running it over thousands of real nodes — neither hole is visible from the happy path, and neither is visible to a caller that catches Exception. The exception contract. ConnectAsync is supposed to throw ProxyProtocolException for anything that goes wrong on the wire, NotSupportedException for a link describing something we cannot speak, and the ArgumentException family for the caller's own mistake. Two paths broke it. CreateSocket() sat outside the guarded region in both overloads, so a failed bind or handle exhaustion under a few thousand concurrent checks escaped as a raw SocketException. And AuthenticationException derives from SystemException, not IOException, so it slipped past the `ex is IOException or SocketException` guard entirely — meaning an expired certificate or an SNI the server will not serve, the most common way a TLS-carried node dies, was never reported as a proxy error at all. Internal/TlsHandshake now owns every client-side handshake so there is one place for that translation, under the new ProxyErrorCode.TlsHandshakeFailed. The code is appended, so existing values keep their numbers. Bulk parsing. Proxy.TryCreate returns the reason a link was rejected and never throws, whatever the input: a subscription is other people's text, one bad line in a thousand must not end a run, and a rejection naming an exception type the parsers do not raise on purpose is how a parser bug makes itself visible instead of hiding behind a caller's catch. IProxyClient.SourceLink carries the text a client was built from. ProxyUri cannot stand in: for vless, trojan and vmess it is only scheme://host:port, so a node written out that way has lost its uuid, sni and transport and cannot be reached again. Added as a default interface member, so nothing implementing the interface breaks. ProxyClientFactory is gone; its methods are static on Proxy, which was already the static entry point and — unlike ProxyClient — is not also the base class implementations derive from. That matters concretely: Create must return IProxyClient rather than ProxyClient, because a future QUIC client cannot derive from it, and a Create on a type named ProxyClient returning something else reads wrong. The singleton Instance goes with it; the class had no state. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 36 ++- QuickProxyNet.Tests/FactoryTest.cs | 14 +- .../Integration/ManagedRealityTunnelTests.cs | 4 +- ...ientFactoryTest.cs => ProxyFactoryTest.cs} | 107 ++++++++- QuickProxyNet.Tests/TrojanTest.cs | 2 +- QuickProxyNet.Tests/VlessTest.cs | 2 +- QuickProxyNet.Tests/VmessClientTest.cs | 4 +- QuickProxyNet/Clients/HttpsProxyClient.cs | 3 +- QuickProxyNet/Clients/ProxyClient.cs | 36 ++- QuickProxyNet/Clients/TrojanClient.cs | 3 +- QuickProxyNet/Clients/VlessClient.cs | 3 +- QuickProxyNet/Clients/VmessClient.cs | 3 +- QuickProxyNet/IProxyClient.cs | 14 +- QuickProxyNet/Internal/TlsHandshake.cs | 54 +++++ QuickProxyNet/Proxy.cs | 222 +++++++++++++++++- QuickProxyNet/ProxyClientFactory.cs | 183 --------------- QuickProxyNet/ProxyProtocolException.cs | 7 +- QuickProxyNet/README.md | 2 +- README.md | 6 +- docs/implementation-plan.md | 6 +- docs/quic-protocols-analysis.md | 18 +- 21 files changed, 490 insertions(+), 239 deletions(-) rename QuickProxyNet.Tests/{ProxyClientFactoryTest.cs => ProxyFactoryTest.cs} (68%) create mode 100644 QuickProxyNet/Internal/TlsHandshake.cs delete mode 100644 QuickProxyNet/ProxyClientFactory.cs diff --git a/AGENTS.md b/AGENTS.md index 1c5bf26..543c4ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,14 +34,14 @@ hand-run diagnostic, and keeping it out of the solution keeps it out of CI. All public library types live in the `QuickProxyNet` namespace. -- `Proxy` exposes static one-call `ConnectAsync(...)` helpers. +- `Proxy` is the static entry point: one-call `ConnectAsync(...)` helpers, plus + `Create(...)` / `TryCreate(...)` building a client from a share-link `string`, + from a `Uri`, or from explicit proxy settings. - `ProxyUriExtensions` adds `Uri.ConnectThroughProxyAsync(...)`. - `IProxyClient` is the client contract; connection methods return - `ValueTask`. + `ValueTask`. `SourceLink` carries the text the client was built from. - `ProxyClient` owns common socket setup, timeout handling, and argument validation. -- `ProxyClientFactory` creates clients from a share-link `string`, from a `Uri`, - or from explicit proxy settings. - `ProxyProtocolException` carries a structured `ProxyErrorCode`. - `VlessOptions` / `TrojanOptions` / `VmessOptions` plus the matching `*ShareLink.Parse` / `TryParse` describe a VPN-style endpoint. @@ -146,7 +146,7 @@ independent: nothing leaves the machine. **Public shape, decided:** REALITY is reached through `VlessClient` — `security=reality` -in `VlessOptions`, or simply the share link via `ProxyClientFactory.Create(string)`. +in `VlessOptions`, or simply the share link via `Proxy.Create(string)`. Nothing under `Internal/Reality/` is public except `RealityHandshakeException`, which is a `ProxyProtocolException` so existing `catch` blocks see it. A separate `RealityClient` or a third package were considered and rejected: a user holds a `vless://` link, and the link @@ -213,7 +213,7 @@ not "clean up" any of them without reading the reasoning first. 8. **`vmess://` links generally cannot be `System.Uri` values.** The base64 JSON payload exceeds `Uri`'s host-length limit and contains `=` padding. Use - `ProxyClientFactory.Create(string)`, `VmessClient.FromShareLink(string)` or + `Proxy.Create(string)`, `VmessClient.FromShareLink(string)` or `VmessShareLink.Parse(string)` — all of which operate on the raw string. 9. **Non-UUID user ids are real and must be derived, not rejected.** Xray's @@ -297,7 +297,29 @@ not "clean up" any of them without reading the reasoning first. opaque launch error rather than as anything about transports. A share link combining the two describes something no server can serve; reject it by name. -18. **Xray's own SOCKS inbound stalls above roughly one TLS record.** A request of +18. **`ConnectAsync` throws exactly three kinds of exception, and that is a contract.** + `ProxyProtocolException` for everything that can go wrong on the wire, + `NotSupportedException` for a link describing something this library cannot speak, + and the `ArgumentException` family for a caller's own mistake. Callers written + against it catch the first and let the other two crash the process, which is right: + one is a dead node, the others are a bug in the calling code. + + Two paths used to break it, and both were invisible from inside the library — + it took a checker running the public API over thousands of real nodes to see them. + `CreateSocket()` sat *outside* the guarded region in both overloads, so a bind + failure or handle exhaustion escaped as a raw `SocketException`. And + `AuthenticationException` derives from `SystemException`, not `IOException`, so it + slipped past the `ex is IOException or SocketException` guard — meaning an expired + certificate or an unservable SNI, the most common way a TLS-carried node dies, was + never reported as a proxy error at all. `TlsHandshake.AuthenticateAsync` now owns + every client-side handshake so there is one place for that translation. + + The lesson generalises: a leak in an exception contract cannot be seen by the tests + that assert on the happy path, and cannot be seen by a caller that catches + `Exception`. It shows up only where something classifies failures and has a bucket + labelled "unrecognised" that starts filling up. + +19. **Xray's own SOCKS inbound stalls above roughly one TLS record.** A request of 16 000 bytes round-trips; 16 500 hangs until the client gives up, with no error logged by either process. Not ours, and worth remembering before spending an afternoon on it again: `LargeRequestDiagnosticTests` isolates it by carrying diff --git a/QuickProxyNet.Tests/FactoryTest.cs b/QuickProxyNet.Tests/FactoryTest.cs index 0fdd64c..c8f9511 100644 --- a/QuickProxyNet.Tests/FactoryTest.cs +++ b/QuickProxyNet.Tests/FactoryTest.cs @@ -10,11 +10,9 @@ public void BadProtocolUri(string stringUri) { Uri uri = new Uri(stringUri); - ProxyClientFactory factory = new ProxyClientFactory(); - try { - factory.Create(uri); + Proxy.Create(uri); } catch (Exception e) { @@ -32,10 +30,7 @@ public void TestCreateFromUri(string stringUri) { Uri uri = new Uri(stringUri); - - ProxyClientFactory factory = new ProxyClientFactory(); - - IProxyClient client = factory.Create(uri); + IProxyClient client = Proxy.Create(uri); Assert.Equal(client.Type.ToString().ToLower(), uri.Scheme.ToLower()); @@ -54,10 +49,7 @@ public void TestCreateFromUriWithPass(string stringUri) { Uri uri = new Uri(stringUri); - - ProxyClientFactory factory = new ProxyClientFactory(); - - IProxyClient client = factory.Create(uri); + IProxyClient client = Proxy.Create(uri); Assert.Equal(client.Type.ToString().ToLower(), uri.Scheme.ToLower()); diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs index 3be5afd..dd36173 100644 --- a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs @@ -84,7 +84,7 @@ private static async Task GetAsync( /// /// The same tunnel, but opened the way a user opens it: a share link into - /// , ConnectAsync, a stream back. Everything the + /// , ConnectAsync, a stream back. Everything the /// direct tests above bypass — VlessClient.ConnectAsync, its REALITY branch, the /// option mapping from the link, the flow wrapping — is on this path and nowhere else. /// @@ -95,7 +95,7 @@ public async Task PublicApi_ShareLinkThroughFactory_CarriesVlessOverReality() await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - IProxyClient client = ProxyClientFactory.Instance.Create(server.ShareLink()); + IProxyClient client = Proxy.Create(server.ShareLink()); await using Stream tunnel = await client.ConnectAsync("127.0.0.1", echo.Port, timeout.Token); Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); diff --git a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs similarity index 68% rename from QuickProxyNet.Tests/ProxyClientFactoryTest.cs rename to QuickProxyNet.Tests/ProxyFactoryTest.cs index 0fae083..2cfe5fe 100644 --- a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -11,11 +11,11 @@ namespace QuickProxyNet.Tests; /// case below — those links are base64 JSON that cannot represent at all — so /// that test is the one that matters most here. /// -public class ProxyClientFactoryTest +public class ProxyFactoryTest { private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; - private static IProxyClient Create(string link) => ProxyClientFactory.Instance.Create(link); + private static IProxyClient Create(string link) => Proxy.Create(link); [Fact] public void Create_Vless_ReturnsAVlessClient() @@ -76,7 +76,7 @@ public void Create_Vmess_HandlesLinksThatCannotBecomeAUri() public void Create_ClassicSchemes_MatchTheUriOverload(string link, Type expected) { Assert.IsType(expected, Create(link)); - Assert.IsType(expected, ProxyClientFactory.Instance.Create(new Uri(link))); + Assert.IsType(expected, Proxy.Create(new Uri(link))); } [Fact] @@ -211,6 +211,107 @@ public void Create_MalformedLink_NeverEchoesTheCredential(string link) Assert.DoesNotContain("SECRET", e.Message); } + // --- TryCreate: walking a subscription without exception-driven control flow. + + [Theory] + [InlineData("socks5://example.com:1080")] + [InlineData("http://user:pass@example.com:8080")] + [InlineData("vless://11223344-5566-7788-99aa-bbccddeeff00@example.com:443?type=tcp&security=tls")] + public void TryCreate_GoodLink_ReturnsTrueAndNoError(string link) + { + Assert.True(Proxy.TryCreate(link, out IProxyClient? client, out string? error)); + + Assert.NotNull(client); + Assert.Null(error); + } + + [Theory] + [InlineData("")] // empty + [InlineData(" ")] // whitespace only + [InlineData("example.com:1080")] // no scheme + [InlineData("ss://not-a-scheme-we-speak@host:443")] // unsupported scheme + [InlineData("vmess://this-is-not-base64-json")] // known scheme, broken payload + [InlineData("vless://" + "a-31-character-id-aaaaaaaaaaaaa" + "@example.com:443")] // id length 31: too long to derive, too short to be hex + public void TryCreate_BadLink_ReturnsFalseWithAReason(string link) + { + Assert.False(Proxy.TryCreate(link, out IProxyClient? client, out string? error)); + + Assert.Null(client); + Assert.NotNull(error); + // The reason has to name the exception type: that is what separates "this link is junk" + // from "a parser threw something nobody planned for", which is a library bug. + Assert.Contains("Exception", error); + } + + /// + /// The whole point of TryCreate: a list from the wild is other people's text, and one bad + /// line in a thousand must not end the run. + /// + [Fact] + public void TryCreate_NeverThrows_WhateverTheInput() + { + string[] hostile = + [ + "://", "vless://", "vmess://", "trojan://", "socks5://", + "vless://@:", "vmess://" + new string('A', 5000), "http://[::", + "\0", "�", new string('/', 200), "vless://%%%@%%%:%%%", + ]; + + foreach (string link in hostile) + { + // Assert.False is not the claim here — some of these could conceivably parse one day. + // The claim is that the call returns rather than throwing. + Proxy.TryCreate(link, out _, out _); + } + } + + // --- SourceLink: the text a client came from, which ProxyUri cannot reconstruct. + + [Fact] + public void SourceLink_FromShareLink_KeepsTheWholeLink() + { + const string link = + "vless://11223344-5566-7788-99aa-bbccddeeff00@example.com:443?type=tcp&security=tls&sni=cdn.example.com#node"; + + IProxyClient client = Proxy.Create(link); + + Assert.Equal(link, client.SourceLink); + // And the reason SourceLink has to exist: ProxyUri has dropped everything that makes the + // node reachable — the uuid, the sni, the transport. + Assert.Equal("vless://example.com:443/", client.ProxyUri.ToString()); + Assert.DoesNotContain("cdn.example.com", client.ProxyUri.ToString()); + } + + [Fact] + public void SourceLink_FromUri_KeepsTheOriginalText() + { + const string link = "socks5://user:pass@example.com:1080"; + + IProxyClient client = Proxy.Create(new Uri(link)); + + Assert.Equal(link, client.SourceLink); + } + + [Fact] + public void SourceLink_FromExplicitSettings_IsNull() + { + IProxyClient client = Proxy.Create(ProxyType.Socks5, "example.com", 1080, credentials: null); + + Assert.Null(client.SourceLink); + } + + [Theory] + [InlineData(ProxyType.Vless)] + [InlineData(ProxyType.Vmess)] + [InlineData(ProxyType.Trojan)] + public void Create_ShareLinkFamilyFromHostAndPort_IsRejected(ProxyType type) + { + // These carry a uuid, a security mode and a transport. Host and port cannot express them, + // and silently building a client that cannot connect would be worse than saying so. + Assert.Throws( + () => Proxy.Create(type, "example.com", 443, credentials: null)); + } + /// A port that was bound and immediately released — nothing is listening on it. private static int UnusedPort() { diff --git a/QuickProxyNet.Tests/TrojanTest.cs b/QuickProxyNet.Tests/TrojanTest.cs index c5f3eae..5540472 100644 --- a/QuickProxyNet.Tests/TrojanTest.cs +++ b/QuickProxyNet.Tests/TrojanTest.cs @@ -185,7 +185,7 @@ await Assert.ThrowsAsync( [Fact] public void Factory_CreatesTrojanClient() { - var client = ProxyClientFactory.Instance.Create( + var client = Proxy.Create( new Uri("trojan://pw@example.com:443?sni=a.com&allowInsecure=1")); var trojan = Assert.IsType(client); Assert.Equal(ProxyType.Trojan, trojan.Type); diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index bad29ca..014ae24 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -502,7 +502,7 @@ await Assert.ThrowsAsync( [Fact] public void Factory_CreatesVlessClient() { - var client = ProxyClientFactory.Instance.Create( + var client = Proxy.Create( new Uri($"vless://{Uuid}@example.com:443?security=tls&sni=a.com")); var vless = Assert.IsType(client); Assert.Equal(ProxyType.Vless, vless.Type); diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index 899efdd..01148a7 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -710,7 +710,7 @@ public void Factory_CreatesVmessClient() string link = UriLink(MinimalJson(add: "cdn.example.com", port: "8443", extra: ",\"scy\":\"aes-128-gcm\",\"tls\":\"tls\"")); - var client = ProxyClientFactory.Instance.Create(new Uri(link)); + var client = Proxy.Create(new Uri(link)); var vmess = Assert.IsType(client); Assert.Equal(ProxyType.Vmess, vmess.Type); @@ -724,7 +724,7 @@ public void Factory_CreatesVmessClient() public void Factory_InvalidVmessUri_Throws() { var uri = new Uri(UriLink(MinimalJson(extra: ",\"aid\":\"1\""))); - Assert.Throws(() => ProxyClientFactory.Instance.Create(uri)); + Assert.Throws(() => Proxy.Create(uri)); } [Fact] diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index 2f94960..ae693a3 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -56,7 +56,8 @@ public override async ValueTask ConnectAsync(Stream stream, string host, var ssl = new SslStream(stream, false); try { - await ssl.AuthenticateAsClientAsync(GetSslClientAuthenticationOptions(host), cancellationToken); + await TlsHandshake.AuthenticateAsync( + ssl, GetSslClientAuthenticationOptions(host), $"{ProxyHost}:{ProxyPort}", cancellationToken); } catch { diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index a9ef0ab..84cbb9c 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -73,6 +73,11 @@ private static string FormatUriHost(string host) => host.Contains(':') && !host.StartsWith('[') ? $"[{host}]" : host; public Uri ProxyUri { get; private set; } + + /// + /// Set by the factory methods when a link was the input. + public string? SourceLink { get; internal set; } + public abstract ProxyType Type { get; } public NetworkCredential? ProxyCredentials { get; } @@ -110,7 +115,20 @@ public async ValueTask ConnectAsync(string host, int port, CancellationT cancellationToken.ThrowIfCancellationRequested(); - var socket = CreateSocket(); + Socket socket; + try + { + socket = CreateSocket(); + } + catch (SocketException ex) + { + // CreateSocket binds LocalEndPoint and allocates a handle, so it fails for reasons a + // caller must see as a connection failure like any other: an address already in use, + // or handle exhaustion under a few thousand concurrent checks. Left outside the guard + // this was the one path that escaped ConnectAsync as a raw SocketException. + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Could not open a socket for proxy {ProxyHost}:{ProxyPort}.", ex); + } try { @@ -152,7 +170,21 @@ public virtual async ValueTask ConnectAsync(string host, int port, TimeS cancellationToken.ThrowIfCancellationRequested(); - var socket = CreateSocket(); + Socket socket; + try + { + socket = CreateSocket(); + } + catch (SocketException ex) + { + // CreateSocket binds LocalEndPoint and allocates a handle, so it fails for reasons a + // caller must see as a connection failure like any other: an address already in use, + // or handle exhaustion under a few thousand concurrent checks. Left outside the guard + // this was the one path that escaped ConnectAsync as a raw SocketException. + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Could not open a socket for proxy {ProxyHost}:{ProxyPort}.", ex); + } + var timedOut = new StrongBox(false); await using ITimer timer = TimeProvider.System.CreateTimer( diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs index 8fde059..8997c5d 100644 --- a/QuickProxyNet/Clients/TrojanClient.cs +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -70,7 +70,8 @@ public override async ValueTask ConnectAsync(Stream stream, string host, Stream layered = new SslStream(stream, leaveInnerStreamOpen: false); try { - await ((SslStream)layered).AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken) + await TlsHandshake.AuthenticateAsync( + (SslStream)layered, BuildSslOptions(), Options.Sni ?? Options.Host, cancellationToken) .ConfigureAwait(false); layered = await ProxyTransport.ApplyAsync( diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index e7509c7..bed1225 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -85,7 +85,8 @@ public override async ValueTask ConnectAsync(Stream stream, string host, // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. var ssl = new SslStream(layered, leaveInnerStreamOpen: false); layered = ssl; - await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + await TlsHandshake.AuthenticateAsync( + ssl, BuildSslOptions(), Options.Sni ?? Options.Host, cancellationToken).ConfigureAwait(false); } else if (Options.Security == VlessSecurity.Reality) { diff --git a/QuickProxyNet/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs index 1f9faae..a961b1e 100644 --- a/QuickProxyNet/Clients/VmessClient.cs +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -136,7 +136,8 @@ public override async ValueTask ConnectAsync(Stream stream, string host, // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. var ssl = new SslStream(layered, leaveInnerStreamOpen: false); layered = ssl; - await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + await TlsHandshake.AuthenticateAsync( + ssl, BuildSslOptions(), Options.Sni ?? Options.Host, cancellationToken).ConfigureAwait(false); } layered = await ProxyTransport.ApplyAsync( diff --git a/QuickProxyNet/IProxyClient.cs b/QuickProxyNet/IProxyClient.cs index e8a9c59..1444435 100644 --- a/QuickProxyNet/IProxyClient.cs +++ b/QuickProxyNet/IProxyClient.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Sockets; namespace QuickProxyNet; @@ -10,6 +10,18 @@ namespace QuickProxyNet; public interface IProxyClient { Uri ProxyUri { get; } + + /// + /// The share link or URL this client was built from, or when it was + /// built from explicit settings. + /// + /// + /// cannot stand in for this. For VLESS, Trojan and VMess it is only + /// scheme://host:port — a node written out that way has lost its uuid, sni, flow and + /// transport, and cannot be connected to again. Code that checks a list of nodes and reports + /// the ones that worked needs the text that came in, not a normalised summary of it. + /// + string? SourceLink => null; /// /// Gets the credentials used to authenticate with the proxy server, if required. diff --git a/QuickProxyNet/Internal/TlsHandshake.cs b/QuickProxyNet/Internal/TlsHandshake.cs new file mode 100644 index 0000000..2629fb7 --- /dev/null +++ b/QuickProxyNet/Internal/TlsHandshake.cs @@ -0,0 +1,54 @@ +using System.Net.Security; +using System.Security.Authentication; + +namespace QuickProxyNet; + +/// +/// Runs the client side of a TLS handshake and reports its failures as the library's own +/// exception type. +/// +/// +/// +/// This exists because derives from +/// , not from — so it slipped past the +/// ex is IOException or SocketException guard in and escaped +/// ConnectAsync raw. A caller that catches , which is +/// the documented contract, therefore missed an expired certificate, a hostname the server will +/// not serve, or an absent shared cipher suite entirely: the three ways a TLS-carried node most +/// often dies. +/// +/// +/// It deliberately does not touch . A truncated handshake is a transport +/// failure, and the layer above already has the context to name which peer dropped it. +/// +/// +internal static class TlsHandshake +{ + /// + /// Performs , + /// converting into + /// . + /// + /// The stream to authenticate. The caller owns it and disposes it on failure. + /// The client authentication options. + /// How to name the far side in the error message — an SNI or a host:port. + /// A token to cancel the handshake. + public static async ValueTask AuthenticateAsync( + SslStream ssl, + SslClientAuthenticationOptions options, + string peer, + CancellationToken cancellationToken) + { + try + { + await ssl.AuthenticateAsClientAsync(options, cancellationToken).ConfigureAwait(false); + } + catch (AuthenticationException ex) + { + throw new ProxyProtocolException( + ProxyErrorCode.TlsHandshakeFailed, + $"TLS handshake with {peer} failed: {ex.Message}", + ex); + } + } +} diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index 64e2b19..5b71600 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Sockets; using System.Runtime.CompilerServices; @@ -23,8 +24,7 @@ public static class Proxy /// supported scheme, including vless, trojan and vmess. /// /// - /// The proxy URL or share link. See for the - /// schemes this accepts. + /// The proxy URL or share link. See for the schemes this accepts. /// /// The target host to connect to through the proxy. /// The target port. @@ -33,14 +33,14 @@ public static class Proxy /// /// The overloads below cover only the classic schemes, and deliberately so: /// they skip the client object entirely, which is what makes them suitable for checking - /// proxies by the thousand. This one goes through instead, + /// proxies by the thousand. This one goes through instead, /// because VLESS, Trojan and VMess need the parsed configuration to negotiate at all. When /// you have a link and no reason to care which family it belongs to, use this. /// public static async ValueTask ConnectAsync(string proxyLink, string host, int port, CancellationToken cancellationToken = default) { - IProxyClient client = ProxyClientFactory.Instance.Create(proxyLink); + IProxyClient client = Create(proxyLink); return await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); } @@ -57,7 +57,7 @@ public static async ValueTask ConnectAsync(string proxyLink, string host public static async ValueTask ConnectAsync(string proxyLink, string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default) { - IProxyClient client = ProxyClientFactory.Instance.Create(proxyLink); + IProxyClient client = Create(proxyLink); return await client.ConnectAsync(host, port, timeout, cancellationToken).ConfigureAwait(false); } @@ -118,6 +118,218 @@ public static ValueTask ConnectAsync(Uri proxyUri, Stream source, string return ProxyConnector.ConnectToProxyAsync(source, proxyUri, host, port, credentials, cancellationToken); } + /// + /// Creates an from a proxy URL or share link, whatever its scheme. + /// + /// + /// http, https, socks4, socks4a, socks5, vless, + /// trojan or vmess. Credentials in the authority are honoured for the classic + /// schemes; the rest carry their configuration in the link itself. + /// + /// A client ready to . + /// is empty or has no scheme. + /// The scheme is not one this library speaks. + /// The scheme is known but the link is malformed. + /// + /// + /// This is the entry point to reach for when all you have is a string. It reads the scheme + /// off the front of the text rather than going through , which matters for + /// vmess://: those links are base64-encoded JSON, and rejects most + /// real ones outright for exceeding its host-length limit or carrying base64 padding. Via + /// such a link cannot even be represented, let alone parsed. + /// + /// + /// Use to walk a whole + /// subscription: a list from the wild always contains links this library cannot speak, and + /// driving that with exceptions costs more than it tells you. + /// + /// + public static IProxyClient Create(string proxyLink) + { + ArgumentException.ThrowIfNullOrWhiteSpace(proxyLink); + + string trimmed = proxyLink.Trim(); + int separator = trimmed.IndexOf("://", StringComparison.Ordinal); + if (separator <= 0) + throw new ArgumentException( + $"The proxy link ({trimmed.Length} characters) has no scheme: expected something like " + + "'socks5://host:port' or 'vless://...'.", nameof(proxyLink)); + + ReadOnlySpan scheme = trimmed.AsSpan(0, separator); + + // The share-link protocols carry everything in the text and are parsed from it directly. + if (scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) + return Tag(new VlessClient(VlessShareLink.Parse(trimmed)), trimmed); + + if (scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) + return Tag(new TrojanClient(TrojanShareLink.Parse(trimmed)), trimmed); + + if (scheme.Equals("vmess", StringComparison.OrdinalIgnoreCase)) + return Tag(new VmessClient(VmessShareLink.Parse(trimmed)), trimmed); + + // The classic ones are host/port URIs, so they go through Uri for its authority parsing. + if (scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("socks4", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("socks4a", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("socks5", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)) + throw new FormatException( + $"The {scheme} proxy link is not a well-formed URI (expected '{scheme}://[user:password@]host:port')."); + + return Tag(Create(uri), trimmed); + } + + throw new NotSupportedException( + $"Proxy scheme '{scheme}' is not supported. This library speaks http, https, socks4, " + + "socks4a, socks5, vless, trojan and vmess."); + } + + /// + /// Attempts to create an from a proxy URL or share link. + /// + /// The proxy URL or share link, in any scheme accepts. + /// The created client, or when the link could not be used. + /// when a client was created. + public static bool TryCreate(string proxyLink, [NotNullWhen(true)] out IProxyClient? client) => + TryCreate(proxyLink, out client, out _); + + /// + /// Attempts to create an from a proxy URL or share link, reporting + /// why a rejected link was rejected. + /// + /// The proxy URL or share link, in any scheme accepts. + /// The created client, or when the link could not be used. + /// + /// on success; otherwise the exception type and message, which is what + /// makes a rejection worth grouping — a run over real links wants to know how many were an + /// unsupported scheme versus a truncated payload. + /// + /// when a client was created. + /// + /// This never throws for bad input, including input no parser anticipated. A subscription is + /// other people's text: one malformed line out of thousands must not end the run, and a + /// rejection naming an exception type the parsers do not raise on purpose is how a parser bug + /// makes itself visible instead of hiding behind a caller's catch. + /// + public static bool TryCreate( + string proxyLink, + [NotNullWhen(true)] out IProxyClient? client, + [NotNullWhen(false)] out string? error) + { + try + { + client = Create(proxyLink); + error = null; + return true; + } + catch (Exception ex) + { + client = null; + error = $"{ex.GetType().Name}: {ex.Message}"; + return false; + } + } + + /// + /// Creates an from a proxy , taking the proxy type + /// from the scheme and the credentials from the authority when present. + /// + /// The proxy URI, including scheme, host, port and optional credentials. + /// A client configured for the proxy the URI describes. + /// The URI scheme is not one this library speaks. + /// + /// Note for vmess://: a VMess share link is base64-encoded JSON rather than a + /// host/port URI, and rejects a payload longer than its host-length limit or + /// containing base64 padding — which covers most real-world links. Such a link cannot be turned + /// into a at all, so prefer . The special case + /// below exists for the short links that are representable. + /// + public static IProxyClient Create(Uri proxyUri) + { + ArgumentNullException.ThrowIfNull(proxyUri); + + // VLESS, Trojan and VMess carry their whole configuration (uuid, security, sni, ...) in + // the URI, so they are parsed as share links rather than as host/port/credential triples. + if (proxyUri.Scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) + return Tag(new VlessClient(VlessShareLink.Parse(proxyUri.OriginalString)), proxyUri.OriginalString); + + if (proxyUri.Scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) + return Tag(new TrojanClient(TrojanShareLink.Parse(proxyUri.OriginalString)), proxyUri.OriginalString); + + if (proxyUri.Scheme.Equals("vmess", StringComparison.OrdinalIgnoreCase)) + return Tag(new VmessClient(VmessShareLink.Parse(proxyUri.OriginalString)), proxyUri.OriginalString); + + ProxyType type = proxyUri.Scheme switch + { + "http" => ProxyType.Http, + "https" => ProxyType.Https, + "socks4" => ProxyType.Socks4, + "socks4a" => ProxyType.Socks4a, + "socks5" => ProxyType.Socks5, + _ => throw new NotSupportedException( + $"Proxy scheme '{proxyUri.Scheme}' is not supported. This library speaks http, https, socks4, " + + "socks4a, socks5, vless, trojan and vmess.") + }; + + return Tag(Create(type, proxyUri.Host, proxyUri.Port, ParseCredentials(proxyUri)), proxyUri.OriginalString); + } + + /// + /// Creates an for one of the classic proxy families from explicit + /// settings. + /// + /// The proxy type. The share-link families are not created this way. + /// The hostname or IP address of the proxy server. + /// The port the proxy listens on. + /// Credentials for the proxy, or for none. + /// A client configured for the given proxy. + /// + /// is one of the share-link families, which need a parsed + /// configuration and cannot be described by host and port alone. + /// + public static IProxyClient Create(ProxyType type, string host, int port, NetworkCredential? credentials) + { + if (credentials is null) + return CreateClassic(type, host, port); + + return type switch + { + ProxyType.Http => new HttpProxyClient(host, port, credentials), + ProxyType.Https => new HttpsProxyClient(host, port, credentials), + ProxyType.Socks4 => new Socks4Client(host, port, credentials), + ProxyType.Socks4a => new Socks4aClient(host, port, credentials), + ProxyType.Socks5 => new Socks5Client(host, port, credentials), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, ShareLinkFamilyMessage) + }; + } + + private static IProxyClient CreateClassic(ProxyType type, string host, int port) => + type switch + { + ProxyType.Http => new HttpProxyClient(host, port), + ProxyType.Https => new HttpsProxyClient(host, port), + ProxyType.Socks4 => new Socks4Client(host, port), + ProxyType.Socks4a => new Socks4aClient(host, port), + ProxyType.Socks5 => new Socks5Client(host, port), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, ShareLinkFamilyMessage) + }; + + private const string ShareLinkFamilyMessage = + "VLESS, Trojan and VMess carry a configuration that host and port cannot express; " + + "build them from a share link instead."; + + // Records the text a client was built from, so a caller holding only IProxyClient can report + // the node it checked. ProxyUri cannot stand in: for the share-link families it is only + // scheme://host:port, and writing a vless node out that way drops its uuid, sni and transport. + private static IProxyClient Tag(IProxyClient client, string link) + { + if (client is ProxyClient concrete) + concrete.SourceLink = link; + return client; + } + private static async ValueTask ConnectCoreAsync(Uri proxyUri, string host, int port, TimeSpan? timeout, CancellationToken cancellationToken) { diff --git a/QuickProxyNet/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs deleted file mode 100644 index cdb5eba..0000000 --- a/QuickProxyNet/ProxyClientFactory.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System.Net; - -namespace QuickProxyNet; - -/// -/// Provides a factory for creating proxy client instances based on a specified URI or configuration. -/// This class simplifies the process of selecting the correct proxy type and configuring it with the necessary settings. -/// -public sealed class ProxyClientFactory -{ - /// - /// Gets a singleton instance of the ProxyClientFactory. - /// - public static ProxyClientFactory Instance { get; } = new(); - - /// - /// Creates an from a proxy URL or share link, whatever its scheme. - /// - /// - /// http, https, socks4, socks4a, socks5, vless, - /// trojan or vmess. Credentials in the authority are honoured for the classic - /// schemes; the rest carry their configuration in the link itself. - /// - /// A client ready to . - /// is empty or has no scheme. - /// The scheme is not one this library speaks. - /// The scheme is known but the link is malformed. - /// - /// - /// This is the entry point to reach for when all you have is a string. It reads the scheme - /// off the front of the text rather than going through , which matters for - /// vmess://: those links are base64-encoded JSON, and rejects most - /// real ones outright for exceeding its host-length limit or carrying base64 padding. Via - /// such a link cannot even be represented, let alone parsed. - /// - /// - public IProxyClient Create(string link) - { - ArgumentException.ThrowIfNullOrWhiteSpace(link); - - string trimmed = link.Trim(); - int separator = trimmed.IndexOf("://", StringComparison.Ordinal); - if (separator <= 0) - throw new ArgumentException( - $"The proxy link ({trimmed.Length} characters) has no scheme: expected something like " + - "'socks5://host:port' or 'vless://...'.", nameof(link)); - - ReadOnlySpan scheme = trimmed.AsSpan(0, separator); - - // The share-link protocols carry everything in the text and are parsed from it directly. - if (scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) - return new VlessClient(VlessShareLink.Parse(trimmed)); - - if (scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) - return new TrojanClient(TrojanShareLink.Parse(trimmed)); - - if (scheme.Equals("vmess", StringComparison.OrdinalIgnoreCase)) - return new VmessClient(VmessShareLink.Parse(trimmed)); - - // The classic ones are host/port URIs, so they go through Uri for its authority parsing. - if (scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || - scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || - scheme.Equals("socks4", StringComparison.OrdinalIgnoreCase) || - scheme.Equals("socks4a", StringComparison.OrdinalIgnoreCase) || - scheme.Equals("socks5", StringComparison.OrdinalIgnoreCase)) - { - if (!Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)) - throw new FormatException( - $"The {scheme} proxy link is not a well-formed URI (expected '{scheme}://[user:password@]host:port')."); - - return Create(uri); - } - - throw new NotSupportedException( - $"Proxy scheme '{scheme}' is not supported. This library speaks http, https, socks4, " + - "socks4a, socks5, vless, trojan and vmess."); - } - - /// - /// Creates an IProxyClient instance based on the provided URI, automatically determining the proxy type - /// and extracting credentials if they are present in the URI. - /// - /// The URI of the proxy server, including scheme, host, port, and optional credentials. - /// An instance of IProxyClient configured for the specified proxy. - /// Thrown if the URI scheme is not supported. - /// - /// Note for vmess://: a VMess share link is base64-encoded JSON rather - /// than a host/port URI, and rejects a payload that is longer than - /// its host-length limit or that contains base64 padding — which covers most - /// real-world links. Such a link cannot be turned into a at all, so - /// prefer (or - /// ) to parse the string directly. The - /// special case below exists for the short links that are representable. - /// - public IProxyClient Create(Uri proxyUri) - { - // VLESS carries its whole configuration (uuid, security, sni, …) in the URI, - // so it is parsed as a share link rather than the generic host/port/credential path. - if (proxyUri.Scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) - return new VlessClient(VlessShareLink.Parse(proxyUri.OriginalString)); - - // Trojan likewise carries its whole configuration (password, sni, alpn, …) in the URI. - if (proxyUri.Scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) - return new TrojanClient(TrojanShareLink.Parse(proxyUri.OriginalString)); - - // VMess carries its whole configuration as base64-encoded JSON in the URI body. - if (proxyUri.Scheme.Equals("vmess", StringComparison.OrdinalIgnoreCase)) - return new VmessClient(VmessShareLink.Parse(proxyUri.OriginalString)); - - NetworkCredential? credential = null; - ProxyType type = proxyUri.Scheme switch - { - "http" => ProxyType.Http, - "https" => ProxyType.Https, - "socks4" => ProxyType.Socks4, - "socks4a" => ProxyType.Socks4a, - "socks5" => ProxyType.Socks5, - _ => throw new NotSupportedException( - $"Proxy scheme '{proxyUri.Scheme}' is not supported. This library speaks http, https, socks4, " + - "socks4a, socks5, vless, trojan and vmess.") - }; - - if (!string.IsNullOrEmpty(proxyUri.UserInfo)) - { - var sep = proxyUri.UserInfo.IndexOf(':'); - credential = sep < 0 - ? new NetworkCredential(proxyUri.UserInfo, string.Empty) - : new NetworkCredential( - proxyUri.UserInfo.Substring(0, sep), - proxyUri.UserInfo.Substring(sep + 1)); - } - - return Create(type, proxyUri.Host, proxyUri.Port, credential); - } - - /// - /// Creates an IProxyClient instance based on the specified proxy type, host, and port. - /// - /// The type of proxy to create (e.g., HTTP, SOCKS5). - /// The hostname or IP address of the proxy server. - /// The port number of the proxy server. - /// An instance of IProxyClient configured for the specified proxy type. - /// Thrown if the proxy type is unsupported. - private IProxyClient Create(ProxyType type, string host, int port) - { - return type switch - { - ProxyType.Http => new HttpProxyClient(host, port), - ProxyType.Https => new HttpsProxyClient(host, port), - ProxyType.Socks4 => new Socks4Client(host, port), - ProxyType.Socks4a => new Socks4aClient(host, port), - ProxyType.Socks5 => new Socks5Client(host, port), - _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) - }; - } - - /// - /// Creates an IProxyClient instance with optional credentials, based on the specified proxy type, host, and port. - /// - /// The type of proxy to create (e.g., HTTP, SOCKS5). - /// The hostname or IP address of the proxy server. - /// The port number of the proxy server. - /// Optional credentials for authenticating with the proxy server. - /// An instance of IProxyClient configured for the specified proxy type with the provided credentials. - /// Thrown if the proxy type is unsupported. - public IProxyClient Create(ProxyType type, string host, int port, NetworkCredential? networkCredential) - { - if (networkCredential is null) - { - return Create(type, host, port); - } - - return type switch - { - ProxyType.Http => new HttpProxyClient(host, port, networkCredential), - ProxyType.Https => new HttpsProxyClient(host, port, networkCredential), - ProxyType.Socks4 => new Socks4Client(host, port, networkCredential), - ProxyType.Socks4a => new Socks4aClient(host, port, networkCredential), - ProxyType.Socks5 => new Socks5Client(host, port, networkCredential), - _ => throw new ArgumentOutOfRangeException(nameof(type), type, null) - }; - } -} \ No newline at end of file diff --git a/QuickProxyNet/ProxyProtocolException.cs b/QuickProxyNet/ProxyProtocolException.cs index 7752f68..f7a8b16 100644 --- a/QuickProxyNet/ProxyProtocolException.cs +++ b/QuickProxyNet/ProxyProtocolException.cs @@ -59,5 +59,10 @@ public enum ProxyErrorCode /// The HTTP upgrade to an alternate transport (ws, httpupgrade) failed — the /// server refused it, or answered something that is not a WebSocket handshake. /// - TransportUpgradeFailed + TransportUpgradeFailed, + /// + /// The TLS handshake with the proxy failed: an untrusted or expired certificate, a name the + /// server will not serve, or no shared cipher suite. + /// + TlsHandshakeFailed } diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index 51cc745..46b3cd8 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -34,7 +34,7 @@ await using var stream = await new Uri("http://proxy:8080") - VLESS REALITY and `xtls-rprx-vision` in-process, no Xray binary - Structured errors: `ProxyProtocolException` with `ProxyErrorCode` enum - Per-connection timeouts with `ProxyErrorCode.Timeout` -- Static API (`Proxy.ConnectAsync`) and factory API (`ProxyClientFactory.Instance.Create(link)`) +- Static API (`Proxy.ConnectAsync`) and factory API (`Proxy.Create(link)`) ## Error Handling diff --git a/README.md b/README.md index a4614b9..ccb4185 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ dotnet add package QuickProxyNet ### One-liner from a share link (recommended) -`Proxy.ConnectAsync` and `ProxyClientFactory.Instance.Create` accept the link as a **string** and dispatch on the scheme themselves. This matters for `vmess://` links: they are base64-encoded JSON, and `System.Uri` rejects most real-world ones (host length limit, base64 padding). You no longer have to inspect the scheme yourself to pick a parser. +`Proxy.ConnectAsync` and `Proxy.Create` accept the link as a **string** and dispatch on the scheme themselves. This matters for `vmess://` links: they are base64-encoded JSON, and `System.Uri` rejects most real-world ones (host length limit, base64 padding). You no longer have to inspect the scheme yourself to pick a parser. ```csharp // Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess links @@ -61,7 +61,7 @@ await using var stream = await proxy.ConnectThroughProxyAsync("example.com", 443 ### Factory API (when you need to configure the client) ```csharp -var client = ProxyClientFactory.Instance.Create("socks5://proxy:1080"); +var client = Proxy.Create("socks5://proxy:1080"); client.NoDelay = true; client.ReadTimeout = 5000; @@ -72,7 +72,7 @@ await using var stream = await client.ConnectAsync("example.com", 443); ```csharp var creds = new NetworkCredential("user", "pass"); -var client = ProxyClientFactory.Instance.Create( +var client = Proxy.Create( ProxyType.Socks5, "proxy.example.com", 1080, creds); await using var stream = await client.ConnectAsync("example.com", 80, diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 60608af..eee555e 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -74,7 +74,7 @@ QuickProxyNet/ Публичная поверхность фазы 1: `ProxyType.Vless`, `VlessClient`, `VlessOptions`, `VlessShareLink.Parse(...)`, регистрация схемы `vless` в -`ProxyClientFactory`. +`Proxy`. ### 2.3 Поток VLESS @@ -160,7 +160,7 @@ BenchmarkDotNet 0.15.8, .NET 10, Xeon E5-2697 v4, ShortRun/InProcessNoEmit - [ ] `Configs/VlessOptions` + `VlessShareLink.Parse` + тесты на корпусе - [ ] `Internal/VlessHelper` (build request / read response) + тесты через `FakeProxyStream` - [ ] `Clients/VlessClient` (none + tls) -- [ ] wiring: `ProxyConnector` (для none), `ProxyClientFactory` (схема `vless`) +- [ ] wiring: `ProxyConnector` (для none), `Proxy` (схема `vless`) - [ ] бенчи: UUID, header build, share-link parse — прогнать, записать числа **Фаза 2 — Trojan:** `Internal/Sha224` + тесты (вектора NIST) + бенч, @@ -312,7 +312,7 @@ HMAC-SHA512. Всё, кроме X25519, есть в платформе. Слож ### Публичная форма — решено Внутрь `VlessClient`: `security=reality` в `VlessOptions`, или просто ссылка в -`ProxyClientFactory.Create(string)` / `Proxy.ConnectAsync(string, …)`. Отдельный +`Proxy.Create(string)` / `Proxy.ConnectAsync(string, …)`. Отдельный `RealityClient` и третий пакет отвергнуты — у пользователя в руках `vless://`-ссылка, и она сама говорит, какой режим безопасности нужен. Наружу из `Internal/Reality/` торчит только `RealityHandshakeException : ProxyProtocolException`. diff --git a/docs/quic-protocols-analysis.md b/docs/quic-protocols-analysis.md index f1f85df..1aa630f 100644 --- a/docs/quic-protocols-analysis.md +++ b/docs/quic-protocols-analysis.md @@ -1,7 +1,7 @@ # QUIC protocols (Hysteria2, TUIC): implementation analysis **Статус: ни один из двух протоколов не реализован.** `ProxyType.Hysteria2` и `ProxyType.Tuic` -существуют в `ProxyType.cs`, но `ProxyClientFactory` бросает `ArgumentOutOfRangeException` +существуют в `ProxyType.cs`, но `Proxy` бросает `ArgumentOutOfRangeException` для обоих. Этот документ не пересказывает спецификации — `docs/hysteria2.md`, `docs/hy2.md` и `docs/tuic.md` уже это делают. Здесь отвечаем на два вопроса: *чего реально будет стоить встроить это в библиотеку* и *что при попытке ломается*. @@ -374,17 +374,17 @@ ideal for mass proxy checking."* Для QUIC такая подача ровно handshake плюс аутентификацию. **Статические хелперы должны остаться только для TCP-семейства, и это надо явно написать.** -### 3.4 `ProxyClientFactory` возвращает то, что теперь нужно освобождать +### 3.4 `Proxy` возвращает то, что теперь нужно освобождать `ProxyType.Hysteria2` и `ProxyType.Tuic` уже есть в `ProxyType.cs`, но обе перегрузки -`Create` в `ProxyClientFactory.cs` проваливаются в +`Create` в `Proxy.cs` проваливаются в `throw new ArgumentOutOfRangeException(nameof(type), type, null)`. Заполнить это тривиально. Настоящая проблема в том, что **`IProxyClient` не расширяет ни `IDisposable`, ни `IAsyncDisposable`**. Клиент, кеширующий `QuicConnection`, держит нативный handle MsQuic, фоновый воркер и обязательство по keepalive. Ничто в текущем контракте не говорит вызывающему, что это надо освободить, и ничто в документации -`ProxyClientFactory` не намекает, что у возвращенного объекта есть время жизни. Это +`Proxy` не намекает, что у возвращенного объекта есть время жизни. Это самое крупное последствие добавления QUIC для публичного API. ### 3.5 Члены, устроенные под сокет, становятся бессмысленными или неверными @@ -487,7 +487,7 @@ if (value <= 0 && value != Timeout.Infinite) требует аккуратного разделения handshake. Политика повторного дозвона — это политическое решение, протекающее в тип "просто клиент". - **Цена для API.** `IProxyClient` не получает новых членов, но конкретный клиент обязан - реализовать `IAsyncDisposable`, а контракт `ProxyClientFactory` должен документировать, + реализовать `IAsyncDisposable`, а контракт `Proxy` должен документировать, что возвращенный клиент может требовать освобождения (`if (client is IAsyncDisposable d) await d.DisposeAsync();`). Вызывающие, которые это проигнорируют, утекут открытым QUIC-соединением, пока соответствующий `SafeHandle` не будет финализирован — процесс держит живой туннель, о @@ -510,7 +510,7 @@ await using Stream s2 = await session.OpenAsync("other.com", 80, ct); задача `Connected`/`Closed`, согласованная полоса, флаг поддержки `Hysteria-UDP`). Тестируем без фабрики. - **Минусы.** Вторая форма API в библиотеке, где сейчас ровно одна. Не компонуется даром - с `ProxyClientFactory`/разбором share-ссылок. Вызывающим, которым действительно нужно + с `Proxy`/разбором share-ссылок. Вызывающим, которым действительно нужно одноразовое поведение, придется написать больше кода. - **Цена для API.** Чисто аддитивная — ничего существующего не меняется. @@ -577,7 +577,7 @@ await using Stream s2 = await session.OpenAsync("other.com", 80, ct); NuGet-зависимостей, и `QuickProxyNet.csproj` не нуждается в новом `PackageReference`. То есть ядро остается BCL-only в любом случае, а разделение по этой оси не дает ничего, но стоит второго пакета, расхождения версий между ними и раздробленного -`ProxyClientFactory` (фабрика в пакете A не может сконструировать клиента, который +`Proxy` (фабрика в пакете A не может сконструировать клиента, который существует только в пакете B, без механизма регистрации, которого сегодня нет). Оговорка, впрочем, реальна: разделение позволило бы локализовать проблему @@ -597,7 +597,7 @@ preview-функций на `net8.0` в пакете, который польз Это и есть настоящий API и то, что покрывается модульными тестами. 2. **`Hysteria2Client : IProxyClient, IAsyncDisposable`** — фасад, владеющий ровно одной лениво созданной сессией, с повторным дозвоном при ее смерти, чтобы - `ProxyClientFactory.Create(uri)` и share-ссылки `hysteria2://` / `hy2://` продолжали + `Proxy.Create(uri)` и share-ссылки `hysteria2://` / `hy2://` продолжали работать ровно так же, как для VLESS. `ConnectAsync(Stream, …)` бросает `NotSupportedException` с сообщением, называющим причину (QUIC невозможно согласовать поверх переданного стрима). @@ -610,7 +610,7 @@ preview-функций на `net8.0` в пакете, который польз производительности, наряженная в удобство. 5. **Не расширять `IProxyClient` до `IAsyncDisposable`** — это сломает каждого внешнего реализатора. Вместо этого реализовать его на конкретных QUIC-клиентах и - задокументировать паттерн `is IAsyncDisposable` на `ProxyClientFactory`. Это самое + задокументировать паттерн `is IAsyncDisposable` на `Proxy`. Это самое слабое звено рекомендации, и я хочу быть честным: вызывающий, который никогда не проверяет, утечет живым туннелем. Альтернатива (расширение интерфейса) — жесткое ломающее изменение; если мажорная версия все равно на столе, расширение чище. From 7feb18aa2795038d1c2e327992ca8c1cb32d2df9 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Wed, 9 Sep 2026 00:51:07 +0500 Subject: [PATCH 02/35] feat: speak Shadowsocks AEAD over TCP ShadowsocksClient joins the share-link family next to VLESS, Trojan and VMess. Four AEAD ciphers: aes-128-gcm, aes-192-gcm, aes-256-gcm and chacha20-ietf-poly1305. Salt length equals key length, so aes-192-gcm carries a 24-byte salt. Master key is EVP_BytesToKey(MD5, no salt) over the password; each direction derives its own subkey with HKDF-SHA1 and info "ss-subkey". The nonce is a 12-byte little-endian counter that advances after every AEAD operation, twice per chunk. A chunk carries the plaintext length in two sealed bytes, then the sealed payload; a decrypted length above 0x3FFF is rejected, never masked. The server sends its salt only after the target replies, so the reader takes it lazily on the first Read. ConnectAsync writes salt and address header eagerly and returns. A FIN at a chunk boundary is EOF; anywhere else it is an error, and so is a failed tag. ShadowsocksShareLink reads both grammars in the wild: the legacy blob where the whole authority is base64, and SIP002 with base64, base64url or plain method:password userinfo. Padding may be absent or arrive as %3D. Port defaults to 8388. StripHtmlAmpPrefix keeps plugin= from hiding behind &. Everything else fails by name before a byte is written: the 2022-blake3 family, legacy stream ciphers, none and plain, xchacha20-ietf-poly1305 and the CCM/GCM-SIV/SM4 variants the BCL cannot do, and every plugin. chacha20-ietf-poly1305 also checks ChaCha20Poly1305.IsSupported, which is false on every shipped Windows 10. Wire bytes are pinned to vectors from an independent Python generator validated against RFC 5869, RFC 8439, the NIST GCM cases and the OpenSSL binary. The two-chunk vector is what catches a wrong nonce increment. Docker cases tunnel through Xray and sing-box on both. Write seals consecutive chunks into one send; Read fills a window with one inner read and opens straight into the caller's buffer when it fits. DeriveSubkey builds HKDF from HMACSHA1 one-shots, the client salt lives in an InlineArray, and the share-link parser slices spans instead of allocating substrings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RhwLwckpNR8tqpgrbXK9Fv --- .../ShadowsocksBenchmark.cs | 397 +++++++++ .../Integration/DockerEndpoints.cs | 30 +- .../Integration/DockerProtocolTests.cs | 168 ++++ QuickProxyNet.Tests/ProxyFactoryTest.cs | 105 ++- QuickProxyNet.Tests/ShadowsocksCryptoTest.cs | 493 +++++++++++ .../ShadowsocksHostilePeerTest.cs | 428 ++++++++++ .../ShadowsocksShareLinkTest.cs | 510 ++++++++++++ QuickProxyNet.Tests/ShadowsocksStreamTest.cs | 579 +++++++++++++ QuickProxyNet.Tests/SkipGates.cs | 32 + QuickProxyNet/Clients/ShadowsocksClient.cs | 182 ++++ QuickProxyNet/Configs/ShadowsocksOptions.cs | 48 ++ QuickProxyNet/Configs/ShadowsocksShareLink.cs | 470 +++++++++++ .../Internal/Shadowsocks/ShadowsocksCipher.cs | 408 +++++++++ .../Internal/Shadowsocks/ShadowsocksStream.cs | 780 ++++++++++++++++++ QuickProxyNet/Proxy.cs | 40 +- QuickProxyNet/ProxyType.cs | 3 +- README.md | 22 +- docs/shadowsocks.md | 211 +++++ tests/docker/README.md | 42 +- tests/docker/docker-compose.yml | 9 +- tests/docker/singbox/config.json | 36 + tests/docker/xray/config.json | 45 + 22 files changed, 5008 insertions(+), 30 deletions(-) create mode 100644 QuickProxyNet.Benchmarks/ShadowsocksBenchmark.cs create mode 100644 QuickProxyNet.Tests/ShadowsocksCryptoTest.cs create mode 100644 QuickProxyNet.Tests/ShadowsocksHostilePeerTest.cs create mode 100644 QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs create mode 100644 QuickProxyNet.Tests/ShadowsocksStreamTest.cs create mode 100644 QuickProxyNet/Clients/ShadowsocksClient.cs create mode 100644 QuickProxyNet/Configs/ShadowsocksOptions.cs create mode 100644 QuickProxyNet/Configs/ShadowsocksShareLink.cs create mode 100644 QuickProxyNet/Internal/Shadowsocks/ShadowsocksCipher.cs create mode 100644 QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs create mode 100644 docs/shadowsocks.md diff --git a/QuickProxyNet.Benchmarks/ShadowsocksBenchmark.cs b/QuickProxyNet.Benchmarks/ShadowsocksBenchmark.cs new file mode 100644 index 0000000..f0f8b8f --- /dev/null +++ b/QuickProxyNet.Benchmarks/ShadowsocksBenchmark.cs @@ -0,0 +1,397 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// The Shadowsocks steady-state hot path — sealing and opening AEAD chunks with +/// — measured next to , the in-house +/// reference for AEAD chunk streaming, at the same payload sizes; plus ss:// share-link +/// parsing next to trojan://. +/// +/// +/// +/// Seal writes to and isolates the seal + frame cost. +/// RoundTrip seals into a recycled and opens the chunks back +/// out, so (RoundTrip − Seal) approximates the open cost. SealYield writes to a sink whose +/// WriteAsync always completes asynchronously: that is the shape of a real socket, and the +/// only shape in which an async method's state machine is boxed — so its allocated bytes +/// are the ones a live connection actually pays per write. +/// +/// +/// Payloads: 1 KiB (a small interactive write), 16 383 B (, +/// exactly one full Shadowsocks chunk; VMess cuts it into three) and 1 MiB (65 Shadowsocks +/// chunks, 129 VMess chunks). Shadowsocks pays two AEAD operations per chunk by protocol — the +/// length is sealed on its own — where VMess pays one. AES-128-GCM VMess is the baseline in every +/// category because that is the cipher VMess speaks; the Shadowsocks aes-128-gcm rows +/// separate framing cost from cipher cost, since aes-256-gcm runs 14 AES rounds to 10. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class ShadowsocksBenchmark +{ + private class Config : ManualConfig + { + public Config() => AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const int SmallSize = 1024; + private const int ChunkSize = ShadowsocksStream.MaxPayloadSize; // 16383 + private const int LargeSize = 1024 * 1024; + + private const string Password = "quickproxynet-benchmark-password"; + + // ---- share links: same host, password and remark for every scheme ---- + + private const string TrojanLink = + "trojan://mysecretpassword@cdn.example.com:8443?type=tcp&sni=real.example.com&alpn=h2%2Chttp%2F1.1&allowInsecure=1#my-node"; + + // SIP002, base64 userinfo (standard alphabet, padded — what v2rayN emits). + private static readonly string SsSip002Base64Link = + "ss://" + Convert.ToBase64String(Encoding.UTF8.GetBytes("aes-256-gcm:mysecretpassword")) + + "@cdn.example.com:8443#my-node"; + + // SIP002, plain percent-encoded userinfo (shadowsocks-rust's preferred output). + private const string SsSip002PlainLink = + "ss://aes-256-gcm:mysecretpassword@cdn.example.com:8443#my-node"; + + // Legacy: the whole authority is one base64 blob. + private static readonly string SsLegacyLink = + "ss://" + Convert.ToBase64String(Encoding.UTF8.GetBytes("aes-256-gcm:mysecretpassword@cdn.example.com:8443")) + + "#my-node"; + + // ---- VMess keys (body keys are 16 bytes regardless of cipher) ---- + + private static readonly byte[] VmessClientKey = MakePattern(0xC1, 16); + private static readonly byte[] VmessClientIv = MakePattern(0xC2, 16); + private static readonly byte[] VmessServerKey = MakePattern(0x51, 16); + private static readonly byte[] VmessServerIv = MakePattern(0x52, 16); + + private readonly byte[] _small = MakePattern(0xAB, SmallSize); + private readonly byte[] _chunk = MakePattern(0xCD, ChunkSize); + private readonly byte[] _large = MakePattern(0xEF, LargeSize); + private readonly byte[] _readBuffer = new byte[64 * 1024]; + + // Seal-only streams (sink: Stream.Null). + private ShadowsocksStream _ssAes128Sealer = null!; + private ShadowsocksStream _ssAes256Sealer = null!; + private ShadowsocksStream _ssChaChaSealer = null!; + private VmessStream _vmAes128Sealer = null!; + private VmessStream _vmChaChaSealer = null!; + + // Seal-only streams whose sink always completes asynchronously. + private ShadowsocksStream _ssAes128YieldSealer = null!; + private VmessStream _vmAes128YieldSealer = null!; + + // Loopback pairs (writer + reader over one recycled MemoryStream each). + private Loop _ssAes128Loop; + private Loop _ssAes256Loop; + private Loop _ssChaChaLoop; + private Loop _vmAes128Loop; + private Loop _vmChaChaLoop; + + private struct Loop where T : Stream + { + public MemoryStream Wire; + public T Writer; + public T Reader; + + public void Dispose() + { + Writer.Dispose(); + Reader.Dispose(); + Wire.Dispose(); + } + } + + private static byte[] MakePattern(byte seed, int length) + { + var data = new byte[length]; + for (int i = 0; i < length; i++) + data[i] = (byte)(seed + i * 31); + return data; + } + + private static byte[] MasterKey(ShadowsocksMethod method) + { + byte[] key = new byte[ShadowsocksCipher.KeySize(method)]; + ShadowsocksCipher.DeriveMasterKey(Password, key); + return key; + } + + private static byte[] Salt(ShadowsocksMethod method, byte seed) => + MakePattern(seed, ShadowsocksCipher.SaltSize(method)); + + private static ShadowsocksStream Ss(Stream inner, ShadowsocksMethod method, byte saltSeed) => + new(inner, method, MasterKey(method), Salt(method, saltSeed), leaveInnerOpen: true); + + private static VmessStream VmWriter(Stream inner, VmessSecurity security) => + new(inner, VmessClientKey, VmessClientIv, VmessServerKey, VmessServerIv, security, leaveInnerOpen: true); + + // The reader's read direction mirrors the writer's write direction. + private static VmessStream VmReader(Stream inner, VmessSecurity security) => + new(inner, VmessServerKey, VmessServerIv, VmessClientKey, VmessClientIv, security, leaveInnerOpen: true); + + private static Loop SsLoop(ShadowsocksMethod method) + { + // The reader keys its read direction from whatever salt arrives, exactly like a server; + // its own write direction (and salt) is never used. + var wire = new MemoryStream(LargeSize + 64 * 1024); + return new Loop + { + Wire = wire, + Writer = Ss(wire, method, 0x11), + Reader = Ss(wire, method, 0x22), + }; + } + + private static Loop VmLoop(VmessSecurity security) + { + var wire = new MemoryStream(LargeSize + 64 * 1024); + return new Loop + { + Wire = wire, + Writer = VmWriter(wire, security), + Reader = VmReader(wire, security), + }; + } + + [GlobalSetup] + public void Setup() + { + _ssAes128Sealer = Ss(Stream.Null, ShadowsocksMethod.Aes128Gcm, 0x31); + _ssAes256Sealer = Ss(Stream.Null, ShadowsocksMethod.Aes256Gcm, 0x32); + _ssChaChaSealer = Ss(Stream.Null, ShadowsocksMethod.ChaCha20Poly1305, 0x33); + _vmAes128Sealer = VmWriter(Stream.Null, VmessSecurity.Aes128Gcm); + _vmChaChaSealer = VmWriter(Stream.Null, VmessSecurity.ChaCha20Poly1305); + + _ssAes128YieldSealer = Ss(new YieldingSink(), ShadowsocksMethod.Aes128Gcm, 0x41); + _vmAes128YieldSealer = VmWriter(new YieldingSink(), VmessSecurity.Aes128Gcm); + + _ssAes128Loop = SsLoop(ShadowsocksMethod.Aes128Gcm); + _ssAes256Loop = SsLoop(ShadowsocksMethod.Aes256Gcm); + _ssChaChaLoop = SsLoop(ShadowsocksMethod.ChaCha20Poly1305); + _vmAes128Loop = VmLoop(VmessSecurity.Aes128Gcm); + _vmChaChaLoop = VmLoop(VmessSecurity.ChaCha20Poly1305); + } + + [GlobalCleanup] + public void Cleanup() + { + _ssAes128Sealer.Dispose(); + _ssAes256Sealer.Dispose(); + _ssChaChaSealer.Dispose(); + _vmAes128Sealer.Dispose(); + _vmChaChaSealer.Dispose(); + _ssAes128YieldSealer.Dispose(); + _vmAes128YieldSealer.Dispose(); + _ssAes128Loop.Dispose(); + _ssAes256Loop.Dispose(); + _ssChaChaLoop.Dispose(); + _vmAes128Loop.Dispose(); + _vmChaChaLoop.Dispose(); + } + + // ============================ seal only → Stream.Null ============================ + + [Benchmark(Baseline = true), BenchmarkCategory("Seal 1K")] + public ValueTask Seal_1K_Vmess_Aes128() => _vmAes128Sealer.WriteAsync(_small.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1K")] + public ValueTask Seal_1K_Ss_Aes128() => _ssAes128Sealer.WriteAsync(_small.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1K")] + public ValueTask Seal_1K_Ss_Aes256() => _ssAes256Sealer.WriteAsync(_small.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1K")] + public ValueTask Seal_1K_Vmess_ChaCha() => _vmChaChaSealer.WriteAsync(_small.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1K")] + public ValueTask Seal_1K_Ss_ChaCha() => _ssChaChaSealer.WriteAsync(_small.AsMemory()); + + [Benchmark(Baseline = true), BenchmarkCategory("Seal 16K")] + public ValueTask Seal_16K_Vmess_Aes128() => _vmAes128Sealer.WriteAsync(_chunk.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 16K")] + public ValueTask Seal_16K_Ss_Aes128() => _ssAes128Sealer.WriteAsync(_chunk.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 16K")] + public ValueTask Seal_16K_Ss_Aes256() => _ssAes256Sealer.WriteAsync(_chunk.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 16K")] + public ValueTask Seal_16K_Vmess_ChaCha() => _vmChaChaSealer.WriteAsync(_chunk.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 16K")] + public ValueTask Seal_16K_Ss_ChaCha() => _ssChaChaSealer.WriteAsync(_chunk.AsMemory()); + + [Benchmark(Baseline = true), BenchmarkCategory("Seal 1M")] + public ValueTask Seal_1M_Vmess_Aes128() => _vmAes128Sealer.WriteAsync(_large.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1M")] + public ValueTask Seal_1M_Ss_Aes128() => _ssAes128Sealer.WriteAsync(_large.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1M")] + public ValueTask Seal_1M_Ss_Aes256() => _ssAes256Sealer.WriteAsync(_large.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1M")] + public ValueTask Seal_1M_Vmess_ChaCha() => _vmChaChaSealer.WriteAsync(_large.AsMemory()); + + [Benchmark, BenchmarkCategory("Seal 1M")] + public ValueTask Seal_1M_Ss_ChaCha() => _ssChaChaSealer.WriteAsync(_large.AsMemory()); + + // ======================== seal + open, MemoryStream loopback ======================== + + [Benchmark(Baseline = true), BenchmarkCategory("RoundTrip 1K")] + public Task RoundTrip_1K_Vmess_Aes128() => RoundTrip(_vmAes128Loop, _small); + + [Benchmark, BenchmarkCategory("RoundTrip 1K")] + public Task RoundTrip_1K_Ss_Aes128() => RoundTrip(_ssAes128Loop, _small); + + [Benchmark, BenchmarkCategory("RoundTrip 1K")] + public Task RoundTrip_1K_Ss_Aes256() => RoundTrip(_ssAes256Loop, _small); + + [Benchmark, BenchmarkCategory("RoundTrip 1K")] + public Task RoundTrip_1K_Vmess_ChaCha() => RoundTrip(_vmChaChaLoop, _small); + + [Benchmark, BenchmarkCategory("RoundTrip 1K")] + public Task RoundTrip_1K_Ss_ChaCha() => RoundTrip(_ssChaChaLoop, _small); + + [Benchmark(Baseline = true), BenchmarkCategory("RoundTrip 16K")] + public Task RoundTrip_16K_Vmess_Aes128() => RoundTrip(_vmAes128Loop, _chunk); + + [Benchmark, BenchmarkCategory("RoundTrip 16K")] + public Task RoundTrip_16K_Ss_Aes128() => RoundTrip(_ssAes128Loop, _chunk); + + [Benchmark, BenchmarkCategory("RoundTrip 16K")] + public Task RoundTrip_16K_Ss_Aes256() => RoundTrip(_ssAes256Loop, _chunk); + + [Benchmark, BenchmarkCategory("RoundTrip 16K")] + public Task RoundTrip_16K_Vmess_ChaCha() => RoundTrip(_vmChaChaLoop, _chunk); + + [Benchmark, BenchmarkCategory("RoundTrip 16K")] + public Task RoundTrip_16K_Ss_ChaCha() => RoundTrip(_ssChaChaLoop, _chunk); + + [Benchmark(Baseline = true), BenchmarkCategory("RoundTrip 1M")] + public Task RoundTrip_1M_Vmess_Aes128() => RoundTrip(_vmAes128Loop, _large); + + [Benchmark, BenchmarkCategory("RoundTrip 1M")] + public Task RoundTrip_1M_Ss_Aes128() => RoundTrip(_ssAes128Loop, _large); + + [Benchmark, BenchmarkCategory("RoundTrip 1M")] + public Task RoundTrip_1M_Ss_Aes256() => RoundTrip(_ssAes256Loop, _large); + + [Benchmark, BenchmarkCategory("RoundTrip 1M")] + public Task RoundTrip_1M_Vmess_ChaCha() => RoundTrip(_vmChaChaLoop, _large); + + [Benchmark, BenchmarkCategory("RoundTrip 1M")] + public Task RoundTrip_1M_Ss_ChaCha() => RoundTrip(_ssChaChaLoop, _large); + + private async Task RoundTrip(Loop loop, byte[] payload) where T : Stream + { + MemoryStream wire = loop.Wire; + wire.Position = 0; + wire.SetLength(0); + await loop.Writer.WriteAsync(payload.AsMemory()); + + wire.Position = 0; + int total = 0; + while (total < payload.Length) + { + int read = await loop.Reader.ReadAsync(_readBuffer.AsMemory()); + if (read == 0) + throw new InvalidOperationException("Unexpected end of stream."); + total += read; + } + + return total; + } + + // ================== seal → a sink that always completes asynchronously ================== + + [Benchmark(Baseline = true), BenchmarkCategory("SealYield 1K")] + public Task SealYield_1K_Vmess_Aes128() => _vmAes128YieldSealer.WriteAsync(_small.AsMemory()).AsTask(); + + [Benchmark, BenchmarkCategory("SealYield 1K")] + public Task SealYield_1K_Ss_Aes128() => _ssAes128YieldSealer.WriteAsync(_small.AsMemory()).AsTask(); + + [Benchmark(Baseline = true), BenchmarkCategory("SealYield 16K")] + public Task SealYield_16K_Vmess_Aes128() => _vmAes128YieldSealer.WriteAsync(_chunk.AsMemory()).AsTask(); + + [Benchmark, BenchmarkCategory("SealYield 16K")] + public Task SealYield_16K_Ss_Aes128() => _ssAes128YieldSealer.WriteAsync(_chunk.AsMemory()).AsTask(); + + [Benchmark(Baseline = true), BenchmarkCategory("SealYield 1M")] + public Task SealYield_1M_Vmess_Aes128() => _vmAes128YieldSealer.WriteAsync(_large.AsMemory()).AsTask(); + + [Benchmark, BenchmarkCategory("SealYield 1M")] + public Task SealYield_1M_Ss_Aes128() => _ssAes128YieldSealer.WriteAsync(_large.AsMemory()).AsTask(); + + /// + /// A write sink whose WriteAsync never completes synchronously, so every awaiting + /// async frame above it has to box its state machine — the way a real socket makes + /// it. The yield itself costs both protocols the same. + /// + private sealed class YieldingSink : Stream + { + public override bool CanRead => false; + 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 void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) { } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => await Task.Yield(); + } + + // ================================ share-link parse ================================ + + [Benchmark(Baseline = true), BenchmarkCategory("Parse")] + public int Parse_Trojan() + { + TrojanShareLink.TryParse(TrojanLink, out TrojanOptions o); + return o.Host.Length + o.Port; + } + + [Benchmark, BenchmarkCategory("Parse")] + public int Parse_Ss_Sip002_Base64() + { + ShadowsocksShareLink.TryParse(SsSip002Base64Link, out ShadowsocksOptions o); + return o.Host.Length + o.Port; + } + + [Benchmark, BenchmarkCategory("Parse")] + public int Parse_Ss_Sip002_Plain() + { + ShadowsocksShareLink.TryParse(SsSip002PlainLink, out ShadowsocksOptions o); + return o.Host.Length + o.Port; + } + + [Benchmark, BenchmarkCategory("Parse")] + public int Parse_Ss_Legacy() + { + ShadowsocksShareLink.TryParse(SsLegacyLink, out ShadowsocksOptions o); + return o.Host.Length + o.Port; + } +} diff --git a/QuickProxyNet.Tests/Integration/DockerEndpoints.cs b/QuickProxyNet.Tests/Integration/DockerEndpoints.cs index 79c691f..3950c14 100644 --- a/QuickProxyNet.Tests/Integration/DockerEndpoints.cs +++ b/QuickProxyNet.Tests/Integration/DockerEndpoints.cs @@ -43,6 +43,7 @@ public enum Server public const string VmessWsId = "66666666-6666-4666-8666-666666666666"; public const string VlessHttpUpgradeId = "77777777-7777-4777-8777-777777777777"; public const string TrojanPassword = "qpn-test-trojan-password"; + public const string ShadowsocksPassword = "qpn-test-ss-password"; /// /// Paths the ws/httpupgrade inbounds are configured with. A WebSocket server only upgrades @@ -83,6 +84,17 @@ public enum Server public const int SingBoxTrojanWs = 24819; public const int SingBoxVlessHttpUpgrade = 24820; + // Shadowsocks: container ports 10011..10014. The +24800/+24810 arithmetic of the first decade + // cannot hold for both servers past 10010, so each server gets a fresh decade: xray 2482x, + // sing-box 2483x. aes-192-gcm exists on sing-box only — Xray has no such cipher. + public const int XrayShadowsocksAes256 = 24821; + public const int XrayShadowsocksChacha = 24822; + public const int XrayShadowsocksAes128 = 24823; + public const int SingBoxShadowsocksAes256 = 24831; + public const int SingBoxShadowsocksChacha = 24832; + public const int SingBoxShadowsocksAes128 = 24833; + public const int SingBoxShadowsocksAes192 = 24834; + /// Every mapped host port, used as the readiness probe list. public static readonly (int Port, string Description)[] All = [ @@ -104,7 +116,14 @@ public static readonly (int Port, string Description)[] All = (SingBoxVlessWs, "sing-box vless over ws"), (SingBoxVmessWs, "sing-box vmess over ws"), (SingBoxTrojanWs, "sing-box trojan over ws"), - (SingBoxVlessHttpUpgrade, "sing-box vless over httpupgrade") + (SingBoxVlessHttpUpgrade, "sing-box vless over httpupgrade"), + (XrayShadowsocksAes256, "xray shadowsocks aes-256-gcm"), + (XrayShadowsocksChacha, "xray shadowsocks chacha20-ietf-poly1305"), + (XrayShadowsocksAes128, "xray shadowsocks aes-128-gcm"), + (SingBoxShadowsocksAes256, "sing-box shadowsocks aes-256-gcm"), + (SingBoxShadowsocksChacha, "sing-box shadowsocks chacha20-ietf-poly1305"), + (SingBoxShadowsocksAes128, "sing-box shadowsocks aes-128-gcm"), + (SingBoxShadowsocksAes192, "sing-box shadowsocks aes-192-gcm") ]; public static int VlessNonePort(Server server) => server is Server.Xray ? XrayVlessNone : SingBoxVlessNone; @@ -118,4 +137,13 @@ public static readonly (int Port, string Description)[] All = public static int VlessHttpUpgradePort(Server server) => server is Server.Xray ? XrayVlessHttpUpgrade : SingBoxVlessHttpUpgrade; + + public static int ShadowsocksAes256Port(Server server) => + server is Server.Xray ? XrayShadowsocksAes256 : SingBoxShadowsocksAes256; + + public static int ShadowsocksChachaPort(Server server) => + server is Server.Xray ? XrayShadowsocksChacha : SingBoxShadowsocksChacha; + + public static int ShadowsocksAes128Port(Server server) => + server is Server.Xray ? XrayShadowsocksAes128 : SingBoxShadowsocksAes128; } diff --git a/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs b/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs index d2b9ecb..4ad07c4 100644 --- a/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs +++ b/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs @@ -146,6 +146,174 @@ public async Task Vmess_ChaCha20Poly1305_RoundTrip(Server server) await AssertEchoRoundTripAsync(client); } + // ----------------------------------------------------------------------------- Shadowsocks + + /// + /// The Shadowsocks server sends its salt only after the target has replied, so a bare + /// connect proves nothing here either — the round trip is what validates the lazy salt read, + /// the read-direction subkey and the chunk framing against a real peer. + /// + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Shadowsocks_Aes256Gcm_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-256-gcm", + Password = ShadowsocksPassword, + Host = "127.0.0.1", + Port = ShadowsocksAes256Port(server) + }); + + await AssertEchoRoundTripAsync(client); + } + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Shadowsocks_ChaCha20Poly1305_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = "chacha20-ietf-poly1305", + Password = ShadowsocksPassword, + Host = "127.0.0.1", + Port = ShadowsocksChachaPort(server) + }); + + await AssertEchoRoundTripAsync(client); + } + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Shadowsocks_Aes128Gcm_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-128-gcm", + Password = ShadowsocksPassword, + Host = "127.0.0.1", + Port = ShadowsocksAes128Port(server) + }); + + await AssertEchoRoundTripAsync(client); + } + + /// + /// aes-192-gcm is outside the spec table: sing-box and shadowsocks-libev speak it, Xray + /// has no such cipher, so this inbound is sing-box only. Its 24-byte salt is the detail no + /// vector from a permissive implementation could pin — none of them supports the cipher. + /// + [DockerFact] + public async Task Shadowsocks_Aes192Gcm_RoundTrip_SingBoxOnly() + { + _docker.EnsureUp(); + + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-192-gcm", + Password = ShadowsocksPassword, + Host = "127.0.0.1", + Port = SingBoxShadowsocksAes192 + }); + + await AssertEchoRoundTripAsync(client); + } + + /// + /// The same link through the public string entry point, so the factory registration, the + /// share-link parser and the client are proven together against a real server. + /// + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Shadowsocks_PublicApi_ShareLink_RoundTrip(Server server) + { + _docker.EnsureUp(); + + string userInfo = Convert.ToBase64String(Encoding.UTF8.GetBytes($"aes-256-gcm:{ShadowsocksPassword}")) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var client = Proxy.Create($"ss://{userInfo}@127.0.0.1:{ShadowsocksAes256Port(server)}#docker"); + + Assert.Equal(ProxyType.Shadowsocks, client.Type); + await AssertEchoRoundTripAsync((ProxyClient)client); + } + + /// + /// A wrong password must not round trip, and must not fail at connect: the server sends + /// nothing it cannot decrypt, so ConnectAsync succeeds and the failure lands on the + /// first Read. Without this, the tests above would still pass if the server accepted + /// anything at all. + /// + /// + /// + /// Xray answers an unauthenticated first chunk by draining a pseudo-random number of further + /// bytes before closing: NewBehaviorSeedLimitedDrainer(seed, 16+38, 3266, 64) in + /// proxy/shadowsocks/protocol.go, i.e. under 3400 bytes (54 + 3266 + 64 at the very most) + /// counted from the first byte received. A bare HTTP request is under 200 bytes, which is why an earlier + /// version of this test had to accept a timeout — and a timeout is what a client whose first + /// Read simply hangs also produces. So the request is followed by 4096 filler bytes: the + /// drainer runs out on both servers, they close, and the only passing outcome is a close on + /// the first Read, never a timeout. + /// + /// + /// The close is a FIN or a RST — Linux resets when a socket is closed with unread data in it, + /// and both servers leave our surplus unread. Through the docker port proxy that usually + /// arrives as a FIN (, ); + /// a RST surfaces as the transport's . Both are "the server closed on + /// us"; a successful round trip, a clean 0, a cancellation or any other exception fails the test. + /// + /// + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Shadowsocks_WrongPassword_ConnectSucceeds_ReadFails(Server server) + { + _docker.EnsureUp(); + + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-256-gcm", + Password = ShadowsocksPassword + "-wrong", + Host = "127.0.0.1", + Port = ShadowsocksAes256Port(server) + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + await using Stream stream = await client.ConnectAsync(EchoHost, EchoPort, ConnectTimeout, cts.Token); + + byte[] request = Encoding.ASCII.GetBytes($"GET / HTTP/1.1\r\nHost: {EchoHost}\r\nConnection: close\r\n\r\n"); + byte[] filler = new byte[4096]; // more than Xray's drainer can ever want; see remarks + + try + { + await stream.WriteAsync(request, cts.Token); + await stream.WriteAsync(filler, cts.Token); + await stream.FlushAsync(cts.Token); + + int read = await stream.ReadAsync(new byte[4096], cts.Token); + Assert.Fail($"a wrong password must not yield data or a clean close, but Read returned {read}"); + } + catch (ProxyProtocolException ex) + { + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + catch (IOException ex) when (ex is not EndOfStreamException) + { + // RST: the server closed with our filler still unread. Still a close, still not a + // timeout — and only reachable after the drainer gave up. A raw EndOfStreamException + // is excluded on purpose: the tunnel must translate a FIN, so one escaping is a bug. + } + } + // ------------------------------------------------------------ ws / httpupgrade transports /// diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index 2cfe5fe..f395244 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -123,11 +123,89 @@ public void Create_WithoutAScheme_ThrowsArgumentException(string link) public void Create_UnsupportedScheme_DoesNotEchoTheWholeLink() { var ex = Assert.Throws( - () => Create("ss://verySecretPasswordThatMustNotLeak@example.com:8388")); + () => Create("hysteria2://verySecretPasswordThatMustNotLeak@example.com:443")); Assert.DoesNotContain("verySecretPasswordThatMustNotLeak", ex.Message); } + // === Shadowsocks === + + [Fact] + public void Create_Shadowsocks_ReturnsAShadowsocksClient_AndKeepsTheLink() + { + // base64url("aes-256-gcm:password") + const string link = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388#node"; + + var client = Assert.IsType(Create(link)); + + Assert.Equal(ProxyType.Shadowsocks, client.Type); + Assert.Equal("example.com", client.Options.Host); + Assert.Equal(8388, client.Options.Port); + Assert.Equal("aes-256-gcm", client.Options.Method); + Assert.Equal("password", client.Options.Password); + Assert.Equal(link, client.SourceLink); + Assert.Equal("ss://example.com:8388/", client.ProxyUri.ToString()); + } + + [Fact] + public void Create_Shadowsocks_FromUri_MatchesTheStringOverload() + { + const string link = "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388/?plugin=#node"; + + var client = Assert.IsType(Proxy.Create(new Uri(link))); + + Assert.Equal(link, client.SourceLink); + Assert.Equal("password", client.Options.Password); + } + + [Fact] + public void Create_Shadowsocks_LegacyGrammar() + { + // base64("aes-256-gcm:p@ss:w0rd@example.com:8388") + var client = Assert.IsType(Create("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODg=#legacy")); + Assert.Equal("p@ss:w0rd", client.Options.Password); + } + + /// + /// AEAD-2022 wears the same scheme but is a different protocol. Walking a subscription must + /// see the cipher name in the reason, not a generic "unsupported". + /// + [Fact] + public void TryCreate_Shadowsocks2022_IsRejectedWithTheCipherName() + { + Assert.False(Proxy.TryCreate( + "ss://2022-blake3-aes-256-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D@192.168.100.1:8888#Example3", + out IProxyClient? client, out string? error)); + + Assert.Null(client); + Assert.NotNull(error); + Assert.StartsWith("NotSupportedException:", error); + Assert.Contains("2022-blake3-aes-256-gcm", error); + Assert.DoesNotContain("YctPZ6U7", error); // the PSK is a credential + } + + [Fact] + public void TryCreate_ShadowsocksWithPlugin_IsRejectedWithThePluginName() + { + Assert.False(Proxy.TryCreate( + "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@example.com:8388/?plugin=v2ray-plugin%3Bmode%3Dwebsocket", + out _, out string? error)); + + Assert.Contains("NotSupportedException", error); + Assert.Contains("v2ray-plugin", error); + } + + [Fact] + public async Task ProxyConnect_WithAShadowsocksLink_GetsPastParsingIntoTheNetwork() + { + var ex = await Assert.ThrowsAsync(async () => + await Proxy.ConnectAsync( + $"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@127.0.0.1:{UnusedPort()}", + "example.com", 443, TimeSpan.FromSeconds(5))); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + [Fact] public void Create_MalformedKnownScheme_ThrowsFormat() { @@ -229,7 +307,9 @@ public void TryCreate_GoodLink_ReturnsTrueAndNoError(string link) [InlineData("")] // empty [InlineData(" ")] // whitespace only [InlineData("example.com:1080")] // no scheme - [InlineData("ss://not-a-scheme-we-speak@host:443")] // unsupported scheme + [InlineData("hysteria2://not-a-scheme-we-speak@host:443")] // unsupported scheme + [InlineData("ss://not!base64!@host:443")] // known scheme, broken userinfo + [InlineData("ss://rc4-md5:pw@host:8388")] // known scheme, cipher we refuse [InlineData("vmess://this-is-not-base64-json")] // known scheme, broken payload [InlineData("vless://" + "a-31-character-id-aaaaaaaaaaaaa" + "@example.com:443")] // id length 31: too long to derive, too short to be hex public void TryCreate_BadLink_ReturnsFalseWithAReason(string link) @@ -304,12 +384,27 @@ public void SourceLink_FromExplicitSettings_IsNull() [InlineData(ProxyType.Vless)] [InlineData(ProxyType.Vmess)] [InlineData(ProxyType.Trojan)] + [InlineData(ProxyType.Shadowsocks)] public void Create_ShareLinkFamilyFromHostAndPort_IsRejected(ProxyType type) { - // These carry a uuid, a security mode and a transport. Host and port cannot express them, - // and silently building a client that cannot connect would be worse than saying so. - Assert.Throws( + // These carry a uuid, a security mode, a cipher and a transport. Host and port cannot + // express them, and silently building a client that cannot connect would be worse than + // saying so. With credentials too: Shadowsocks looks like user/password, and is not. + var ex = Assert.Throws( () => Proxy.Create(type, "example.com", 443, credentials: null)); + Assert.Contains("Shadowsocks", ex.Message); + + Assert.Throws( + () => Proxy.Create(type, "example.com", 443, new NetworkCredential("aes-256-gcm", "password"))); + } + + [Fact] + public void ProxyType_Shadowsocks_IsAppendedNotInserted() + { + // The enum has no explicit values: inserting a member renumbers everything after it, a + // silent breaking change for anything that persisted the number. + Assert.Equal(8, (int)ProxyType.Shadowsocks); + Assert.Equal(7, (int)ProxyType.Trojan); } /// A port that was bound and immediately released — nothing is listening on it. diff --git a/QuickProxyNet.Tests/ShadowsocksCryptoTest.cs b/QuickProxyNet.Tests/ShadowsocksCryptoTest.cs new file mode 100644 index 0000000..a379f4c --- /dev/null +++ b/QuickProxyNet.Tests/ShadowsocksCryptoTest.cs @@ -0,0 +1,493 @@ +using System.Security.Cryptography; +using System.Text; +using QuickProxyNet.Tests.Helpers; + +// CA2022 ("avoid inexact reads") warns whenever a single ReadAsync is expected to fill a +// buffer. Chunk-at-a-time delivery is exactly what these tests assert, so the analyzer is +// off for this file. +#pragma warning disable CA2022 + +namespace QuickProxyNet.Tests; + +/// +/// Byte-exact vectors for the Shadowsocks AEAD (SIP004/SIP007) key schedule, chunk framing and +/// first client packet: , and +/// . +/// +/// Every value below is GROUND TRUTH produced by an independent Python implementation +/// (scratchpad/ss/vectors.py, written from shadowsocks.org/doc/aead alone — `cryptography` for +/// the AEADs, hashlib/hmac for MD5 and HKDF). Before emitting a single vector that script +/// validates itself against published data: RFC 5869 HKDF-SHA1 test cases 4–7, RFC 8439 §2.8.2 +/// for ChaCha20-Poly1305, NIST GCM test cases 3 (AES-128) and 16 (AES-256), and EVP_BytesToKey +/// against the actual OpenSSL 3.5.6 binary (`openssl enc -k pass -md md5 -nosalt -P`, three key +/// sizes, two passwords). There is no official end-to-end Shadowsocks AEAD vector anywhere — +/// neither the spec nor the reference implementations publish one — so this compositional +/// validation is the strongest available. A failure here means the code is wrong, not the test. +/// +/// Fixed inputs: password "quickproxynet-test-password"; salt[i] = (i·37 + 11) mod 256; +/// payload pattern data[i] = i mod 256. The 0x3FFF fixtures are ~33 KB of hex each, so they are +/// pinned by the SHA-256 of the wire after the salt; everything else is pinned as full hex. +/// +public class ShadowsocksCryptoTest +{ + private const string Password = "quickproxynet-test-password"; + + private const string Aes128 = "aes-128-gcm"; + private const string Aes192 = "aes-192-gcm"; + private const string Aes256 = "aes-256-gcm"; + private const string ChaCha = "chacha20-ietf-poly1305"; + + // ---- pinned address headers (cipher-independent) ---- + private const string HeaderDomain443 = "030b6578616d706c652e636f6d01bb"; + private const string HeaderIpv4 = "01010203040050"; + private const string HeaderIpv6 = "0420010db80000000000000000000000011f90"; + private const string HeaderDomain80 = "030b6578616d706c652e636f6d0050"; + private const string InitialPayload = "474554202f20485454502f312e300d0a0d0a"; // "GET / HTTP/1.0\r\n\r\n" + + // ---- pinned payload digests for the oversize fixtures ---- + private const string PatternSha16383 = "fab82f1352405c22ca2953ff80a508e5567c51e1a9aeb57cf9a56447e40ba066"; + private const string PatternSha16384 = "a1f259d4365ed4320c377ce26f5c8c56dcdc9a89e7b641bfd8eabfbbeac86654"; + + private sealed record Vector( + string Name, + int KeySize, + string MasterKey, + string Salt, + string Subkey, + string EmptyChunk, + string OneByteChunk, + string MaxChunkSha256, + string MaxChunkPlusOneSha256, + string PacketDomain, + string PacketIpv4, + string PacketIpv6, + string PacketDomainWithPayload); + + // Emitted mechanically from vectors.json (a python one-liner), not transcribed by hand. + private static readonly Dictionary Vectors = new() + { + ["aes-128-gcm"] = new( + Name: "aes-128-gcm", + KeySize: 16, + MasterKey: "58fa331ca37b32eadf1893359b65e3ca", + Salt: "0b30557a9fc4e90e33587da2c7ec1136", + Subkey: "c7ae681c327f172529a33539a9320d55", + EmptyChunk: "377fbc0fdb661e461222a8844cb594476d1a77a4bf9e48325e514b87740b59ef37b0", + OneByteChunk: "377eb914669adf115506ea7b59f2dc41dd20cdbf04b2414736ef4975013acb2ee144cd", + MaxChunkSha256: "4c99ca649a6fe0f5a64e5c268d5022a5d1759ba0092f36657640353518a7c7f9", + MaxChunkPlusOneSha256: "a9e14d3d7441fe13323622c64f9f6005da833cd76e1da3d2b6802a69508e174b", + PacketDomain: "0b30557a9fc4e90e33587da2c7ec113637708f9005765258fefd73818c5b2c63fc6c8f4a221c73bf5b2ce8b3511a11e70e58a637a2332b006b98b278e63b7ac3ad", + PacketIpv4: "0b30557a9fc4e90e33587da2c7ec11363778a74dea9058e2c7df647926616c567dbc8d40456716d27bc651756a316e943f0428b59f6ddbec91", + PacketIpv6: "0b30557a9fc4e90e33587da2c7ec1136376ce298c2af48cba90a40742708cc27bef488614669aad22b408d9d32757ce6b5e59bc93a61874411e6760124d545ad26f3f62301", + PacketDomainWithPayload: "0b30557a9fc4e90e33587da2c7ec1136375e1a63d902f5f9b18eb599f11bdc97da608f4a221c73bf5b2ce8b3511a11e6e5a2df828a2ae686f10c6bdb7e749ceb76605d64edcdd7a718d0c8dea9e5fbca708e7b"), + ["aes-192-gcm"] = new( + Name: "aes-192-gcm", + KeySize: 24, + MasterKey: "58fa331ca37b32eadf1893359b65e3ca03c988218e317c2e", + Salt: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e", + Subkey: "87deca5cda8d57c120edbc217e8b4d5de6241c6f79f25f38", + EmptyChunk: "d4c487b8a2cda1f580de46ff530363e3be759d3f226be1277ff0377c5ac121e38cf0", + OneByteChunk: "d4c543571fc220d6ab2c8fe3c405f2722cb54250ce5df7d8efc139e4762717d06baad8", + MaxChunkSha256: "b054bf947a3df294ac1bce446eaa99a6cee2f9778a77a43ee93cc2a1627f670e", + MaxChunkPlusOneSha256: "9b3788ebe17525088c72d15a874f00c16344bec7885fcd7ddafe04c516ed11ff", + PacketDomain: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395ed4cb724b799f2f050c94714c0e261b9bd433001cc1dc3d54f84c449f1b1aad1e35a8c31188cf4434a0fa9e0bef1158216d", + PacketIpv4: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395ed4c39f3691e3261c530239a8b612971742360216a6a75839d8a83e129a69cebd84fa20f39d20446bcb", + PacketIpv6: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395ed4d71073b52530a243e58c139a61c848253f0737a5a9e439882021b17875c01f8e38bc321d262b8f2217ddbfe1be314a95da5c1a2e", + PacketDomainWithPayload: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395ed4e572a0be3204b3d575ac7124d7c04074a3001cc1dc3d54f84c449f1b1aad1fde7ff879add328e0ab7b4450279ab296eb941e33bf9f75ba97a5ba207046bf6309fbb4"), + ["aes-256-gcm"] = new( + Name: "aes-256-gcm", + KeySize: 32, + MasterKey: "58fa331ca37b32eadf1893359b65e3ca03c988218e317c2e0b194f5a11876dda", + Salt: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186", + Subkey: "5ede2ce410b9cae4302a9adac4ba9c8bd2b7d8b1852e16528db3779fed55e30a", + EmptyChunk: "6fad51a59b7e126d2ea494b7c8bfeb5d79e8af8880d92435b36887a8aa873a969428", + OneByteChunk: "6fac3aa79051d37a98130772dd2e8fd52904dfc714e2fecbf5596fe7d4ea31ecba37bf", + MaxChunkSha256: "b0e7533e7d48451729fbcc58df258e92cc03fbbba79b3501c31bde8326aa2b61", + MaxChunkPlusOneSha256: "b73f9ae2ae797610798f05a890ea061a90e0e31ea9bd99fd92ccc2f3e6a0815a", + PacketDomain: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c61866fa25ebbf3cf5db69a1ef1c40cc2f0a64c0f9d62e968382724cec0b21602fb4088428dbb64c65536ca356e914104d7b7d1", + PacketIpv4: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c61866faa82abaab1550b2fa26feca049d4e4cb6d9f688e135d4a04695b3d63085c41e7fc029d8154e65d3d", + PacketIpv6: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c61866fbe968334f2402e9e051ca9af1a0e4086199a498d1de14a54a2a59c756d964133d4dbeaa9b97ce21b1425a3c9ac294994c52f2f43", + PacketDomainWithPayload: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c61866f8ccee6f5a9f18c4ee17fd06f021edf348e9d62e968382724cec0b21602fb4163939fa119079b6d5fac8e6d053f4d5b2e073a8972828fb822cd70d2978e4c0f0d79f9"), + ["chacha20-ietf-poly1305"] = new( + Name: "chacha20-ietf-poly1305", + KeySize: 32, + MasterKey: "58fa331ca37b32eadf1893359b65e3ca03c988218e317c2e0b194f5a11876dda", + Salt: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186", + Subkey: "5ede2ce410b9cae4302a9adac4ba9c8bd2b7d8b1852e16528db3779fed55e30a", + EmptyChunk: "f40e92617f73950e0ef6c2d1952a45a38474b13aee3acdd4b26b1a56817a8ed68574", + OneByteChunk: "f40f3e9eb189e9872eae3341d5e99babeabcf20afc3d3149bf21207735ee5f406f3a83", + MaxChunkSha256: "69e027fd8eeea6eb6f4589d6c78042178ac36a7aabf833da1dfbd3124454bda9", + MaxChunkPlusOneSha256: "c75a1b1398a0699566c846f1e8441a9457eeded2a0e6c084b1ce390f103b58a6", + PacketDomain: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186f401e04cf25250e5679c09295d74dd3656c7b0f1fae15deaccbd5b6b7586251a2eed3a1cb59b734fa83635d6aa570ecbf6", + PacketIpv4: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186f4093b328404f1af6b5d8fa4586e9379860ab2fb9d9a3887ec9734591bfcde443d9586d89cf0843879", + PacketIpv6: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186f41d97ef70c0822af5bf5d594d5f5a207fb2b7da9e948487bcd13e4516e9481b9540a3803630f01e38b326db33ae1bf431404007e8", + PacketDomainWithPayload: "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186f42fa033f94f6cb23db24a2fc3d173b6abc9b0f1fae15deaccbd5b6b7586251bc507e7cb86bcb631d1923df9a65a0f19a572456eeecf41e29f4df9a0a69c263b60e8ae"), + }; + + // ================================ helpers ================================ + + private static byte[] Hex(string h) => Convert.FromHexString(h); + private static string Hex(ReadOnlySpan b) => Convert.ToHexStringLower(b); + private static string Sha256Hex(ReadOnlySpan b) => Convert.ToHexStringLower(SHA256.HashData(b)); + + /// salt[i] = (i·37 + 11) mod 256 — the fixed salt the vectors were generated with. + internal static byte[] FixedSalt(int length) + { + byte[] salt = new byte[length]; + for (int i = 0; i < length; i++) + salt[i] = (byte)((i * 37 + 11) % 256); + return salt; + } + + /// data[i] = i mod 256. + private static byte[] Pattern(int length) + { + byte[] data = new byte[length]; + for (int i = 0; i < length; i++) + data[i] = (byte)i; + return data; + } + + private static byte[] MasterKey(Vector v) + { + byte[] key = new byte[v.KeySize]; + ShadowsocksCipher.DeriveMasterKey(Password, key); + return key; + } + + private static ShadowsocksStream Stream(Vector v, Stream transport) => + new(transport, ShadowsocksCipher.Resolve(v.Name), MasterKey(v), FixedSalt(v.KeySize), leaveInnerOpen: true); + + // ================================ key schedule ================================ + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [InlineData(ChaCha)] + public void FixedSalt_MatchesThePinnedSalt(string name) + { + Vector v = Vectors[name]; + Assert.Equal(v.Salt, Hex(FixedSalt(v.KeySize))); + Assert.Equal(v.KeySize, ShadowsocksCipher.SaltSize(ShadowsocksCipher.Resolve(name))); + } + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [InlineData(ChaCha)] + public void MasterKey_MatchesEvpBytesToKey(string name) + { + Vector v = Vectors[name]; + Assert.Equal(v.MasterKey, Hex(MasterKey(v))); + } + + [Fact] + public void MasterKey_ShorterKeysAreAPrefixOfLongerOnes() + { + // EVP_BytesToKey truncates one MD5 chain, so the 16-byte key is the head of the 32-byte one. + Assert.StartsWith(Vectors[Aes128].MasterKey, Vectors[Aes256].MasterKey); + Assert.StartsWith(Vectors[Aes192].MasterKey, Vectors[Aes256].MasterKey); + Assert.Equal(Vectors[Aes256].MasterKey, Vectors[ChaCha].MasterKey); + } + + [Fact] + public void MasterKey_IsAnMd5Chain() + { + // Independent recomputation: D1 = MD5(pw), D2 = MD5(D1 ‖ pw), key = D1 ‖ D2. + byte[] pw = Encoding.UTF8.GetBytes(Password); + byte[] d1 = MD5.HashData(pw); + byte[] d2 = MD5.HashData([.. d1, .. pw]); + Assert.Equal(Vectors[Aes256].MasterKey, Hex(d1) + Hex(d2)); + } + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [InlineData(ChaCha)] + public void Subkey_MatchesHkdfSha1(string name) + { + Vector v = Vectors[name]; + byte[] subkey = new byte[v.KeySize]; + ShadowsocksCipher.DeriveSubkey(Hex(v.MasterKey), Hex(v.Salt), subkey); + Assert.Equal(v.Subkey, Hex(subkey)); + } + + [Fact] + public void Subkey_IsHkdfSha1WithTheSsSubkeyInfo() + { + Vector v = Vectors[Aes256]; + byte[] direct = HKDF.DeriveKey(HashAlgorithmName.SHA1, Hex(v.MasterKey), 32, Hex(v.Salt), "ss-subkey"u8.ToArray()); + Assert.Equal(v.Subkey, Hex(direct)); + + // The info string and the hash are both load-bearing. + Assert.NotEqual(v.Subkey, Hex(HKDF.DeriveKey(HashAlgorithmName.SHA1, Hex(v.MasterKey), 32, Hex(v.Salt), "ss-subkey\0"u8.ToArray()))); + Assert.NotEqual(v.Subkey, Hex(HKDF.DeriveKey(HashAlgorithmName.SHA256, Hex(v.MasterKey), 32, Hex(v.Salt), "ss-subkey"u8.ToArray()))); + } + + // ================================ chunk framing: write ================================ + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task EmptyChunk_MatchesThePinnedWire(string name) + { + Vector v = Vectors[name]; + var transport = new DuplexTestStream([]); + var stream = Stream(v, transport); + + await stream.WriteChunkAsync(ReadOnlyMemory.Empty); + + Assert.Equal(v.Salt + v.EmptyChunk, Hex(transport.Written)); + Assert.Equal(34 + v.KeySize, transport.Written.Length); + Assert.Equal(2UL, stream.WriteNonceCounter); // two operations, even for an empty payload + } + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task OneByteChunk_MatchesThePinnedWire(string name) + { + Vector v = Vectors[name]; + var transport = new DuplexTestStream([]); + var stream = Stream(v, transport); + + await stream.WriteAsync(new byte[] { 0x41 }); + + Assert.Equal(v.Salt + v.OneByteChunk, Hex(transport.Written)); + Assert.Equal(2UL, stream.WriteNonceCounter); + } + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task MaxChunk_MatchesThePinnedDigest(string name) + { + Vector v = Vectors[name]; + byte[] payload = Pattern(ShadowsocksStream.MaxPayloadSize); + Assert.Equal(PatternSha16383, Sha256Hex(payload)); + + var transport = new DuplexTestStream([]); + var stream = Stream(v, transport); + await stream.WriteAsync(payload); + + byte[] wire = transport.Written; + Assert.Equal(v.Salt, Hex(wire.AsSpan(0, v.KeySize))); + Assert.Equal(16417, wire.Length - v.KeySize); + Assert.Equal(v.MaxChunkSha256, Sha256Hex(wire.AsSpan(v.KeySize))); + Assert.Equal(2UL, stream.WriteNonceCounter); + } + + /// + /// The one test that catches a wrong nonce increment: the second chunk is sealed with + /// nonce 2 and 3, and a per-chunk (rather than per-operation) counter, or a big-endian one, + /// passes every single-chunk vector and fails here. + /// + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task MaxChunkPlusOne_SplitsIntoTwoChunks_AndMatchesThePinnedDigest(string name) + { + Vector v = Vectors[name]; + byte[] payload = Pattern(ShadowsocksStream.MaxPayloadSize + 1); + Assert.Equal(PatternSha16384, Sha256Hex(payload)); + + var transport = new DuplexTestStream([]); + var stream = Stream(v, transport); + await stream.WriteAsync(payload); + + byte[] wire = transport.Written; + Assert.Equal(16452, wire.Length - v.KeySize); // (16383 + 34) + (1 + 34) + Assert.Equal(v.MaxChunkPlusOneSha256, Sha256Hex(wire.AsSpan(v.KeySize))); + Assert.Equal(4UL, stream.WriteNonceCounter); + } + + // ================================ chunk framing: read ================================ + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task Reader_OpensThePinnedOneByteChunk(string name) + { + // The read direction is keyed from the salt it receives; the vector salt keys it exactly + // like the write direction, so the same wire opens on either side. + Vector v = Vectors[name]; + var transport = new DuplexTestStream(Hex(v.Salt + v.OneByteChunk)); + var stream = Stream(v, transport); + + byte[] buffer = new byte[16]; + Assert.False(stream.IsServerSaltRead); + Assert.Equal(1, await stream.ReadAsync(buffer)); + Assert.Equal(0x41, buffer[0]); + Assert.True(stream.IsServerSaltRead); + Assert.Equal(2UL, stream.ReadNonceCounter); + + // FIN exactly at the chunk boundary: the clean end of stream. + Assert.Equal(0, await stream.ReadAsync(buffer)); + Assert.True(stream.IsReadCompleted); + } + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task Reader_OpensThePinnedEmptyChunk_WithoutReportingEof(string name) + { + Vector v = Vectors[name]; + var transport = new DuplexTestStream(Hex(v.Salt + v.EmptyChunk)); + var stream = Stream(v, transport); + + // The only bytes are an empty chunk followed by a FIN. The chunk must be opened (counter + // advances to 2) and the FIN, not the chunk, is what ends the stream. + Assert.Equal(0, await stream.ReadAsync(new byte[16])); + Assert.Equal(2UL, stream.ReadNonceCounter); + Assert.True(stream.IsReadCompleted); + } + + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task Reader_RoundTripsTheDigestPinnedTwoChunkStream(string name) + { + // The writer's output is anchored by the pinned digest above; feeding it back proves the + // reader opens two consecutive chunks with the right nonces and reassembles them. + Vector v = Vectors[name]; + byte[] payload = Pattern(ShadowsocksStream.MaxPayloadSize + 1); + + var outbound = new DuplexTestStream([]); + await Stream(v, outbound).WriteAsync(payload); + byte[] wire = outbound.Written; + Assert.Equal(v.MaxChunkPlusOneSha256, Sha256Hex(wire.AsSpan(v.KeySize))); + + var reader = Stream(v, new DuplexTestStream(wire, maxReadSize: 1000)); + byte[] received = new byte[payload.Length]; + int offset = 0; + while (offset < received.Length) + { + int read = await reader.ReadAsync(received.AsMemory(offset)); + Assert.True(read > 0); + offset += read; + } + + Assert.Equal(payload, received); + Assert.Equal(4UL, reader.ReadNonceCounter); + Assert.Equal(0, await reader.ReadAsync(received)); + } + + // ================================ first client packet ================================ + + [Fact] + public void AddressHeader_MatchesThePinnedBytes() + { + Span buffer = stackalloc byte[ProxyAddress.MaxLength + 2]; + + int n = ShadowsocksClient.BuildAddressHeader(buffer, "example.com", 443); + Assert.Equal(HeaderDomain443, Hex(buffer.Slice(0, n))); + + n = ShadowsocksClient.BuildAddressHeader(buffer, "1.2.3.4", 80); + Assert.Equal(HeaderIpv4, Hex(buffer.Slice(0, n))); + + n = ShadowsocksClient.BuildAddressHeader(buffer, "2001:db8::1", 8080); + Assert.Equal(HeaderIpv6, Hex(buffer.Slice(0, n))); + + n = ShadowsocksClient.BuildAddressHeader(buffer, "example.com", 80); + Assert.Equal(HeaderDomain80, Hex(buffer.Slice(0, n))); + } + + /// + /// The complete first packet through the public path: salt ‖ chunk(atyp ‖ addr ‖ port), + /// with the vector salt injected through the internal hook. Nothing may be read. + /// + [Theory] + [InlineData(Aes128, "example.com", 443, "domain")] + [InlineData(Aes128, "1.2.3.4", 80, "ipv4")] + [InlineData(Aes128, "2001:db8::1", 8080, "ipv6")] + [InlineData(Aes192, "example.com", 443, "domain")] + [InlineData(Aes192, "1.2.3.4", 80, "ipv4")] + [InlineData(Aes192, "2001:db8::1", 8080, "ipv6")] + [InlineData(Aes256, "example.com", 443, "domain")] + [InlineData(Aes256, "1.2.3.4", 80, "ipv4")] + [InlineData(Aes256, "2001:db8::1", 8080, "ipv6")] + [ChaCha20InlineData(ChaCha, "example.com", 443, "domain")] + [ChaCha20InlineData(ChaCha, "1.2.3.4", 80, "ipv4")] + [ChaCha20InlineData(ChaCha, "2001:db8::1", 8080, "ipv6")] + public async Task ConnectAsync_WritesThePinnedFirstPacket_AndReadsNothing(string name, string host, int port, string which) + { + Vector v = Vectors[name]; + string expected = which switch + { + "domain" => v.PacketDomain, + "ipv4" => v.PacketIpv4, + _ => v.PacketIpv6 + }; + + var transport = new ScriptedDuplexStream(); + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = name, Password = Password, Host = "proxy.example", Port = 8388 + }) + { + SaltSource = salt => FixedSalt(salt.Length).CopyTo(salt) + }; + + await using Stream tunnel = await client.ConnectAsync(transport, host, port); + + Assert.Equal(expected, Hex(transport.Written)); + Assert.Equal(0, transport.ReadCount); // the server salt is read lazily, never at connect + Assert.IsType(tunnel); + } + + /// + /// The header and the first payload may share one chunk (shadowsocks-rust does this to hide + /// the short address chunk). This client sends the header alone from ConnectAsync, so the + /// coalesced vector is pinned at the stream level, where a single write is a single chunk. + /// + [Theory] + [InlineData(Aes128)] + [InlineData(Aes192)] + [InlineData(Aes256)] + [ChaCha20InlineData(ChaCha)] + public async Task HeaderWithInitialPayloadInOneChunk_MatchesThePinnedPacket(string name) + { + Vector v = Vectors[name]; + var transport = new DuplexTestStream([]); + var stream = Stream(v, transport); + + await stream.WriteAsync(Hex(HeaderDomain80 + InitialPayload)); + + Assert.Equal(v.PacketDomainWithPayload, Hex(transport.Written)); + Assert.Equal("GET / HTTP/1.0\r\n\r\n", Encoding.ASCII.GetString(Hex(InitialPayload))); + } + + // ================================ constants ================================ + + [Fact] + public void Constants_MatchTheSpec() + { + Assert.Equal(16, ShadowsocksStream.TagSize); + Assert.Equal(2, ShadowsocksStream.LengthSize); + Assert.Equal(18, ShadowsocksStream.LengthBlockSize); + Assert.Equal(0x3FFF, ShadowsocksStream.MaxPayloadSize); + Assert.Equal(16417, ShadowsocksStream.MaxWireChunkSize); + Assert.Equal(12, ShadowsocksCipher.NonceSize); + Assert.Equal(16, ShadowsocksCipher.KeySize(ShadowsocksMethod.Aes128Gcm)); + Assert.Equal(24, ShadowsocksCipher.KeySize(ShadowsocksMethod.Aes192Gcm)); + Assert.Equal(32, ShadowsocksCipher.KeySize(ShadowsocksMethod.Aes256Gcm)); + Assert.Equal(32, ShadowsocksCipher.KeySize(ShadowsocksMethod.ChaCha20Poly1305)); + } +} diff --git a/QuickProxyNet.Tests/ShadowsocksHostilePeerTest.cs b/QuickProxyNet.Tests/ShadowsocksHostilePeerTest.cs new file mode 100644 index 0000000..8ca455b --- /dev/null +++ b/QuickProxyNet.Tests/ShadowsocksHostilePeerTest.cs @@ -0,0 +1,428 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using QuickProxyNet.Tests.Helpers; + +// CA2022 ("avoid inexact reads") warns whenever a single ReadAsync is expected to fill a +// buffer. Chunk-at-a-time delivery is exactly what these tests assert. +#pragma warning disable CA2022 + +namespace QuickProxyNet.Tests; + +/// +/// Drives and against a peer +/// that is malformed, truncating, tampering or silent. +/// +/// +/// +/// The wire bytes here are produced by , a deliberately separate +/// re-implementation of the server side (HKDF-SHA1, AES-GCM, a little-endian counting nonce) +/// that is first anchored to the pinned vectors of and only +/// then bent. Everything runs in memory: no process, no socket, no timing. +/// +/// +/// The rules under test: a decrypted length above 0x3FFF is refused, never masked; a FIN +/// anywhere but exactly at a chunk boundary is an error, never 0; a failed tag is an +/// error; a server that says nothing fails on the first Read and never at connect. +/// +/// +public class ShadowsocksHostilePeerTest +{ + private const string Password = "quickproxynet-test-password"; + private const ShadowsocksMethod Method = ShadowsocksMethod.Aes256Gcm; + + private static readonly byte[] ServerSalt = ShadowsocksCryptoTest.FixedSalt(32); + + private static byte[] MasterKey(string password = Password) + { + byte[] key = new byte[32]; + ShadowsocksCipher.DeriveMasterKey(password, key); + return key; + } + + private static ShadowsocksStream ClientStream(byte[] inbound, int maxReadSize = int.MaxValue) => + new(new DuplexTestStream(inbound, maxReadSize), Method, MasterKey(), ShadowsocksCryptoTest.FixedSalt(32), leaveInnerOpen: true); + + private static byte[] Concat(params byte[][] parts) + { + int length = 0; + foreach (byte[] p in parts) + length += p.Length; + + byte[] result = new byte[length]; + int offset = 0; + foreach (byte[] p in parts) + { + p.CopyTo(result, offset); + offset += p.Length; + } + + return result; + } + + private static async Task ExpectReadFailureAsync(ShadowsocksStream stream, int bufferSize = 64) + { + var ex = await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[bufferSize])); + Assert.False(stream.IsReadCompleted, "a failure must never leave the stream marked as cleanly closed"); + Assert.True(stream.IsReadFaulted, "a failure must latch the read direction"); + + // The failure is final. A caller that swallows the first exception and reads again must + // get it again — never a 0 from the transport's FIN dressed up as a clean end of stream. + var again = await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[bufferSize])); + Assert.Equal(ProxyErrorCode.InvalidResponse, again.ErrorCode); + Assert.False(stream.IsReadCompleted); + return ex; + } + + // ================================ anchor ================================ + + [Fact] + public void ServerSealer_ReproducesThePinnedVector() + { + // aes-256-gcm one_byte from ShadowsocksCryptoTest: the sealer is only trustworthy if it + // reproduces the independent vector before it is used to build hostile fixtures. + var sealer = new ServerSealer(MasterKey(), ServerSalt); + Assert.Equal( + "6fac3aa79051d37a98130772dd2e8fd52904dfc714e2fecbf5596fe7d4ea31ecba37bf", + Convert.ToHexStringLower(sealer.Chunk([0x41]))); + } + + // ================================ oversized length ================================ + + [Theory] + [InlineData(0x8000)] // a masking implementation turns this into 0 and desynchronises + [InlineData(0x4000)] // the smallest illegal value + [InlineData(0xFFFF)] + public async Task DecryptedLengthAboveTheCap_IsRejectedNotMasked(int declared) + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] wire = Concat(ServerSalt, sealer.LengthBlock((ushort)declared), sealer.RawPayload(new byte[64])); + + var ex = await ExpectReadFailureAsync(ClientStream(wire)); + + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.Contains($"0x{declared:X4}", ex.Message); + Assert.Contains("16383", ex.Message); + Assert.Contains("masked", ex.Message); + } + + [Fact] + public async Task DecryptedLengthAtTheCap_IsAccepted() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] payload = new byte[ShadowsocksStream.MaxPayloadSize]; + for (int i = 0; i < payload.Length; i++) + payload[i] = (byte)(i * 3); + + var stream = ClientStream(Concat(ServerSalt, sealer.Chunk(payload))); + byte[] buffer = new byte[payload.Length]; + Assert.Equal(payload.Length, await stream.ReadAsync(buffer)); + Assert.Equal(payload, buffer); + } + + // ================================ truncation ================================ + + [Theory] + [InlineData(0)] // nothing at all: how a wrong password looks + [InlineData(1)] + [InlineData(31)] // one byte short of the salt + public async Task FinInsideTheSalt_IsAnErrorNotEof(int saltBytes) + { + var ex = await ExpectReadFailureAsync(ClientStream(ServerSalt[..saltBytes])); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + Assert.IsType(ex.InnerException); + if (saltBytes == 0) + Assert.Contains("password", ex.Message); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] // the two length bytes without their tag + [InlineData(17)] // one byte short of the length block + public async Task FinInsideTheLengthBlock_IsAnErrorNotEof(int keep) + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] chunk = sealer.Chunk("hello"u8.ToArray()); + + var ex = await ExpectReadFailureAsync(ClientStream(Concat(ServerSalt, chunk[..keep]))); + + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.Contains("length block", ex.Message); + } + + [Theory] + [InlineData(18)] // a complete length block and no payload + [InlineData(20)] + [InlineData(38)] // one byte short of the payload tag + public async Task FinInsideThePayload_IsAnErrorNotEof(int keep) + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] chunk = sealer.Chunk("hello"u8.ToArray()); // 18 + 5 + 16 = 39 bytes + + var ex = await ExpectReadFailureAsync(ClientStream(Concat(ServerSalt, chunk[..keep]))); + + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task FinAfterACompleteChunk_ThenMidChunk_FailsOnTheSecondRead() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] first = sealer.Chunk("hello"u8.ToArray()); + byte[] second = sealer.Chunk("world"u8.ToArray()); + + var stream = ClientStream(Concat(ServerSalt, first, second[..10]), maxReadSize: 3); + byte[] buffer = new byte[64]; + Assert.Equal(5, await stream.ReadAsync(buffer)); + Assert.Equal("hello"u8.ToArray(), buffer[..5]); + + var ex = await ExpectReadFailureAsync(stream); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + // ================================ tampering ================================ + + [Fact] + public async Task TamperedPayloadTag_IsAnError() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] chunk = sealer.Chunk("hello"u8.ToArray()); + chunk[^1] ^= 0xFF; + + var ex = await ExpectReadFailureAsync(ClientStream(Concat(ServerSalt, chunk))); + + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.IsAssignableFrom(ex.InnerException); + Assert.Contains("payload", ex.Message); + } + + [Fact] + public async Task TamperedLengthBlock_IsAnError() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + byte[] chunk = sealer.Chunk("hello"u8.ToArray()); + chunk[0] ^= 0x01; + + var ex = await ExpectReadFailureAsync(ClientStream(Concat(ServerSalt, chunk))); + + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.IsAssignableFrom(ex.InnerException); + Assert.Contains("length block", ex.Message); + } + + [Fact] + public async Task ChunkSealedForALaterPosition_FailsTheNonceCheck() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + sealer.Chunk("skipped"u8.ToArray()); + byte[] second = sealer.Chunk("hello"u8.ToArray()); + + // Presented as the first chunk, it was sealed with nonces 2 and 3 and cannot open under 0. + var ex = await ExpectReadFailureAsync(ClientStream(Concat(ServerSalt, second))); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + [Fact] + public async Task ServerWithAnotherPassword_FailsAuthentication() + { + // The rare server that does answer under the wrong key: its salt reads fine, the + // subkey differs, the first tag fails. + var sealer = new ServerSealer(MasterKey("some-other-password"), ServerSalt); + byte[] wire = Concat(ServerSalt, sealer.Chunk("hello"u8.ToArray())); + + var ex = await ExpectReadFailureAsync(ClientStream(wire)); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + [Fact] + public async Task ServerReusingTheClientSalt_StillOpens_DifferentSaltsStillOpen() + { + // The read subkey comes from the salt actually received, whatever it is. + byte[] otherSalt = new byte[32]; + for (int i = 0; i < otherSalt.Length; i++) + otherSalt[i] = (byte)(200 - i); + + var sealer = new ServerSealer(MasterKey(), otherSalt); + var stream = ClientStream(Concat(otherSalt, sealer.Chunk("hello"u8.ToArray()))); + + byte[] buffer = new byte[64]; + Assert.Equal(5, await stream.ReadAsync(buffer)); + Assert.Equal("hello"u8.ToArray(), buffer[..5]); + } + + // ================================ clean EOF ================================ + + [Fact] + public async Task FinExactlyAtAChunkBoundary_IsCleanEof_AndStaysEof() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + var stream = ClientStream(Concat(ServerSalt, sealer.Chunk("hello"u8.ToArray()), sealer.Chunk("!"u8.ToArray()))); + + byte[] buffer = new byte[64]; + Assert.Equal(5, await stream.ReadAsync(buffer)); + Assert.Equal(1, await stream.ReadAsync(buffer)); + Assert.Equal(0, await stream.ReadAsync(buffer)); + Assert.True(stream.IsReadCompleted); + Assert.Equal(0, await stream.ReadAsync(buffer)); + Assert.Equal(4UL, stream.ReadNonceCounter); + } + + [Fact] + public async Task FinRightAfterTheSalt_IsCleanEof() + { + // A server that keyed the stream and then closed without sending a chunk: the salt is + // complete and the FIN sits on a chunk boundary, so this is a clean close. + var stream = ClientStream(ServerSalt); + Assert.Equal(0, await stream.ReadAsync(new byte[16])); + Assert.True(stream.IsServerSaltRead); + Assert.True(stream.IsReadCompleted); + } + + [Fact] + public async Task EmptyChunks_AreOpenedAndSkipped_NeverReportedAsEof() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + var stream = ClientStream(Concat( + ServerSalt, + sealer.Chunk([]), + sealer.Chunk([]), + sealer.Chunk("data"u8.ToArray()), + sealer.Chunk([]))); + + byte[] buffer = new byte[64]; + Assert.Equal(4, await stream.ReadAsync(buffer)); + Assert.Equal("data"u8.ToArray(), buffer[..4]); + Assert.Equal(6UL, stream.ReadNonceCounter); // three chunks opened so far + Assert.Equal(0, await stream.ReadAsync(buffer)); + Assert.Equal(8UL, stream.ReadNonceCounter); // the trailing empty chunk was opened, then the FIN + } + + [Fact] + public async Task SmallCallerBuffer_StopsAtTheChunkBoundary() + { + var sealer = new ServerSealer(MasterKey(), ServerSalt); + var stream = ClientStream(Concat(ServerSalt, sealer.Chunk("hello"u8.ToArray()), sealer.Chunk("A"u8.ToArray()))); + + byte[] two = new byte[2]; + var assembled = new List(); + for (int i = 0; i < 3; i++) + { + int read = await stream.ReadAsync(two); + assembled.AddRange(two[..read]); + } + + // 2 + 2 + 1: the third read stops at the chunk boundary and only one chunk is open. + Assert.Equal("hello"u8.ToArray(), assembled); + Assert.Equal(2UL, stream.ReadNonceCounter); + Assert.Equal(1, await stream.ReadAsync(two)); + Assert.Equal((byte)'A', two[0]); + } + + // ================================ the silent server ================================ + + /// + /// A Shadowsocks server that cannot open the first chunk sends nothing. ConnectAsync must + /// still succeed — it reads nothing — and the first Read is where the failure surfaces. + /// This is the in-memory proof of the lazy salt read that the docker tests prove for real. + /// + [Fact] + public async Task SilentServer_ConnectSucceeds_FirstReadFails() + { + var transport = new ScriptedDuplexStream(); + var client = new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-256-gcm", Password = Password, Host = "proxy.example", Port = 8388 + }); + + await using Stream tunnel = await client.ConnectAsync(transport, "example.com", 443); + + // salt(32) ‖ chunk(header of 15 bytes) = 32 + 18 + 15 + 16. + Assert.Equal(81, transport.Written.Length); + Assert.Equal(0, transport.ReadCount); + + // The caller speaks first, as HTTP does; nothing comes back. + await tunnel.WriteAsync("GET / HTTP/1.0\r\n\r\n"u8.ToArray()); + Assert.Equal(0, transport.ReadCount); + + var ex = await Assert.ThrowsAsync(async () => await tunnel.ReadAsync(new byte[64])); + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + Assert.Contains("password", ex.Message); + Assert.Equal(1, transport.ReadCount); + } + + /// + /// The full client path against a scripted server: connect, write, read back what the + /// "server" sealed for us. Proves the client's read direction is keyed from the salt the + /// server sends, not from its own. + /// + [Fact] + public async Task ScriptedServer_RoundTripsThroughTheClient() + { + var transport = new ScriptedDuplexStream(maxReadSize: 5); + var client = new ShadowsocksClient(ShadowsocksShareLink.Parse( + "ss://YWVzLTI1Ni1nY206cXVpY2twcm94eW5ldC10ZXN0LXBhc3N3b3Jk@proxy.example:8388")); + Assert.Equal(Password, client.Options.Password); + + await using Stream tunnel = await client.ConnectAsync(transport, "example.com", 80); + + byte[] otherSalt = new byte[32]; + for (int i = 0; i < otherSalt.Length; i++) + otherSalt[i] = (byte)(i ^ 0x5A); + var sealer = new ServerSealer(MasterKey(), otherSalt); + transport.Enqueue(otherSalt); + transport.Enqueue(sealer.Chunk("HTTP/1.0 200 OK\r\n"u8.ToArray())); + transport.Enqueue(sealer.Chunk("\r\nQPN"u8.ToArray())); + + var received = new List(); + byte[] buffer = new byte[7]; + int read; + while ((read = await tunnel.ReadAsync(buffer)) > 0) + received.AddRange(buffer[..read]); + + Assert.Equal("HTTP/1.0 200 OK\r\n\r\nQPN"u8.ToArray(), received); + } + + /// + /// An independent server-side sealer: HKDF-SHA1 subkey, AES-256-GCM, 12-byte little-endian + /// counting nonce advanced after every operation. Anchored to the pinned vectors by + /// . + /// + private sealed class ServerSealer + { + private readonly AesGcm _aes; + private readonly byte[] _nonce = new byte[12]; + + public ServerSealer(byte[] masterKey, byte[] salt) + { + byte[] subkey = HKDF.DeriveKey(HashAlgorithmName.SHA1, masterKey, 32, salt, "ss-subkey"u8.ToArray()); + _aes = new AesGcm(subkey, 16); + } + + public byte[] Chunk(byte[] plaintext) => Concat(LengthBlock((ushort)plaintext.Length), RawPayload(plaintext)); + + /// Seals a length block declaring , whatever payload follows. + public byte[] LengthBlock(ushort declared) + { + byte[] plain = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(plain, declared); + return Seal(plain); + } + + /// Seals a payload with the current nonce, independent of any length block. + public byte[] RawPayload(byte[] plaintext) => Seal(plaintext); + + private byte[] Seal(byte[] plaintext) + { + byte[] wire = new byte[plaintext.Length + 16]; + _aes.Encrypt(_nonce, plaintext, wire.AsSpan(0, plaintext.Length), wire.AsSpan(plaintext.Length, 16)); + for (int i = 0; i < _nonce.Length && ++_nonce[i] == 0; i++) + { + } + + return wire; + } + } +} diff --git a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs new file mode 100644 index 0000000..a0c3f1b --- /dev/null +++ b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs @@ -0,0 +1,510 @@ +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +/// +/// ss:// parsing — both grammars and every base64 quirk the wild produces — and the +/// by-name rejection of ciphers and plugins this library does not speak. +/// +public class ShadowsocksShareLinkTest +{ + // base64url("aes-128-gcm:test"), the SIP002 spec's own example, unpadded. + private const string Aes128TestUrlSafe = "YWVzLTEyOC1nY206dGVzdA"; + + // base64("aes-256-gcm:password"), standard alphabet, padded. + private const string Aes256PasswordPadded = "YWVzLTI1Ni1nY206cGFzc3dvcmQ="; + + // "aes-256-gcm:¯¡¾" — a password whose standard base64 contains both '+' and '/'. + private const string PlusSlashStandard = "YWVzLTI1Ni1nY206wq/CocK+"; + private const string PlusSlashUrlSafe = "YWVzLTI1Ni1nY206wq_CocK-"; + private const string PlusSlashPassword = "¯¡¾"; + + // ================================ SIP002 ================================ + + [Fact] + public void Parse_Sip002_Base64UrlUnpadded_TheSpecExample() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@192.168.100.1:8888#Example1"); + + Assert.Equal("aes-128-gcm", o.Method); + Assert.Equal("test", o.Password); + Assert.Equal("192.168.100.1", o.Host); + Assert.Equal(8888, o.Port); + Assert.Equal("Example1", o.Remark); + Assert.Null(o.Plugin); + } + + [Fact] + public void Parse_Sip002_StandardAlphabetWithPadding() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes256PasswordPadded}@example.com:8388"); + + Assert.Equal("aes-256-gcm", o.Method); + Assert.Equal("password", o.Password); + } + + [Fact] + public void Parse_Sip002_PercentEncodedPadding_IsDecodedBeforeBase64() + { + // Outline and Python's urlsafe_b64encode keep the '=' and it arrives as %3D. + var o = ShadowsocksShareLink.Parse("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ%3D@example.com:8388"); + + Assert.Equal("aes-256-gcm", o.Method); + Assert.Equal("password", o.Password); + } + + [Fact] + public void Parse_Sip002_BothAlphabets_DecodeToTheSamePassword() + { + var standard = ShadowsocksShareLink.Parse($"ss://{PlusSlashStandard}@example.com:8388"); + var urlSafe = ShadowsocksShareLink.Parse($"ss://{PlusSlashUrlSafe}@example.com:8388"); + + Assert.Equal(PlusSlashPassword, standard.Password); + Assert.Equal(PlusSlashPassword, urlSafe.Password); + Assert.Equal("aes-256-gcm", standard.Method); + } + + [Fact] + public void Parse_Sip002_PlainUserInfo_PercentEncoded() + { + // ':' after percent-decoding means plain 'method:password'; the password keeps its own ':' and '@'. + var o = ShadowsocksShareLink.Parse("ss://aes-256-gcm:p%40ss%3Aw0rd%2F1@example.com:8388#n"); + + Assert.Equal("aes-256-gcm", o.Method); + Assert.Equal("p@ss:w0rd/1", o.Password); + Assert.Equal("example.com", o.Host); + } + + [Fact] + public void Parse_Sip002_PlainUserInfo_ColonPercentEncoded() + { + var o = ShadowsocksShareLink.Parse("ss://aes-128-gcm%3Asecret@example.com:8388"); + + Assert.Equal("aes-128-gcm", o.Method); + Assert.Equal("secret", o.Password); + } + + [Theory] + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com")] + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com/")] + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com#tag")] + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com/?unsupported=ignored#tag")] + public void Parse_MissingPort_DefaultsTo8388(string link) + { + var o = ShadowsocksShareLink.Parse(link); + Assert.Equal("example.com", o.Host); + Assert.Equal(8388, o.Port); + Assert.Equal(8388, ShadowsocksShareLink.DefaultPort); + } + + [Fact] + public void Parse_BracketedIPv6_StripsTheBrackets() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@[2001:db8::1]:9000"); + Assert.Equal("2001:db8::1", o.Host); + Assert.Equal(9000, o.Port); + + var noPort = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@[2001:db8::1]"); + Assert.Equal("2001:db8::1", noPort.Host); + Assert.Equal(8388, noPort.Port); + } + + /// + /// An unbracketed IPv6 literal has no unambiguous reading: 2001:db8::1:9000 is itself a + /// valid address (the 9000 is a hextet) and almost certainly meant [2001:db8::1]:9000. + /// SIP002 requires the brackets, and shadowsocks-rust refuse the bare form, + /// and so does this parser — connecting to a guessed address on a guessed port is worse than + /// an error that says how to write it. + /// + [Theory] + [InlineData("2001:db8::1:9000")] + [InlineData("2001:db8::1:8388")] + [InlineData("2001:db8::1")] + public void Parse_UnbracketedIPv6_IsRejected_NotGuessed(string hostPort) + { + string link = $"ss://{Aes128TestUrlSafe}@{hostPort}"; + + Assert.False(ShadowsocksShareLink.TryParse(link, out var options)); + Assert.Null(options); + + var ex = Assert.Throws(() => ShadowsocksShareLink.Parse(link)); + Assert.Contains("[addr]:port", ex.Message); + } + + [Fact] + public void Parse_PercentEncodedTag_IsUnescaped() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@example.com:8388#My%20Node%20%F0%9F%9A%80"); + Assert.Equal("My Node \U0001F680", o.Remark); + + var empty = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@example.com:8388#"); + Assert.Null(empty.Remark); + } + + [Fact] + public void Parse_TrailingSlash_BeforeQueryAndFragment() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@example.com:8388/?plugin=obfs-local%3Bobfs%3Dhttp#Example2"); + + Assert.Equal("example.com", o.Host); + Assert.Equal(8388, o.Port); + Assert.Equal("obfs-local;obfs=http", o.Plugin); + Assert.Equal("Example2", o.Remark); + } + + [Fact] + public void Parse_UnknownQueryKeys_AreIgnored() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@example.com:8388/?group=abc&udp=1&novalue"); + Assert.Null(o.Plugin); + } + + [Fact] + public void Parse_IsCaseInsensitiveAboutTheScheme_AndTrimsWhitespace() + { + var o = ShadowsocksShareLink.Parse($" SS://{Aes128TestUrlSafe}@example.com:8388\n"); + Assert.Equal("example.com", o.Host); + } + + // ================================ legacy ================================ + + [Fact] + public void Parse_Legacy_WholeAuthorityIsBase64() + { + // base64("aes-256-gcm:p@ss:w0rd@example.com:8388"): the raw password holds '@' and ':'. + var o = ShadowsocksShareLink.Parse("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODg=#legacy"); + + Assert.Equal("aes-256-gcm", o.Method); + Assert.Equal("p@ss:w0rd", o.Password); + Assert.Equal("example.com", o.Host); + Assert.Equal(8388, o.Port); + Assert.Equal("legacy", o.Remark); + } + + [Theory] + [InlineData("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODg")] // unpadded + [InlineData("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODg=/")] // trailing slash + [InlineData("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODg/#t")] // both + [InlineData("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODgK")] // base64("…:8388\n"): what `echo | base64` makes + [InlineData("ss://YWVzLTI1Ni1nY206cEBzczp3MHJkQGV4YW1wbGUuY29tOjgzODgg")] // base64("…:8388 ") + public void Parse_Legacy_PaddingAndTrailingSlashAreTolerated(string link) + { + var o = ShadowsocksShareLink.Parse(link); + Assert.Equal("p@ss:w0rd", o.Password); + Assert.Equal(8388, o.Port); + } + + [Fact] + public void Parse_Legacy_BracketedIPv6() + { + // base64("chacha20-ietf-poly1305:secret@[2001:db8::1]:9000") + var o = ShadowsocksShareLink.Parse("ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpzZWNyZXRAWzIwMDE6ZGI4OjoxXTo5MDAw"); + + Assert.Equal("chacha20-ietf-poly1305", o.Method); + Assert.Equal("secret", o.Password); + Assert.Equal("2001:db8::1", o.Host); + Assert.Equal(9000, o.Port); + } + + // ================================ malformed ================================ + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("vless://YWVzLTEyOC1nY206dGVzdA@example.com:8388")] + [InlineData("ss://")] + [InlineData("ss://@example.com:8388")] // empty userinfo + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@")] // no host + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@:8388")] // empty host + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com:99999")] // bad port + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com:0")] + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@example.com:abc")] + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@[2001:db8::1")] // unterminated bracket + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@[2001:db8::1]x:80")] // junk after bracket + [InlineData("ss://YWVzLTEyOC1nY206dGVzdA@2001:db8::zz:8388")] // unbracketed, not an IPv6 address either + [InlineData("ss://not!base64!@example.com:8388")] // neither base64 nor plain + [InlineData("ss://YWJj@example.com:8388")] // base64("abc"): no ':' + [InlineData("ss://:password@example.com:8388")] // empty method + [InlineData("ss://aes-256-gcm:@example.com:8388")] // empty password + [InlineData("ss://bm8tYXQtc2lnbg")] // legacy base64("no-at-sign") + public void TryParse_RejectsMalformed(string link) + { + Assert.False(ShadowsocksShareLink.TryParse(link, out var options)); + Assert.Null(options); + Assert.Throws(() => ShadowsocksShareLink.Parse(link)); + } + + /// + /// base64("aes:pw@host") is a well-formed legacy link with the default port. It parses; the + /// unsupported cipher name is the client's business, not the parser's. + /// + [Fact] + public void Parse_Legacy_MinimalHostWithoutPort_ParsesWithTheDefaultPort() + { + var o = ShadowsocksShareLink.Parse("ss://YWVzOnB3QGhvc3Q"); + + Assert.Equal("aes", o.Method); + Assert.Equal("pw", o.Password); + Assert.Equal("host", o.Host); + Assert.Equal(8388, o.Port); + } + + /// Whatever a malformed link throws, the credential in it must not be in the message. + [Theory] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@:8388")] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@example.com:99999")] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@[2001:db8::1")] + [InlineData("ss://rc4-md5:SECRETPASSWORD@example.com:8388")] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@example.com:8388/?plugin=v2ray-plugin%3Bmode%3Dwebsocket")] + [InlineData("ss://SECRETPASSWORD:aes-256-gcm@example.com:8388")] // swapped fields: the password lands where the cipher goes + [InlineData("ss://SECRET%2FPASSWORD%3D:aes-256-gcm@example.com:8388")] // same, with characters no cipher name has + [InlineData("ss://U0VDUkVUUEFTU1dPUkQ6YWVzLTI1Ni1nY20@example.com:8388")] // same, base64("SECRETPASSWORD:aes-256-gcm") + public void Errors_NeverEchoTheCredential(string link) + { + Exception ex = Assert.ThrowsAny(() => ShadowsocksClient.FromShareLink(link)); + + for (Exception? e = ex; e is not null; e = e.InnerException) + Assert.DoesNotContain("SECRET", e.Message); + } + + // ================================ cipher rejection ================================ + + [Theory] + [InlineData("2022-blake3-aes-128-gcm")] + [InlineData("2022-blake3-aes-256-gcm")] + [InlineData("2022-blake3-chacha20-poly1305")] + [InlineData("2022-blake3-chacha8-poly1305")] + [InlineData("table")] + [InlineData("rc4")] + [InlineData("rc4-md5")] + [InlineData("chacha20")] + [InlineData("chacha20-ietf")] + [InlineData("salsa20")] + [InlineData("bf-cfb")] + [InlineData("aes-128-ctr")] + [InlineData("aes-192-ctr")] + [InlineData("aes-256-ctr")] + [InlineData("aes-128-cfb")] + [InlineData("aes-128-cfb1")] + [InlineData("aes-128-cfb8")] + [InlineData("aes-128-cfb128")] + [InlineData("aes-192-cfb")] + [InlineData("aes-256-cfb")] + [InlineData("aes-256-cfb8")] + [InlineData("aes-128-ofb")] + [InlineData("aes-256-ofb")] + [InlineData("camellia-128-cfb")] + [InlineData("camellia-192-ctr")] + [InlineData("camellia-256-ofb")] + [InlineData("camellia-256-cfb128")] + [InlineData("none")] + [InlineData("plain")] + [InlineData("xchacha20-ietf-poly1305")] + [InlineData("aes-128-ccm")] + [InlineData("aes-256-ccm")] + [InlineData("aes-128-gcm-siv")] + [InlineData("aes-256-gcm-siv")] + [InlineData("sm4-gcm")] + [InlineData("sm4-ccm")] + [InlineData("totally-made-up-cipher")] + [InlineData("chacha20-poly1305")] // Xray's bare spelling; early libev meant the 64-bit-nonce draft by it + [InlineData("AEAD_AES_128_GCM")] // spec-table identifiers, never wire names + [InlineData("AEAD_AES_256_GCM")] + [InlineData("AEAD_CHACHA20_POLY1305")] + [InlineData("aead_chacha20_poly1305")] + public void UnsupportedCipher_IsRejectedByName_BeforeAnyByteIsWritten(string method) + { + // Plain userinfo: the name goes through percent-decoding untouched. + string link = $"ss://{method}:pw@example.com:8388"; + var parsed = ShadowsocksShareLink.Parse(link); + Assert.Equal(method, parsed.Method); // the parser keeps it; the client refuses it + + var ex = Assert.Throws(() => ShadowsocksClient.FromShareLink(link)); + + Assert.Contains(method, ex.Message); + Assert.Contains("aes-256-gcm", ex.Message); + Assert.Contains("chacha20-ietf-poly1305", ex.Message); + + if (method.StartsWith("2022-")) + Assert.Contains("BLAKE3", ex.Message); + if (method == "xchacha20-ietf-poly1305") + Assert.Contains("XChaCha20", ex.Message); + if (method is "none" or "plain") + Assert.Contains("unencrypted", ex.Message); + if (method is "rc4-md5" or "aes-256-cfb" or "table") + Assert.Contains("stream cipher", ex.Message); + if (method == "chacha20-poly1305") + Assert.Contains("ambiguous", ex.Message); + if (method.StartsWith("AEAD_") || method.StartsWith("aead_")) + Assert.Contains("spec-table identifier", ex.Message); + } + + [Fact] + public void UnsupportedCipher_InBase64UserInfo_IsRejectedByName() + { + // base64url("2022-blake3-aes-256-gcm:somekey") and base64url("rc4-md5:passwd") + var ex = Assert.Throws(() => + ShadowsocksClient.FromShareLink("ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206c29tZWtleQ@example.com:8388")); + Assert.Contains("2022-blake3-aes-256-gcm", ex.Message); + + ex = Assert.Throws(() => + ShadowsocksClient.FromShareLink("ss://cmM0LW1kNTpwYXNzd2Q@example.com:8388")); + Assert.Contains("rc4-md5", ex.Message); + + // base64("AEAD_CHACHA20_POLY1305:pw") + ex = Assert.Throws(() => + ShadowsocksClient.FromShareLink("ss://QUVBRF9DSEFDSEEyMF9QT0xZMTMwNTpwdw@example.com:8388")); + Assert.Contains("AEAD_CHACHA20_POLY1305", ex.Message); + } + + [Fact] + public void UnsupportedCipher_IsRejectedAtConstruction_NotAtConnect() + { + // Nothing can have been written: the exception comes out of the constructor, and the + // transport it would have written to does not even exist yet. + var ex = Assert.Throws(() => new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-256-cfb", Password = "pw", Host = "example.com", Port = 8388 + })); + Assert.Contains("aes-256-cfb", ex.Message); + } + + [Theory] + [InlineData("aes-128-gcm", "aes-128-gcm", 16)] + [InlineData("aes-192-gcm", "aes-192-gcm", 24)] + [InlineData("aes-256-gcm", "aes-256-gcm", 32)] + [InlineData("AES-256-GCM", "aes-256-gcm", 32)] + [InlineData("chacha20-ietf-poly1305", "chacha20-ietf-poly1305", 32)] + [InlineData("ChaCha20-IETF-Poly1305", "chacha20-ietf-poly1305", 32)] + public void SupportedCipher_Resolves(string name, string canonical, int keySize) + { + var method = ShadowsocksCipher.Resolve(name); + Assert.Equal(canonical, ShadowsocksCipher.Name(method)); + Assert.Equal(keySize, ShadowsocksCipher.KeySize(method)); + } + + // ================================ plugin rejection ================================ + + [Theory] + [InlineData("plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dexample.com", "obfs-local")] + [InlineData("plugin=simple-obfs%3Bobfs%3Dtls", "simple-obfs")] + [InlineData("plugin=v2ray-plugin%3Bmode%3Dwebsocket%3Bhost%3Dcdn.example.com%3Btls", "v2ray-plugin")] + [InlineData("plugin=xray-plugin", "xray-plugin")] + [InlineData("plugin=kcptun%3Bkey%3Dx", "kcptun")] + [InlineData("plugin=GoQuiet%3BKey%3Dx", "GoQuiet")] + [InlineData("plugin=Cloak", "Cloak")] + [InlineData("plugin=gost-plugin", "gost-plugin")] + public void Plugin_IsRejectedByName(string query, string pluginName) + { + string link = $"ss://{Aes128TestUrlSafe}@example.com:8388/?{query}#n"; + + var parsed = ShadowsocksShareLink.Parse(link); + Assert.StartsWith(pluginName, parsed.Plugin); + + var ex = Assert.Throws(() => ShadowsocksClient.FromShareLink(link)); + Assert.Contains($"'{pluginName}'", ex.Message); + Assert.Contains("obfuscated", ex.Message); + } + + /// + /// The HTML-escaped separator must not hide the plugin: amp;plugin dropped as an + /// unknown key would connect as plain Shadowsocks to a server running obfs. + /// + [Fact] + public void Plugin_BehindAnHtmlEscapedAmpersand_IsStillRejected() + { + string link = $"ss://{Aes128TestUrlSafe}@example.com:8388/?group=x&plugin=v2ray-plugin%3Bmode%3Dwebsocket#n"; + + Assert.Equal("v2ray-plugin;mode=websocket", ShadowsocksShareLink.Parse(link).Plugin); + + var ex = Assert.Throws(() => ShadowsocksClient.FromShareLink(link)); + Assert.Contains("v2ray-plugin", ex.Message); + } + + [Fact] + public void Plugin_EmptyValue_IsNoPlugin() + { + var o = ShadowsocksShareLink.Parse($"ss://{Aes128TestUrlSafe}@example.com:8388/?plugin="); + Assert.Null(o.Plugin); + _ = new ShadowsocksClient(o); + } + + // ================================ client construction ================================ + + [Fact] + public void Client_NullOptions_ThrowsArgumentNull() + { + Assert.Throws(() => new ShadowsocksClient(null!)); + } + + [Fact] + public void Client_EmptyPassword_ThrowsAtConstruction() + { + var bad = new ShadowsocksOptions { Method = "aes-256-gcm", Password = "", Host = "example.com", Port = 8388 }; + Assert.Throws(() => new ShadowsocksClient(bad)); + } + + /// + /// A missing cipher name on hand-built options is the caller's bug — the ArgumentException + /// family — not a link naming a cipher this library cannot speak. The parser never produces + /// an empty method, so only direct construction can reach this. + /// + [Theory] + [InlineData("")] + [InlineData(null)] + public void Client_EmptyMethod_ThrowsArgumentException_NotNotSupported(string? method) + { + var bad = new ShadowsocksOptions { Method = method!, Password = "pw", Host = "example.com", Port = 8388 }; + + var ex = Assert.Throws(() => new ShadowsocksClient(bad)); + Assert.Contains("Method", ex.Message); + } + + [Fact] + public void Client_IPv6Host_ConstructsWithoutThrowing() + { + var client = ShadowsocksClient.FromShareLink($"ss://{Aes128TestUrlSafe}@[2001:db8::1]:8388"); + Assert.Equal("2001:db8::1", client.ProxyHost); + Assert.Equal(8388, client.ProxyPort); + Assert.Equal(ProxyType.Shadowsocks, client.Type); + Assert.Equal("ss", client.ProxyUri.Scheme); + } + + [Fact] + public void FromShareLink_CreatesClient() + { + var client = ShadowsocksClient.FromShareLink($"ss://{Aes256PasswordPadded}@example.com:8388#node"); + Assert.Equal("example.com", client.Options.Host); + Assert.Equal(8388, client.Options.Port); + Assert.Equal("aes-256-gcm", client.Options.Method); + Assert.Equal("password", client.Options.Password); + Assert.Equal("node", client.Options.Remark); + } + + [Fact] + public void FromShareLink_Malformed_ThrowsFormat() + { + Assert.Throws(() => ShadowsocksClient.FromShareLink("ss://not!base64!@example.com:8388")); + } + + [Fact] + public async Task Client_ConnectAsync_NullStream_ThrowsArgumentNull() + { + var client = ShadowsocksClient.FromShareLink($"ss://{Aes128TestUrlSafe}@example.com:8388"); + await Assert.ThrowsAsync(async () => + await client.ConnectAsync((Stream)null!, "example.org", 443)); + } + + [Fact] + public async Task Client_ConnectAsync_HostNameTooLong_FailsBeforeWriting() + { + var transport = new FakeProxyStream([]); + var client = ShadowsocksClient.FromShareLink($"ss://{Aes128TestUrlSafe}@example.com:8388"); + + var ex = await Assert.ThrowsAsync(async () => + await client.ConnectAsync(transport, new string('a', 300), 443)); + + Assert.Equal(ProxyErrorCode.StringTooLong, ex.ErrorCode); + Assert.Empty(transport.WrittenBytes); + } +} diff --git a/QuickProxyNet.Tests/ShadowsocksStreamTest.cs b/QuickProxyNet.Tests/ShadowsocksStreamTest.cs new file mode 100644 index 0000000..e2e9931 --- /dev/null +++ b/QuickProxyNet.Tests/ShadowsocksStreamTest.cs @@ -0,0 +1,579 @@ +using QuickProxyNet.Tests.Helpers; + +// CA2022 ("avoid inexact reads") warns whenever a single ReadAsync is expected to fill a +// buffer. Partial reads are exactly what these tests exercise. +#pragma warning disable CA2022 + +namespace QuickProxyNet.Tests; + +/// +/// Round trips through in chunks of random length, both when +/// handing payload to Write and when feeding wire bytes into Read. +/// +/// +/// +/// A cipher stream that is tested with one call carrying the whole buffer proves little: every +/// interesting bug lives at a boundary — a chunk split across two writes, a length block split +/// across two reads, a caller's buffer smaller than the chunk, a chunk larger than 0x3FFF +/// that has to be cut. So every case here slices with a seeded and asserts +/// the bytes come out identical and the chunk count is what the slicing predicts. +/// +/// +/// The peer is a second keyed with the same master key: its read +/// direction derives the subkey from whatever salt arrives, exactly as a server does, so the +/// writer's output is read back through the code under test rather than through a helper. +/// +/// +public class ShadowsocksStreamTest +{ + private const string Password = "quickproxynet-test-password"; + private const int PayloadSize = 100_000; + + private static byte[] MasterKey(ShadowsocksMethod method) + { + byte[] key = new byte[ShadowsocksCipher.KeySize(method)]; + ShadowsocksCipher.DeriveMasterKey(Password, key); + return key; + } + + private static byte[] Salt(ShadowsocksMethod method, byte seed) + { + byte[] salt = new byte[ShadowsocksCipher.SaltSize(method)]; + for (int i = 0; i < salt.Length; i++) + salt[i] = (byte)(seed + i * 7); + return salt; + } + + private static int ExpectedChunks(int writeLength) => + (writeLength + ShadowsocksStream.MaxPayloadSize - 1) / ShadowsocksStream.MaxPayloadSize; + + /// Writes in random slices; returns the chunks this must produce. + private static async Task WriteInRandomSlicesAsync(Stream stream, byte[] payload, Random rng, int maxSlice) + { + int chunks = 0; + int offset = 0; + while (offset < payload.Length) + { + int count = Math.Min(rng.Next(1, maxSlice + 1), payload.Length - offset); + await stream.WriteAsync(payload.AsMemory(offset, count)); + chunks += ExpectedChunks(count); + offset += count; + } + + return chunks; + } + + /// Reads exactly bytes with random-sized buffers. + private static async Task ReadInRandomSlicesAsync(Stream stream, int length, Random rng, int maxSlice) + { + byte[] received = new byte[length]; + int offset = 0; + while (offset < length) + { + int want = Math.Min(rng.Next(1, maxSlice + 1), length - offset); + int read = await stream.ReadAsync(received.AsMemory(offset, want)); + Assert.True(read > 0, "a Read returned 0 before the payload was complete"); + Assert.True(read <= want); + offset += read; + } + + return received; + } + + [Theory] + [InlineData("aes-256-gcm", 1)] + [InlineData("aes-256-gcm", 2)] + [InlineData("aes-256-gcm", 3)] + [InlineData("aes-128-gcm", 4)] + [InlineData("aes-192-gcm", 5)] + [ChaCha20InlineData("chacha20-ietf-poly1305", 6)] + [ChaCha20InlineData("chacha20-ietf-poly1305", 7)] + public async Task ClientToServer_RandomWriteSlices_RandomReadSlices_RoundTrips(string name, int seed) + { + ShadowsocksMethod method = ShadowsocksCipher.Resolve(name); + var rng = new Random(seed); + byte[] payload = new byte[PayloadSize]; + rng.NextBytes(payload); + + // Client side: writes in slices of 1..40 000 bytes, so some are cut into 0x3FFF chunks + // and some are tiny. + var outbound = new DuplexTestStream([]); + var client = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 0x11), leaveInnerOpen: true); + int chunks = await WriteInRandomSlicesAsync(client, payload, rng, maxSlice: 40_000); + + byte[] wire = outbound.Written; + Assert.Equal(2UL * (ulong)chunks, client.WriteNonceCounter); + Assert.Equal(ShadowsocksCipher.SaltSize(method) + payload.Length + 34 * chunks, wire.Length); + + // Server side: the wire arrives in random slices of 1..5 000 bytes, so salt, length block + // and payload all get split at arbitrary points; the caller reads with random buffers. + var inbound = new RandomSliceStream(wire, new Random(seed + 100), maxSlice: 5_000); + var server = new ShadowsocksStream(inbound, method, MasterKey(method), Salt(method, 0x22), leaveInnerOpen: true); + byte[] received = await ReadInRandomSlicesAsync(server, payload.Length, new Random(seed + 200), maxSlice: 50_000); + + Assert.Equal(payload, received); + Assert.Equal(2UL * (ulong)chunks, server.ReadNonceCounter); + Assert.True(server.IsServerSaltRead); + + // Everything consumed: the FIN lands exactly on a chunk boundary. + Assert.Equal(0, await server.ReadAsync(new byte[16])); + Assert.True(server.IsReadCompleted); + } + + [Theory] + [InlineData("aes-256-gcm", 11)] + [InlineData("aes-128-gcm", 12)] + [ChaCha20InlineData("chacha20-ietf-poly1305", 13)] + public async Task ServerToClient_RandomWriteSlices_RandomReadSlices_RoundTrips(string name, int seed) + { + // The other direction: the "server" stream writes with its own salt, the "client" stream + // reads it. Same code paths in mirror; independent counters and subkeys. + ShadowsocksMethod method = ShadowsocksCipher.Resolve(name); + var rng = new Random(seed); + byte[] payload = new byte[PayloadSize]; + rng.NextBytes(payload); + + var outbound = new DuplexTestStream([]); + var server = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 0x33), leaveInnerOpen: true); + int chunks = await WriteInRandomSlicesAsync(server, payload, rng, maxSlice: 20_000); + + var inbound = new RandomSliceStream(outbound.Written, new Random(seed + 100), maxSlice: 700); + var client = new ShadowsocksStream(inbound, method, MasterKey(method), Salt(method, 0x44), leaveInnerOpen: true); + + // The client has written nothing: its own salt is still pending and its write counter is + // untouched while it reads. + byte[] received = await ReadInRandomSlicesAsync(client, payload.Length, new Random(seed + 200), maxSlice: 3_000); + + Assert.Equal(payload, received); + Assert.Equal(2UL * (ulong)chunks, client.ReadNonceCounter); + Assert.Equal(0UL, client.WriteNonceCounter); + Assert.Equal(0, await client.ReadAsync(new byte[16])); + } + + [Fact] + public async Task BothDirections_OnOneStreamPair_KeepIndependentCounters() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var rng = new Random(21); + + byte[] request = new byte[30_000]; + byte[] response = new byte[70_000]; + rng.NextBytes(request); + rng.NextBytes(response); + + // Client writes the request; server reads it and writes the response; client reads it. + var toServer = new DuplexTestStream([]); + var client = new ShadowsocksStream(toServer, method, MasterKey(method), Salt(method, 0x55), leaveInnerOpen: true); + int requestChunks = await WriteInRandomSlicesAsync(client, request, rng, maxSlice: 9_000); + + var toClient = new DuplexTestStream([]); + var serverReader = new ShadowsocksStream( + new RandomSliceStream(toServer.Written, new Random(22), maxSlice: 1_500), + method, MasterKey(method), Salt(method, 0x66), leaveInnerOpen: true); + Assert.Equal(request, await ReadInRandomSlicesAsync(serverReader, request.Length, new Random(23), maxSlice: 8_000)); + + var serverWriter = new ShadowsocksStream(toClient, method, MasterKey(method), Salt(method, 0x66), leaveInnerOpen: true); + int responseChunks = await WriteInRandomSlicesAsync(serverWriter, response, rng, maxSlice: 25_000); + + var clientReader = new ShadowsocksStream( + new RandomSliceStream(toClient.Written, new Random(24), maxSlice: 2_000), + method, MasterKey(method), Salt(method, 0x55), leaveInnerOpen: true); + Assert.Equal(response, await ReadInRandomSlicesAsync(clientReader, response.Length, new Random(25), maxSlice: 60_000)); + + Assert.Equal(2UL * (ulong)requestChunks, client.WriteNonceCounter); + Assert.Equal(2UL * (ulong)requestChunks, serverReader.ReadNonceCounter); + Assert.Equal(2UL * (ulong)responseChunks, serverWriter.WriteNonceCounter); + Assert.Equal(2UL * (ulong)responseChunks, clientReader.ReadNonceCounter); + } + + [Fact] + public void SyncOverloads_RandomSlices_RoundTrip() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes128Gcm; + var rng = new Random(31); + byte[] payload = new byte[40_000]; + rng.NextBytes(payload); + + var outbound = new DuplexTestStream([]); + var writer = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 0x77), leaveInnerOpen: true); + int offset = 0; + while (offset < payload.Length) + { + int count = Math.Min(rng.Next(1, 20_000), payload.Length - offset); + if ((offset & 1) == 0) + writer.Write(payload.AsSpan(offset, count)); + else + writer.Write(payload, offset, count); + offset += count; + } + + var reader = new ShadowsocksStream( + new RandomSliceStream(outbound.Written, new Random(32), maxSlice: 900), + method, MasterKey(method), Salt(method, 0x88), leaveInnerOpen: true); + + byte[] received = new byte[payload.Length]; + offset = 0; + while (offset < received.Length) + { + int want = Math.Min(rng.Next(1, 7_000), received.Length - offset); + int read = (offset & 1) == 0 + ? reader.Read(received.AsSpan(offset, want)) + : reader.Read(received, offset, want); + Assert.True(read > 0); + offset += read; + } + + Assert.Equal(payload, received); + Assert.Equal(0, reader.Read(new byte[8], 0, 8)); + } + + [Fact] + public async Task SyncWriteSpan_RandomSlices_ReadByTheAsyncReader_RoundTrips() + { + // The synchronous Write seals straight into the send buffer and calls the transport's + // synchronous Write: no rented copy, no blocking on the async path. Its wire must be what + // the async reader expects, and every Write of at most one chunk must be one transport write. + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var rng = new Random(41); + byte[] payload = new byte[PayloadSize]; + rng.NextBytes(payload); + + var sink = new CountingSink(); + var writer = new ShadowsocksStream(sink, method, MasterKey(method), Salt(method, 0x99), leaveInnerOpen: true); + int chunks = 0; + int writes = 0; + int offset = 0; + while (offset < payload.Length) + { + int count = Math.Min(rng.Next(1, 40_001), payload.Length - offset); + writer.Write(payload.AsSpan(offset, count)); + chunks += ExpectedChunks(count); + writes++; + offset += count; + } + + Assert.Equal(writes, sink.WriteCount); // 40 000 bytes are at most three chunks: always one run + Assert.Equal(2UL * (ulong)chunks, writer.WriteNonceCounter); + + var reader = new ShadowsocksStream( + new RandomSliceStream(sink.Written, new Random(42), maxSlice: 3_000), + method, MasterKey(method), Salt(method, 0xAA), leaveInnerOpen: true); + Assert.Equal(payload, await ReadInRandomSlicesAsync(reader, payload.Length, new Random(43), maxSlice: 20_000)); + Assert.Equal(0, await reader.ReadAsync(new byte[16])); + } + + [Fact] + public async Task WriteAsync_SealsConsecutiveChunksIntoOneTransportWrite() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var sink = new CountingSink(); + var stream = new ShadowsocksStream(sink, method, MasterKey(method), Salt(method, 0x12), leaveInnerOpen: true); + + // CopyToAsync's default buffer: six chunks (5 × 16 383 + 5), salt included, in ONE write. + await stream.WriteAsync(new byte[81_920]); + Assert.Equal(1, sink.WriteCount); + Assert.Equal(12UL, stream.WriteNonceCounter); + Assert.Equal(32 + 81_920 + 6 * 34, sink.Written.Length); + + // 1 MiB is 65 chunks; the send buffer takes seven per write (the 128 KiB pool bucket holds + // the salt plus seven full chunks), so ten writes — not sixty-five. + await stream.WriteAsync(new byte[1 << 20]); + Assert.Equal(11, sink.WriteCount); + Assert.Equal(2UL * (6 + 65), stream.WriteNonceCounter); + Assert.Equal(32 + 81_920 + 6 * 34 + (1 << 20) + 65 * 34, sink.Written.Length); + } + + [Fact] + public async Task WriteAsync_Coalesced_ProducesTheSameWireAsChunkByChunk() + { + // Coalescing changes how many transport writes carry the bytes, never the bytes: the + // chunk boundaries and the nonce sequence are what they were, so one WriteAsync and the + // same payload fed through WriteChunkAsync slice by slice must produce identical wires. + ShadowsocksMethod method = ShadowsocksMethod.Aes128Gcm; + byte[] payload = new byte[4 * ShadowsocksStream.MaxPayloadSize + 123]; + new Random(51).NextBytes(payload); + + var oneCall = new CountingSink(); + await new ShadowsocksStream(oneCall, method, MasterKey(method), Salt(method, 0x13), leaveInnerOpen: true) + .WriteAsync(payload); + Assert.Equal(1, oneCall.WriteCount); + + var chunkByChunk = new CountingSink(); + var stream = new ShadowsocksStream(chunkByChunk, method, MasterKey(method), Salt(method, 0x13), leaveInnerOpen: true); + for (int offset = 0; offset < payload.Length; offset += ShadowsocksStream.MaxPayloadSize) + { + int count = Math.Min(ShadowsocksStream.MaxPayloadSize, payload.Length - offset); + await stream.WriteChunkAsync(payload.AsMemory(offset, count)); + } + + Assert.Equal(5, chunkByChunk.WriteCount); + Assert.Equal(chunkByChunk.Written, oneCall.Written); + } + + [Fact] + public async Task ReadAsync_OpensExactlyOneChunkPerCall_FromAWindowHoldingSeveral() + { + // The reader takes whatever the transport has — here five chunks and the salt in one + // transport read — but opens only the chunk the caller asked for: the nonce advances by + // two per Read, and later Reads are served from the window without touching the transport. + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var outbound = new DuplexTestStream([]); + var writer = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 0x14), leaveInnerOpen: true); + for (int i = 0; i < 5; i++) + await writer.WriteAsync(new byte[100 + i]); + + var transport = new ScriptedDuplexStream(); + transport.Enqueue(outbound.Written); + var reader = new ShadowsocksStream(transport, method, MasterKey(method), Salt(method, 0x15), leaveInnerOpen: true); + + byte[] buffer = new byte[1024]; + for (int i = 0; i < 5; i++) + { + Assert.Equal(100 + i, await reader.ReadAsync(buffer)); + Assert.Equal(2UL * (ulong)(i + 1), reader.ReadNonceCounter); + Assert.Equal(1, transport.ReadCount); + } + + // Only the FIN needs another transport read. + Assert.Equal(0, await reader.ReadAsync(buffer)); + Assert.Equal(2, transport.ReadCount); + Assert.True(reader.IsReadCompleted); + } + + [Fact] + public async Task ReadAsync_OneByteTransportReads_OneByteCallerBuffer_AcrossAMaxChunk() + { + // Both extremes at once: the transport hands over one byte per read, so the salt, the + // length block and a 16 399-byte payload+tag each take many fills, and the caller wants one + // byte per Read, so the whole chunk is opened once and drained from the leftover buffer. + ShadowsocksMethod method = ShadowsocksMethod.Aes128Gcm; + byte[] payload = new byte[ShadowsocksStream.MaxPayloadSize]; + new Random(61).NextBytes(payload); + + var outbound = new DuplexTestStream([]); + await new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 0x16), leaveInnerOpen: true) + .WriteAsync(payload); + + var reader = new ShadowsocksStream( + new DuplexTestStream(outbound.Written, maxReadSize: 1), + method, MasterKey(method), Salt(method, 0x17), leaveInnerOpen: true); + + byte[] received = new byte[payload.Length]; + byte[] one = new byte[1]; + for (int i = 0; i < received.Length; i++) + { + Assert.Equal(1, await reader.ReadAsync(one)); + received[i] = one[0]; + Assert.Equal(2UL, reader.ReadNonceCounter); // opened once, on the first Read + } + + Assert.Equal(payload, received); + Assert.Equal(0, await reader.ReadAsync(one)); + } + + [Fact] + public async Task ReadAsync_TransportSlicesLargerThanTheWindow_RoundTrips() + { + // Slices up to 100 000 bytes against a 32 KiB window: every transport read is bounded by + // the free space behind the unparsed tail, chunks straddle the end of the buffer and are + // compacted to the front, and nothing is lost or duplicated. + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var rng = new Random(71); + byte[] payload = new byte[PayloadSize]; + rng.NextBytes(payload); + + var outbound = new DuplexTestStream([]); + var writer = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 0x18), leaveInnerOpen: true); + int chunks = await WriteInRandomSlicesAsync(writer, payload, rng, maxSlice: 30_000); + + var reader = new ShadowsocksStream( + new RandomSliceStream(outbound.Written, new Random(72), maxSlice: 100_000), + method, MasterKey(method), Salt(method, 0x19), leaveInnerOpen: true); + Assert.Equal(payload, await ReadInRandomSlicesAsync(reader, payload.Length, new Random(73), maxSlice: 2_000)); + Assert.Equal(2UL * (ulong)chunks, reader.ReadNonceCounter); + Assert.Equal(0, await reader.ReadAsync(new byte[16])); + } + + [Fact] + public async Task Write_EmptyBuffer_EmitsNothing_NotEvenTheSalt() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var outbound = new DuplexTestStream([]); + var stream = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 1), leaveInnerOpen: true); + + await stream.WriteAsync(ReadOnlyMemory.Empty); + + Assert.Empty(outbound.Written); + Assert.Equal(0UL, stream.WriteNonceCounter); + + // The salt then travels with the first real chunk, in one write. + await stream.WriteAsync("x"u8.ToArray()); + Assert.Equal(32 + 35, outbound.Written.Length); + } + + [Fact] + public async Task Write_LargeBuffer_SplitsAtTheChunkCap() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var outbound = new DuplexTestStream([]); + var stream = new ShadowsocksStream(outbound, method, MasterKey(method), Salt(method, 2), leaveInnerOpen: true); + + await stream.WriteAsync(new byte[3 * ShadowsocksStream.MaxPayloadSize + 5]); + + Assert.Equal(8UL, stream.WriteNonceCounter); // 4 chunks + Assert.Equal(32 + (3 * (ShadowsocksStream.MaxPayloadSize + 34)) + (5 + 34), outbound.Written.Length); + } + + [Fact] + public async Task WriteChunk_AboveTheCap_IsRefused() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var stream = new ShadowsocksStream(new DuplexTestStream([]), method, MasterKey(method), Salt(method, 3), leaveInnerOpen: true); + + await Assert.ThrowsAsync(async () => + await stream.WriteChunkAsync(new byte[ShadowsocksStream.MaxPayloadSize + 1])); + } + + [Fact] + public async Task ReadAndWrite_HonorCancellation() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + var stream = new ShadowsocksStream(new DuplexTestStream(new byte[64]), method, MasterKey(method), Salt(method, 4), leaveInnerOpen: true); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(async () => + await stream.ReadAsync(new byte[64], cts.Token)); + await Assert.ThrowsAnyAsync(async () => + await stream.WriteAsync("hello"u8.ToArray(), cts.Token)); + } + + [Fact] + public async Task Dispose_DisposesTheInnerStreamUnlessAskedNotTo_AndRejectsLaterUse() + { + ShadowsocksMethod method = ShadowsocksMethod.Aes256Gcm; + + var owned = new DuplexTestStream([]); + var stream = new ShadowsocksStream(owned, method, MasterKey(method), Salt(method, 5)); + await stream.DisposeAsync(); + await stream.DisposeAsync(); + Assert.Equal(1, owned.DisposeCount); + Assert.False(stream.CanRead); + await Assert.ThrowsAsync(async () => await stream.ReadAsync(new byte[8])); + await Assert.ThrowsAsync(async () => await stream.WriteAsync(new byte[8])); + + var borrowed = new DuplexTestStream([]); + new ShadowsocksStream(borrowed, method, MasterKey(method), Salt(method, 6), leaveInnerOpen: true).Dispose(); + Assert.Equal(0, borrowed.DisposeCount); + } + + [Fact] + public void Constructor_RejectsWrongSizesAndNull() + { + var transport = new DuplexTestStream([]); + byte[] key32 = MasterKey(ShadowsocksMethod.Aes256Gcm); + + Assert.Throws(() => + new ShadowsocksStream(transport, ShadowsocksMethod.Aes128Gcm, key32, Salt(ShadowsocksMethod.Aes128Gcm, 1))); + Assert.Throws(() => + new ShadowsocksStream(transport, ShadowsocksMethod.Aes256Gcm, key32, new byte[16])); + Assert.Throws(() => + new ShadowsocksStream(null!, ShadowsocksMethod.Aes256Gcm, key32, Salt(ShadowsocksMethod.Aes256Gcm, 1))); + } + + /// + /// A write-only transport that counts its writes, so how many transport writes a + /// Write turns into is observable. + /// + private sealed class CountingSink : Stream + { + private readonly List _written = []; + + public int WriteCount { get; private set; } + public byte[] Written => [.. _written]; + + public override bool CanRead => false; + 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 void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override void Write(ReadOnlySpan buffer) + { + WriteCount++; + _written.AddRange(buffer); + } + + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Write(buffer.Span); + return ValueTask.CompletedTask; + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + /// + /// A read-only transport that hands out a pre-recorded byte stream in slices of random + /// length (1..maxSlice), so the reader sees salt, length blocks and payloads split anywhere. + /// + private sealed class RandomSliceStream(byte[] inbound, Random rng, int maxSlice) : Stream + { + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(Span buffer) + { + int remaining = inbound.Length - _position; + if (remaining <= 0 || buffer.IsEmpty) + return 0; + + int count = Math.Min(Math.Min(buffer.Length, rng.Next(1, maxSlice + 1)), remaining); + inbound.AsSpan(_position, count).CopyTo(buffer); + _position += count; + return count; + } + + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return ValueTask.FromResult(Read(buffer.Span)); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } +} diff --git a/QuickProxyNet.Tests/SkipGates.cs b/QuickProxyNet.Tests/SkipGates.cs index 675b43e..e1bf391 100644 --- a/QuickProxyNet.Tests/SkipGates.cs +++ b/QuickProxyNet.Tests/SkipGates.cs @@ -1,3 +1,7 @@ +using System.Reflection; +using System.Security.Cryptography; +using Xunit.Sdk; + namespace QuickProxyNet.Tests; /// @@ -131,3 +135,31 @@ public sealed class DockerTheoryAttribute : TheoryAttribute public DockerTheoryAttribute() => Skip = SkipGates.DockerEnabled ? null : $"{SkipGates.DockerSwitch} is not set to 1."; } + +/// +/// An [InlineData] row that reports as skipped when the OS does not provide +/// ChaCha20-Poly1305 ( — false on every Windows 10). +/// +/// +/// The theory's other rows still run. This exists so a cipher theory can cover +/// chacha20-ietf-poly1305 without an early return inside the test body, which +/// would report "did not run" as passed. xunit v2 honours at +/// discovery time, the same mechanism relies on. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class ChaCha20InlineDataAttribute : DataAttribute +{ + private readonly object[] _data; + + /// The row's arguments. + public ChaCha20InlineDataAttribute(params object[] data) + { + _data = data; + Skip = ChaCha20Poly1305.IsSupported + ? null + : "ChaCha20-Poly1305 is not available on this OS (Windows needs build 20142 or later)."; + } + + /// + public override IEnumerable GetData(MethodInfo testMethod) => [_data]; +} diff --git a/QuickProxyNet/Clients/ShadowsocksClient.cs b/QuickProxyNet/Clients/ShadowsocksClient.cs new file mode 100644 index 0000000..0ac2a0c --- /dev/null +++ b/QuickProxyNet/Clients/ShadowsocksClient.cs @@ -0,0 +1,182 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// Connects to a target host through a Shadowsocks proxy, speaking the AEAD construction of +/// SIP004/SIP007 over raw TCP with aes-128-gcm, aes-192-gcm, aes-256-gcm or +/// chacha20-ietf-poly1305. +/// +/// +/// +/// Everything else is refused by name with at construction, +/// before any byte is written: the AEAD-2022 (2022-blake3-*, SIP022) family, every legacy +/// stream cipher, none/plain, xchacha20-ietf-poly1305 (no XChaCha20 in the +/// .NET BCL), and any SIP003 plugin=. There is no TLS, no ws/httpupgrade +/// transport and no UDP in this protocol as spoken here. chacha20-ietf-poly1305 also +/// requires an OS that provides ChaCha20-Poly1305 — on Windows that is build 20142 or later +/// (Windows 11 / Server 2022), never Windows 10. +/// +/// +/// writes the salt and the +/// sealed target address and returns a that seals everything written and +/// opens everything read. The server's salt is consumed lazily on the first Read — a real +/// server does not send it until the target has replied, so reading it at connect time would +/// deadlock every client-speaks-first protocol. +/// +/// +/// A wrong password is not reported as one, by design of the protocol. A Shadowsocks +/// server that cannot open the first chunk sends nothing and closes; Xray goes further and drains +/// a pseudo-random number of bytes first so the failure is not even timing-distinguishable. From +/// here that is a first Read failing with +/// () or timing out — exactly what a target the +/// server could not reach looks like. Nothing on the wire tells the two apart, so +/// ConnectAsync succeeds either way. +/// +/// +public sealed class ShadowsocksClient : ProxyClient +{ + private const byte AtypIPv4 = 0x01; + private const byte AtypDomain = 0x03; + private const byte AtypIPv6 = 0x04; + + /// atyp(1) + address(var) + port(2). + private const int MaxAddressHeaderSize = ProxyAddress.MaxLength + 2; + + private readonly ShadowsocksMethod _method; + + /// Creates a Shadowsocks client from strongly-typed options. + /// is null. + /// The password or the cipher name () is empty. + /// + /// The cipher is not one of the four this library speaks, a plugin is configured, or the + /// platform does not provide the cipher. The message names what was found and what is accepted. + /// + public ShadowsocksClient(ShadowsocksOptions options) + : base("ss", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) + { + if (string.IsNullOrEmpty(options.Password)) + throw new ArgumentException("Shadowsocks password must not be empty.", nameof(options)); + + // A missing required field is the caller's mistake (ArgumentException family), not a link + // naming a cipher this library cannot speak. The share-link parser never lets one through. + if (string.IsNullOrEmpty(options.Method)) + throw new ArgumentException("Shadowsocks cipher name (Method) must not be empty.", nameof(options)); + + // Refuse by name before anything else happens; there is no cipher to fall back to. + _method = ShadowsocksCipher.Resolve(options.Method); + + if (!string.IsNullOrEmpty(options.Plugin)) + throw new NotSupportedException(PluginMessage(options.Plugin)); + + ShadowsocksCipher.EnsurePlatformSupport(_method); + + Options = options; + } + + /// Creates a Shadowsocks client by parsing an ss:// share link. + /// The link is malformed. + /// The link names a cipher or plugin this library does not speak. + public static ShadowsocksClient FromShareLink(string shareLink) => new(ShadowsocksShareLink.Parse(shareLink)); + + /// The parsed Shadowsocks configuration this client connects with. + public ShadowsocksOptions Options { get; } + + /// + public override ProxyType Type => ProxyType.Shadowsocks; + + /// Fills a salt buffer. Test hook: production draws a fresh random salt per connection. + internal delegate void SaltFiller(Span salt); + + /// + /// Replaces the random salt source. Tests inject the fixed salt the pinned vectors were + /// generated with; production leaves this . + /// + internal SaltFiller? SaltSource { get; set; } + + /// + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + + ShadowsocksStream tunnel = CreateTunnel(stream); + try + { + byte[] header = ArrayPool.Shared.Rent(MaxAddressHeaderSize); + try + { + int length = BuildAddressHeader(header, host, port); + + // salt ‖ chunk(atyp ‖ addr ‖ port), eagerly, in one write. Nothing is read here: + // the server will not answer until the target does. + await tunnel.WriteAsync(header.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + await tunnel.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(header); + } + + return tunnel; + } + catch + { + // Owns the transport too, so this unwinds the whole stack. + await tunnel.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// + /// Writes the SOCKS5-style target address — atyp(1) ‖ addr ‖ port(2 BE), with + /// 0x01 IPv4, 0x03 domain, 0x04 IPv6 and the port after the + /// address — and returns the number of bytes written. + /// + internal static int BuildAddressHeader(Span buffer, string host, int port) + { + int length = ProxyAddress.WriteTypeAndAddress(host, buffer, AtypIPv4, AtypDomain, AtypIPv6); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(length), (ushort)port); + return length + 2; + } + + // Derives the master key and draws the salt on the stack, and keys the stream with them. + // Synchronous because stackalloc cannot live across an await. + private ShadowsocksStream CreateTunnel(Stream stream) + { + int keySize = ShadowsocksCipher.KeySize(_method); + Span masterKey = stackalloc byte[ShadowsocksCipher.MaxKeySize]; + Span salt = stackalloc byte[ShadowsocksCipher.MaxKeySize]; + masterKey = masterKey.Slice(0, keySize); + salt = salt.Slice(0, keySize); + try + { + ShadowsocksCipher.DeriveMasterKey(Options.Password, masterKey); + + if (SaltSource is null) + ShadowsocksCipher.FillSalt(salt); + else + SaltSource(salt); + + // The stream copies both; the local master key is zeroed below. + return new ShadowsocksStream(stream, _method, masterKey, salt); + } + finally + { + CryptographicOperations.ZeroMemory(masterKey); + } + } + + private static string PluginMessage(string plugin) + { + // SIP003: "name;key=value;…" — the name is what the user recognises. + int semicolon = plugin.IndexOf(';'); + string name = semicolon < 0 ? plugin : plugin.Substring(0, semicolon); + return $"Shadowsocks plugin '{name}' is not supported: SIP003 plugins (obfs-local/simple-obfs, v2ray-plugin, " + + "xray-plugin, kcptun, GoQuiet, Cloak, gost-plugin) are separate processes this library does not spawn, " + + "and connecting without the plugin would send plain Shadowsocks to a server expecting an obfuscated " + + "stream. Use a server reachable without a plugin."; + } +} diff --git a/QuickProxyNet/Configs/ShadowsocksOptions.cs b/QuickProxyNet/Configs/ShadowsocksOptions.cs new file mode 100644 index 0000000..892d7d7 --- /dev/null +++ b/QuickProxyNet/Configs/ShadowsocksOptions.cs @@ -0,0 +1,48 @@ +namespace QuickProxyNet; + +/// +/// Strongly-typed configuration for a Shadowsocks outbound, produced by +/// or built directly. +/// +/// +/// +/// Only the AEAD ciphers of SIP004/SIP007 are spoken: aes-128-gcm, aes-192-gcm, +/// aes-256-gcm and chacha20-ietf-poly1305. Every other — the +/// AEAD-2022 (2022-blake3-*) family, the legacy stream ciphers, none/plain, +/// xchacha20-ietf-poly1305 — is parsed so callers can inspect it, but constructing a +/// from it throws +/// naming the cipher. +/// +/// +/// The same holds for : a SIP003 plugin is a separate process this library +/// does not spawn, so any value is rejected by name at construction, never ignored. +/// +/// +public sealed class ShadowsocksOptions +{ + /// + /// The cipher name (method), e.g. aes-256-gcm or chacha20-ietf-poly1305. + /// + public required string Method { get; init; } + + /// + /// The password. The master key is derived from its UTF-8 bytes with OpenSSL's + /// EVP_BytesToKey (MD5, no salt, one round). + /// + public required string Password { get; init; } + + /// Proxy server host name or IP address. + public required string Host { get; init; } + + /// Proxy server port. Share links without a port default to 8388. + public required int Port { get; init; } + + /// + /// The SIP003 plugin string from the share link's plugin= parameter + /// (name;key=value;…), or . Any plugin is unsupported. + /// + public string? Plugin { get; init; } + + /// Human-readable label from the share-link fragment (#tag). + public string? Remark { get; init; } +} diff --git a/QuickProxyNet/Configs/ShadowsocksShareLink.cs b/QuickProxyNet/Configs/ShadowsocksShareLink.cs new file mode 100644 index 0000000..a5e7a14 --- /dev/null +++ b/QuickProxyNet/Configs/ShadowsocksShareLink.cs @@ -0,0 +1,470 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace QuickProxyNet; + +/// +/// Parses ss:// share links into . +/// +/// +/// +/// Two grammars exist in the wild and both are handled: +/// +/// +/// +/// Legacy — ss://base64(method:password@host:port)#tag. The whole authority is one +/// base64 blob; the #tag sits outside it. +/// +/// +/// SIP002 — ss://userinfo@host:port[/][?plugin=…][#tag], where userinfo is +/// either base64 of method:password (URL-safe or standard alphabet, padding optional, padding +/// sometimes percent-encoded as %3D) or the literal method:password, percent-encoded. +/// The two are told apart the way shadowsocks-rust and v2rayN do: percent-decode first; a +/// ':' in the result means plain text. +/// +/// +/// +/// The authority is scanned by hand rather than through : a standard-alphabet +/// base64 userinfo may contain /, which would read as the start of the +/// path. A missing port defaults to 8388. The query is scanned with +/// so a plugin= cannot hide behind an +/// HTML-escaped &amp; and be dropped as an unknown key — that would connect as plain +/// Shadowsocks to a server expecting an obfuscated stream. +/// +/// +/// Parsing accepts any cipher name and any plugin; rejects the +/// unsupported ones by name. SIP008 JSON subscriptions are not a URI and are not handled here. +/// +/// +public static class ShadowsocksShareLink +{ + /// The port a share link without one means: 8388. + public const int DefaultPort = 8388; + + private const string Scheme = "ss://"; + + /// + /// Parses an ss:// share link. + /// + /// The link is malformed. + public static ShadowsocksOptions Parse(string shareLink) + { + if (!TryParse(shareLink, out var options, out var error)) + throw new FormatException(error); + return options; + } + + /// + /// Attempts to parse an ss:// share link, returning instead + /// of throwing on malformed input. + /// + public static bool TryParse(string shareLink, [NotNullWhen(true)] out ShadowsocksOptions? options) + => TryParse(shareLink, out options, out _); + + private static bool TryParse( + string shareLink, + [NotNullWhen(true)] out ShadowsocksOptions? options, + [NotNullWhen(false)] out string? error) + { + options = null; + + if (string.IsNullOrWhiteSpace(shareLink)) + { + error = "Shadowsocks share link is empty."; + return false; + } + + string trimmed = shareLink.Trim(); + if (!trimmed.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase)) + { + error = "Shadowsocks share link must start with 'ss://'."; + return false; + } + + ReadOnlySpan body = trimmed.AsSpan(Scheme.Length); + + // '#tag' is outside both grammars' payload and is percent-encoded. + string? remark = null; + int hash = body.IndexOf('#'); + if (hash >= 0) + { + ReadOnlySpan fragment = body.Slice(hash + 1); + remark = fragment.IsEmpty ? null : Uri.UnescapeDataString(fragment.ToString()); + body = body.Slice(0, hash); + } + + // Neither base64 alphabet contains '?', and a plain userinfo must percent-encode one. + ReadOnlySpan query = default; + int question = body.IndexOf('?'); + if (question >= 0) + { + query = body.Slice(question + 1); + body = body.Slice(0, question); + } + + string method, password, host; + int port; + + // Neither base64 alphabet contains '@' either, so its presence decides the grammar. + // The LAST '@' separates userinfo from host: a base64 userinfo has none, a plain one + // must encode a literal '@' as %40. + int at = body.LastIndexOf('@'); + if (at < 0) + { + if (!TryParseLegacy(body, out method, out password, out host, out port, out error)) + return false; + } + else + { + ReadOnlySpan hostPort = body.Slice(at + 1); + int slash = hostPort.IndexOf('/'); + if (slash >= 0) + hostPort = hostPort.Slice(0, slash); // the optional trailing '/' (path) + + if (!TryDecodeUserInfo(body.Slice(0, at), out method, out password, out error)) + return false; + if (!TryParseHostPort(hostPort, out host, out port, out error)) + return false; + } + + if (method.Length == 0) + { + error = "Shadowsocks share link is missing the cipher name (the 'method' before ':')."; + return false; + } + + if (password.Length == 0) + { + error = "Shadowsocks share link is missing the password."; + return false; + } + + string? plugin = null; + while (!query.IsEmpty) + { + int amp = query.IndexOf('&'); + ReadOnlySpan pair = amp < 0 ? query : query.Slice(0, amp); + query = amp < 0 ? default : query.Slice(amp + 1); + + int eq = pair.IndexOf('='); + if (eq < 0) + continue; + + ReadOnlySpan key = ShareLinkQuery.StripHtmlAmpPrefix(pair.Slice(0, eq)); + ReadOnlySpan rawVal = pair.Slice(eq + 1); + if (rawVal.IsEmpty) + continue; + + if (key.Equals("plugin", StringComparison.OrdinalIgnoreCase)) + plugin = Decode(rawVal); + } + + options = new ShadowsocksOptions + { + Method = method, + Password = password, + Host = host, + Port = port, + Plugin = plugin, + Remark = remark + }; + error = null; + return true; + } + + // Legacy form: the whole body is base64(method:password@host:port), possibly followed by a + // '/' that some producers append. A standard-alphabet blob can legitimately END in '/', so the + // untrimmed text is tried first and the trailing slashes are only dropped if that fails. + private static bool TryParseLegacy( + ReadOnlySpan body, + out string method, out string password, out string host, out int port, + [NotNullWhen(false)] out string? error) + { + method = password = host = string.Empty; + port = 0; + error = null; + + if (TryDecodeBase64Utf8(body, out DecodedText text)) + { + using (text) + { + if (TrySplitLegacy(text.Span, out method, out password, out host, out port, out error)) + return true; + } + } + + ReadOnlySpan trimmed = body.TrimEnd('/'); + if (trimmed.Length != body.Length && TryDecodeBase64Utf8(trimmed, out text)) + { + using (text) + { + if (TrySplitLegacy(text.Span, out method, out password, out host, out port, out error)) + return true; + } + } + + error ??= "Shadowsocks share link has no '@' and is not base64 of 'method:password@host:port' (the legacy form)."; + return false; + } + + private static bool TrySplitLegacy( + ReadOnlySpan text, + out string method, out string password, out string host, out int port, + [NotNullWhen(false)] out string? error) + { + method = password = host = string.Empty; + port = 0; + + // A hand-made blob often carries the newline `echo | base64` appended; the outer link was + // trimmed, this is the decoded text. Only the ends go, so a password keeps its own spaces. + text = text.Trim(); + + // The password is raw here and may itself contain '@' — the host follows the LAST one. + int at = text.LastIndexOf('@'); + if (at < 0) + { + error = "Shadowsocks legacy share link decodes to text without '@' between the credentials and the host."; + return false; + } + + int colon = text.IndexOf(':'); + if (colon < 0 || colon > at) + { + error = "Shadowsocks legacy share link decodes to text without ':' between the cipher name and the password."; + return false; + } + + method = text.Slice(0, colon).ToString(); + password = text.Slice(colon + 1, at - colon - 1).ToString(); + return TryParseHostPort(text.Slice(at + 1), out host, out port, out error); + } + + // SIP002 userinfo: percent-decode, then ':' means plain 'method:password'; otherwise base64. + // Percent-decoding needs a string; without a '%' (the common case) the span is split as is. + private static bool TryDecodeUserInfo( + ReadOnlySpan userInfo, + out string method, out string password, + [NotNullWhen(false)] out string? error) + { + method = password = string.Empty; + + if (userInfo.IsEmpty) + { + error = "Shadowsocks share link is missing the userinfo (cipher name and password) before '@'."; + return false; + } + + if (userInfo.IndexOf('%') < 0) + return TrySplitUserInfo(userInfo, out method, out password, out error); + + return TrySplitUserInfo(Uri.UnescapeDataString(userInfo.ToString()), out method, out password, out error); + } + + private static bool TrySplitUserInfo( + ReadOnlySpan decoded, + out string method, out string password, + [NotNullWhen(false)] out string? error) + { + method = password = string.Empty; + + int colon = decoded.IndexOf(':'); + if (colon >= 0) + { + method = decoded.Slice(0, colon).ToString(); + password = decoded.Slice(colon + 1).ToString(); + error = null; + return true; + } + + if (!TryDecodeBase64Utf8(decoded, out DecodedText text)) + { + error = "Shadowsocks share link userinfo is neither base64 nor a percent-encoded 'method:password'."; + return false; + } + + using (text) + { + ReadOnlySpan plain = text.Span; + colon = plain.IndexOf(':'); + if (colon < 0) + { + error = "Shadowsocks share link userinfo decodes to text without ':' between the cipher name and the password."; + return false; + } + + method = plain.Slice(0, colon).ToString(); + password = plain.Slice(colon + 1).ToString(); + error = null; + return true; + } + } + + // host[:port], with a bracketed IPv6 literal allowed. Port defaults to 8388. + private static bool TryParseHostPort( + ReadOnlySpan hostPort, + out string host, out int port, + [NotNullWhen(false)] out string? error) + { + host = string.Empty; + port = DefaultPort; + + if (hostPort.IsEmpty) + { + error = "Shadowsocks share link is missing the server host."; + return false; + } + + ReadOnlySpan portText = default; + if (hostPort[0] == '[') + { + int close = hostPort.IndexOf(']'); + if (close < 0) + { + error = "Shadowsocks share link has an unterminated '[' in the server host."; + return false; + } + + // Uri.Host would keep the brackets; the raw address is what the socket needs. + host = hostPort.Slice(1, close - 1).ToString(); + ReadOnlySpan rest = hostPort.Slice(close + 1); + if (!rest.IsEmpty) + { + if (rest[0] != ':') + { + error = "Shadowsocks share link has stray characters after the bracketed IPv6 host."; + return false; + } + + portText = rest.Slice(1); + } + } + else + { + int colon = hostPort.LastIndexOf(':'); + if (colon >= 0 && hostPort.IndexOf(':') != colon) + { + // More than one ':' without brackets. '2001:db8::1:9000' is a valid IPv6 address + // AND almost certainly meant '[2001:db8::1]:9000'; there is no reading that does + // not guess, so — like Uri and the url crate shadowsocks-rust parses with — refuse. + error = "Shadowsocks share link has an IPv6 server host that is not bracketed; write it as '[addr]:port'."; + return false; + } + + if (colon >= 0) + { + host = hostPort.Slice(0, colon).ToString(); + portText = hostPort.Slice(colon + 1); + } + else + { + host = hostPort.ToString(); + } + } + + if (host.Length == 0) + { + error = "Shadowsocks share link is missing the server host."; + return false; + } + + if (!portText.IsEmpty) + { + if (!int.TryParse(portText, System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out port) || + port <= 0 || port > 65535) + { + error = "Shadowsocks share link does not have a valid server port."; + return false; + } + } + + error = null; + return true; + } + + // Decodes base64 in either alphabet, with or without padding, into UTF-8 text held in a + // pooled buffer the caller disposes. The text is decoded into the same char[] that held the + // normalized base64 — the UTF-8 char count never exceeds the byte count, which never exceeds + // the base64 length — so the only strings built are the final method, password and host. + private static bool TryDecodeBase64Utf8(ReadOnlySpan payload, out DecodedText text) + { + text = default; + if (payload.IsEmpty) + return false; + + // Padding may add up to 3 characters to the normalized form. + char[] chars = ArrayPool.Shared.Rent(payload.Length + 3); + byte[] bytes = ArrayPool.Shared.Rent(payload.Length); // decoded is always shorter + int length = 0; + int decoded = 0; + bool handedOver = false; + try + { + for (int i = 0; i < payload.Length; i++) + { + char c = payload[i]; + if (char.IsWhiteSpace(c)) + continue; + + chars[length++] = c switch + { + '-' => '+', + '_' => '/', + _ => c + }; + } + + int remainder = length % 4; + if (remainder == 1) + return false; // no base64 string has this length + + for (int i = remainder; remainder != 0 && i < 4; i++) + chars[length++] = '='; + + if (!Convert.TryFromBase64Chars(chars.AsSpan(0, length), bytes, out decoded) || decoded == 0) + return false; + + int textLength = Encoding.UTF8.GetChars(bytes.AsSpan(0, decoded), chars); + text = new DecodedText(chars, textLength, length); + handedOver = true; + return true; + } + finally + { + // Both may have held the password; only the bytes actually written need clearing. + Array.Clear(bytes, 0, decoded); + ArrayPool.Shared.Return(bytes); + if (!handedOver) + { + Array.Clear(chars, 0, length); + ArrayPool.Shared.Return(chars); + } + } + } + + /// + /// Decoded text in a pooled char[]: is the text, + /// clears every character the buffer was written with (the text and the base64 it came from) + /// and returns the array. + /// + private readonly struct DecodedText(char[] rented, int length, int dirtyLength) : IDisposable + { + public ReadOnlySpan Span => rented.AsSpan(0, length); + + public void Dispose() + { + if (rented is null) + return; + + Array.Clear(rented, 0, Math.Max(length, dirtyLength)); + ArrayPool.Shared.Return(rented); + } + } + + private static string Decode(ReadOnlySpan value) + { + // Only pay for unescaping when the value actually contains an escape. + return value.IndexOf('%') < 0 ? value.ToString() : Uri.UnescapeDataString(value.ToString()); + } +} diff --git a/QuickProxyNet/Internal/Shadowsocks/ShadowsocksCipher.cs b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksCipher.cs new file mode 100644 index 0000000..f9a05f2 --- /dev/null +++ b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksCipher.cs @@ -0,0 +1,408 @@ +using System.Buffers; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet; + +/// The Shadowsocks AEAD ciphers this library speaks (SIP004/SIP007). +internal enum ShadowsocksMethod +{ + /// aes-128-gcm: 16-byte key and salt. + Aes128Gcm, + + /// aes-192-gcm: 24-byte key and salt. Not in the spec table; sing-box and shadowsocks-libev speak it, Xray does not. + Aes192Gcm, + + /// aes-256-gcm: 32-byte key and salt. + Aes256Gcm, + + /// chacha20-ietf-poly1305: 32-byte key and salt. + ChaCha20Poly1305 +} + +/// +/// The Shadowsocks cipher table and key schedule: method-name resolution with by-name +/// rejection of everything else, EVP_BytesToKey for the master key, HKDF-SHA1 for the +/// per-session subkey, and the platform gate for ChaCha20-Poly1305. +/// +/// +/// +/// Written from the spec (shadowsocks.org/doc/aead) and validated against vectors generated by +/// an independent implementation; behaviour cross-checked with shadowsocks-crypto (MIT) and +/// go-shadowsocks2 (Apache-2.0). Salt length equals key length for every AEAD method — the +/// reason aes-192-gcm has a 24-byte salt, which the spec table (which omits that cipher) +/// cannot tell you. +/// +/// +/// Rejection is by name and lists what is accepted. There is no fallback: an unknown cipher +/// must not become "some cipher", a none cipher must not become a proxy that sends the +/// target address in the clear. +/// +/// +internal static class ShadowsocksCipher +{ + /// Size of every AEAD tag, in bytes. + public const int TagSize = 16; + + /// Size of the AEAD nonce, in bytes (a little-endian counter). + public const int NonceSize = 12; + + /// The largest key — and therefore salt — any supported method uses. + public const int MaxKeySize = 32; + + /// The names accepts, for error messages. + public const string AcceptedNames = + "'aes-128-gcm', 'aes-192-gcm', 'aes-256-gcm' and 'chacha20-ietf-poly1305'"; + + /// + /// The accepted names that need only AES-GCM, for the message a platform without + /// ChaCha20-Poly1305 gets. Kept next to so the two lists cannot drift. + /// + public const string AcceptedAesNames = "'aes-128-gcm', 'aes-192-gcm' and 'aes-256-gcm'"; + + /// The longest string echoes back as a cipher name. + private const int MaxEchoedNameLength = 40; + + private const int Md5Size = 16; + private const int Sha1Size = 20; + private const int StackScratchBytes = 256; + + /// The HKDF info string SIP004 fixes: the ASCII bytes ss-subkey. + private static ReadOnlySpan SubkeyInfo => "ss-subkey"u8; + + /// Length of , for the stack buffer that holds it. + private const int SubkeyInfoLength = 9; + + /// Key size of in bytes. + public static int KeySize(ShadowsocksMethod method) => method switch + { + ShadowsocksMethod.Aes128Gcm => 16, + ShadowsocksMethod.Aes192Gcm => 24, + _ => 32 + }; + + /// Salt size of in bytes — always equal to the key size. + public static int SaltSize(ShadowsocksMethod method) => KeySize(method); + + /// The canonical wire name of . + public static string Name(ShadowsocksMethod method) => method switch + { + ShadowsocksMethod.Aes128Gcm => "aes-128-gcm", + ShadowsocksMethod.Aes192Gcm => "aes-192-gcm", + ShadowsocksMethod.Aes256Gcm => "aes-256-gcm", + _ => "chacha20-ietf-poly1305" + }; + + /// + /// Resolves a cipher name, or throws a that names the + /// cipher found, the reason it is refused, and the names that are accepted. + /// + public static ShadowsocksMethod Resolve(string? method) + { + if (TryResolve(method, out ShadowsocksMethod resolved)) + return resolved; + + throw new NotSupportedException(RejectionMessage(method)); + } + + /// Resolves a cipher name without throwing. + public static bool TryResolve(string? method, out ShadowsocksMethod resolved) + { + // Exactly the four canonical wire names, case-insensitively: a capitalised name is not a + // different cipher, and accepting it downgrades nothing. No aliases — the spec table's + // AEAD_* labels never appear in a link, and Xray's bare 'chacha20-poly1305' is ambiguous + // (RejectionMessage says why); both are refused by name below. + if (Is(method, "aes-128-gcm")) + { + resolved = ShadowsocksMethod.Aes128Gcm; + return true; + } + + if (Is(method, "aes-192-gcm")) + { + resolved = ShadowsocksMethod.Aes192Gcm; + return true; + } + + if (Is(method, "aes-256-gcm")) + { + resolved = ShadowsocksMethod.Aes256Gcm; + return true; + } + + if (Is(method, "chacha20-ietf-poly1305")) + { + resolved = ShadowsocksMethod.ChaCha20Poly1305; + return true; + } + + resolved = default; + return false; + } + + /// + /// The message for a refused cipher name: what was found, why it is refused, what is accepted. + /// + public static string RejectionMessage(string? method) + { + string found = method ?? string.Empty; + string reason; + + if (found.Length == 0) + reason = "no cipher name was given"; + else if (found.StartsWith("2022-blake3-", StringComparison.OrdinalIgnoreCase)) + reason = "it is an AEAD-2022 (SIP022) cipher, a different protocol under the same URI scheme " + + "that needs BLAKE3, which the .NET BCL does not provide"; + else if (Is(found, "chacha20-poly1305")) + reason = "the bare name is ambiguous: Xray reads it as 'chacha20-ietf-poly1305', early " + + "shadowsocks-libev meant the pre-RFC 64-bit-nonce draft by it; write 'chacha20-ietf-poly1305'"; + else if (found.StartsWith("aead_", StringComparison.OrdinalIgnoreCase)) + reason = "it is a spec-table identifier, not a wire name; use the lower-case dashed name"; + else if (Is(found, "xchacha20-ietf-poly1305")) + reason = "there is no XChaCha20-Poly1305 in the .NET BCL"; + else if (Is(found, "none") || Is(found, "plain") || Is(found, "dummy")) + reason = "it sends the target address and the payload unencrypted"; + else if (IsLegacyStreamCipher(found)) + reason = "it is a legacy stream cipher, which the Shadowsocks project has deprecated as broken"; + else if (IsUnimplementedAead(found)) + reason = "it is an AEAD outside the SIP004 table with no implementation here"; + else if (!LooksLikeCipherName(found)) + // A swapped 'password:method' puts the password here; echo its length, not its text. + return $"Shadowsocks cipher is not supported: a {found.Length}-character value that is not a cipher " + + $"name was found where the cipher name belongs. Accepted: {AcceptedNames}."; + else + reason = "it is not a Shadowsocks cipher name this library recognises"; + + return $"Shadowsocks cipher '{found}' is not supported: {reason}. Accepted: {AcceptedNames}."; + } + + // A cipher name is short ASCII — letters, digits, '-' and '_' — with at least one separator + // (every name outside the fixed single-word set handled above has one: aes-…, camellia-…, + // rc4-md5, 2022-blake3-…). Anything else is echoed by length only, because the usual way a + // non-name lands here is a swapped 'password:method'. A password shaped exactly like a cipher + // name would still be echoed; that is the trade-off for naming what was found. + private static bool LooksLikeCipherName(string value) + { + if (value.Length > MaxEchoedNameLength) + return false; + + bool separator = false; + foreach (char c in value) + { + if (c is '-' or '_') + separator = true; + else if (!char.IsAsciiLetterOrDigit(c)) + return false; + } + + return separator; + } + + /// + /// Verifies the platform can run , throwing a + /// that names the OS otherwise. + /// + /// + /// is false on every shipped Windows 10: the + /// runtime requires build 20142, which only Windows 11 and Windows Server 2022 reach. About + /// half of real-world ss:// nodes use this cipher, so this message is one users see. + /// + public static void EnsurePlatformSupport(ShadowsocksMethod method) + { + if (method == ShadowsocksMethod.ChaCha20Poly1305) + { + if (!ChaCha20Poly1305.IsSupported) + { + string hint = OperatingSystem.IsWindows() + ? " On Windows it needs build 20142 or later (Windows 11 / Windows Server 2022); no Windows 10 release has it." + : string.Empty; + throw new NotSupportedException( + "Shadowsocks cipher 'chacha20-ietf-poly1305' requires ChaCha20-Poly1305, which this platform " + + $"({RuntimeInformation.OSDescription}) does not provide.{hint} Accepted on this platform: " + + $"{AcceptedAesNames}."); + } + + return; + } + + if (!AesGcm.IsSupported) + throw new NotSupportedException( + $"Shadowsocks cipher '{Name(method)}' requires AES-GCM, which this platform " + + $"({RuntimeInformation.OSDescription}) does not provide."); + } + + /// + /// Derives the master key from : UTF-8 bytes through + /// . must be the method's key size. + /// + public static void DeriveMasterKey(string password, Span key) + { + ArgumentNullException.ThrowIfNull(password); + + int maxBytes = Encoding.UTF8.GetMaxByteCount(password.Length); + byte[]? rented = maxBytes > StackScratchBytes ? ArrayPool.Shared.Rent(maxBytes) : null; + Span utf8 = rented ?? stackalloc byte[StackScratchBytes]; + int written = 0; + try + { + written = Encoding.UTF8.GetBytes(password, utf8); + EvpBytesToKey(utf8.Slice(0, written), key); + } + finally + { + CryptographicOperations.ZeroMemory(utf8.Slice(0, written)); + if (rented is not null) + ArrayPool.Shared.Return(rented); + } + } + + /// + /// OpenSSL's EVP_BytesToKey with MD5, no salt and an iteration count of 1: + /// D_1 = MD5(password), D_i = MD5(D_{i-1} ‖ password), + /// key = (D_1 ‖ D_2 ‖ …)[0..keyLen]. + /// + /// + /// MD5 is what the protocol defines; there is no alternative. This is exactly what + /// openssl enc -k <pass> -md md5 -nosalt -P prints as its key. + /// + public static void EvpBytesToKey(ReadOnlySpan password, Span key) + { + int blockLength = Md5Size + password.Length; + byte[]? rented = blockLength > StackScratchBytes ? ArrayPool.Shared.Rent(blockLength) : null; + Span block = rented ?? stackalloc byte[StackScratchBytes]; + block = block.Slice(0, blockLength); + // The digest is hashed into its own scratch and then copied to the front of `block`: + // HashData does not promise to tolerate a destination that overlaps its source. + Span digest = stackalloc byte[Md5Size]; + try + { + int written = 0; + bool first = true; + while (written < key.Length) + { + if (first) + { + MD5.HashData(password, digest); + first = false; + } + else + { + // block holds D_{i-1} in its first 16 bytes; append the password and hash. + password.CopyTo(block.Slice(Md5Size)); + MD5.HashData(block, digest); + } + + digest.CopyTo(block); + + int take = Math.Min(Md5Size, key.Length - written); + digest.Slice(0, take).CopyTo(key.Slice(written)); + written += take; + } + } + finally + { + CryptographicOperations.ZeroMemory(digest); + CryptographicOperations.ZeroMemory(block); + if (rented is not null) + ArrayPool.Shared.Return(rented); + } + } + + /// + /// Derives one direction's session subkey: + /// HKDF-SHA1(ikm = masterKey, salt, info = "ss-subkey", L = keyLen). + /// must be the method's key size. + /// + /// + /// RFC 5869 spelled out with + /// one-shots rather than : + /// the BCL's Expand step creates an IncrementalHash per call, 272 bytes twice per + /// connection, and every Shadowsocks subkey is at most two SHA-1 blocks long, so the + /// unrolled form is both allocation-free and shorter. Byte-identical to the BCL on the pinned + /// vectors and on 10 000 random (ikm, salt) pairs. + /// + public static void DeriveSubkey(ReadOnlySpan masterKey, ReadOnlySpan salt, Span subkey) + { + if (subkey.Length > 2 * Sha1Size) + throw new ArgumentOutOfRangeException(nameof(subkey), subkey.Length, + $"A Shadowsocks subkey is at most {MaxKeySize} bytes."); + + ReadOnlySpan info = SubkeyInfo; + Span prk = stackalloc byte[Sha1Size]; + Span block = stackalloc byte[Sha1Size]; + Span message = stackalloc byte[Sha1Size + SubkeyInfoLength + 1]; + try + { + // Extract: PRK = HMAC(salt, IKM). + HMACSHA1.HashData(salt, masterKey, prk); + + // Expand, T(1) = HMAC(PRK, info ‖ 0x01). + info.CopyTo(message); + message[info.Length] = 0x01; + HMACSHA1.HashData(prk, message.Slice(0, info.Length + 1), block); + int take = Math.Min(Sha1Size, subkey.Length); + block.Slice(0, take).CopyTo(subkey); + + if (subkey.Length > Sha1Size) + { + // T(2) = HMAC(PRK, T(1) ‖ info ‖ 0x02) — a 24- or 32-byte key needs its head. + block.CopyTo(message); + info.CopyTo(message.Slice(Sha1Size)); + message[Sha1Size + info.Length] = 0x02; + HMACSHA1.HashData(prk, message.Slice(0, Sha1Size + info.Length + 1), block); + block.Slice(0, subkey.Length - Sha1Size).CopyTo(subkey.Slice(Sha1Size)); + } + } + finally + { + // All three hold key material: the PRK outright, the blocks as subkey bytes. + CryptographicOperations.ZeroMemory(prk); + CryptographicOperations.ZeroMemory(block); + CryptographicOperations.ZeroMemory(message); + } + } + + /// + /// Fills with fresh random bytes, redrawing an all-zero result the way + /// shadowsocks-crypto's random_iv_or_salt does. + /// + public static void FillSalt(Span salt) + { + do + { + RandomNumberGenerator.Fill(salt); + } while (salt.IndexOfAnyExcept((byte)0) < 0); + } + + private static bool Is(string? value, string name) => + string.Equals(value, name, StringComparison.OrdinalIgnoreCase); + + // The full stream-cipher name list of shadowsocks-crypto's kind.rs, matched by shape: + // the fixed names, plus aes-*/camellia-* in a stream mode. + private static bool IsLegacyStreamCipher(string name) + { + if (Is(name, "table") || Is(name, "rc4") || Is(name, "rc4-md5") || Is(name, "chacha20") || + Is(name, "chacha20-ietf") || Is(name, "salsa20") || Is(name, "bf-cfb")) + return true; + + bool block = name.StartsWith("aes-", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("camellia-", StringComparison.OrdinalIgnoreCase); + if (!block) + return false; + + return name.EndsWith("-ctr", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("-cfb", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("-cfb1", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("-cfb8", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("-cfb128", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("-ofb", StringComparison.OrdinalIgnoreCase); + } + + // AEADs that exist somewhere (aes-*-ccm, *-gcm-siv, sm4-*) but are outside SIP004 and + // not implemented here. + private static bool IsUnimplementedAead(string name) => + name.EndsWith("-ccm", StringComparison.OrdinalIgnoreCase) || + name.EndsWith("-gcm-siv", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("sm4-", StringComparison.OrdinalIgnoreCase); +} diff --git a/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs new file mode 100644 index 0000000..10075df --- /dev/null +++ b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs @@ -0,0 +1,780 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The Shadowsocks AEAD (SIP004/SIP007) TCP stream: a that seals everything +/// written and opens everything read, with a lazily consumed server salt. +/// +/// +/// +/// Wire format. Each direction starts with a salt of the cipher's key size, then any number of +/// chunks: +/// +/// +/// salt(keyLen) ‖ [ sealed(len BE 2) ‖ tag(16) ‖ sealed(payload) ‖ tag(16) ] … +/// +/// +/// The 2-byte length is the plaintext payload length, big-endian, and is itself the +/// plaintext of its own AEAD operation — the opposite of VMess, whose prefix carries the +/// sealed size in the clear. That is why this class shares no code with +/// VmessStream: the frames look alike and mean different things. The cap is +/// 0x3FFF; a decrypted length above it is refused, not masked (go-shadowsocks2 and +/// shadowsocks-libev mask and desynchronise; shadowsocks-rust refuses; this refuses). +/// +/// +/// The nonce is the whole 12 bytes as a little-endian counter from 0, advanced after +/// every AEAD operation — two per chunk. Read and write counters are independent. +/// Associated data is empty. +/// +/// +/// The write direction's subkey is derived at construction from the caller's salt, which is +/// sent in front of the first chunk. The read direction is keyed on the first read: the +/// server sends its salt only once the target has replied (Xray buffers it behind the target's +/// first bytes, shadowsocks-rust and go-shadowsocks2 write it from their first +/// Write), so reading it inside ConnectAsync would deadlock every +/// client-speaks-first protocol — the same trap as the VLESS and VMess response headers. +/// +/// +/// Buffering. A Write is cut into chunks of at most , and +/// consecutive chunks are sealed back-to-back into one send buffer and handed to the transport +/// in as few writes as the buffer allows; everything sealed by one Write is on the +/// transport before it returns, so a request/response protocol never waits on a byte held here. +/// A Read fills a 32 KiB window with whatever the transport has and parses chunks out of +/// it, opening exactly one non-empty chunk per call: the nonce never advances past what the +/// caller asked for, and a chunk that arrives whole in the window costs no second transport read. +/// +/// +/// There is no in-band terminator. A FIN exactly at a chunk boundary is the only clean end of +/// stream and returns 0. A FIN inside the salt, the length block or a payload is +/// truncation and raises ; so does a failed tag. Neither is +/// ever reported as a clean end of stream. +/// +/// +internal sealed class ShadowsocksStream : Stream +{ + /// Size of every AEAD tag, in bytes. + public const int TagSize = ShadowsocksCipher.TagSize; + + /// Size of the encrypted length field's plaintext, in bytes. + public const int LengthSize = 2; + + /// Size of the sealed length block on the wire: the 2-byte length plus its tag. + public const int LengthBlockSize = LengthSize + TagSize; + + /// The largest payload one chunk may carry: 0x3FFF, the two high bits reserved. + public const int MaxPayloadSize = 0x3FFF; + + /// The largest chunk on the wire: 2 + 16 + 0x3FFF + 16 = 16 417 bytes. + public const int MaxWireChunkSize = LengthBlockSize + MaxPayloadSize + TagSize; + + /// + /// The send buffer while every Write fits one chunk: the salt plus one full chunk, + /// 16 449 bytes — the 32 KiB pool bucket. + /// + private const int SmallSendBufferSize = ShadowsocksCipher.MaxKeySize + MaxWireChunkSize; + + /// + /// The send buffer once a Write has spanned more than one chunk: the salt plus seven + /// full chunks, 114 951 bytes — the 128 KiB pool bucket. Seven chunks carry 114 681 bytes of + /// payload, so CopyToAsync's 81 920-byte buffer leaves as exactly one transport write. + /// + private const int LargeSendBufferSize = ShadowsocksCipher.MaxKeySize + 7 * MaxWireChunkSize; + + private readonly Stream _inner; + private readonly bool _leaveInnerOpen; + private readonly ShadowsocksMethod _method; + private readonly int _saltSize; + + private readonly Direction _writer; + private Direction? _reader; + private byte[]? _masterKey; + + // This side's salt, held inline until the first write carries it out in front of the first chunk. + private SaltBuffer _pendingSalt; + private int _pendingSaltLength; + + private byte[]? _sendBuffer; + + // The receive window: wire bytes not yet parsed sit at [_start, _end) of _receiveBuffer. + private byte[]? _receiveBuffer; + private int _start; + private int _end; + + // The plaintext length of the chunk whose length block has been opened but whose payload has + // not fully arrived; -1 between chunks. Opening the length block consumes it from the window, + // so the length has to survive the next transport read here. + private int _pendingLength = -1; + + private byte[]? _receivePlain; + private int _plainOffset; + private int _plainCount; + + private bool _readEof; + private bool _readFaulted; + private bool _disposed; + + /// + /// Wraps in the Shadowsocks AEAD framing. + /// + /// The transport, positioned before the first byte of either direction. + /// The cipher. + /// The master key (); copied. + /// + /// This side's salt, sent in front of the first chunk. Callers pass a fresh random salt + /// (); tests pass a fixed one. + /// + /// When true, disposing this stream does not dispose the transport. + /// is null. + /// The key or salt is not the method's key size. + /// The platform does not provide the cipher. + public ShadowsocksStream( + Stream innerStream, + ShadowsocksMethod method, + ReadOnlySpan masterKey, + ReadOnlySpan clientSalt, + bool leaveInnerOpen = false) + { + ArgumentNullException.ThrowIfNull(innerStream); + + int keySize = ShadowsocksCipher.KeySize(method); + if (masterKey.Length != keySize) + throw new ArgumentException( + $"Master key for {ShadowsocksCipher.Name(method)} must be exactly {keySize} bytes.", nameof(masterKey)); + if (clientSalt.Length != keySize) + throw new ArgumentException( + $"Salt for {ShadowsocksCipher.Name(method)} must be exactly {keySize} bytes.", nameof(clientSalt)); + + ShadowsocksCipher.EnsurePlatformSupport(method); + + _inner = innerStream; + _leaveInnerOpen = leaveInnerOpen; + _method = method; + _saltSize = keySize; + _masterKey = masterKey.ToArray(); + + Span pendingSalt = _pendingSalt; + clientSalt.CopyTo(pendingSalt); + _pendingSaltLength = keySize; + + Span subkey = stackalloc byte[ShadowsocksCipher.MaxKeySize]; + subkey = subkey.Slice(0, keySize); + try + { + ShadowsocksCipher.DeriveSubkey(masterKey, clientSalt, subkey); + _writer = new Direction(method, subkey); + } + catch + { + CryptographicOperations.ZeroMemory(_masterKey); + throw; + } + finally + { + CryptographicOperations.ZeroMemory(subkey); + } + } + + /// + /// The write nonce as a counter (its low 64 bits): the number of AEAD operations performed + /// so far in that direction, two per chunk. + /// + public ulong WriteNonceCounter => _writer.Counter; + + /// + /// The read nonce as a counter (its low 64 bits): the number of AEAD operations performed + /// so far in that direction, two per chunk. Zero until the server salt has been read. + /// + public ulong ReadNonceCounter => _reader?.Counter ?? 0; + + /// Whether the server salt has been read and the read direction keyed. + public bool IsServerSaltRead => _reader is not null; + + /// Whether the peer closed its direction cleanly, at a chunk boundary. + public bool IsReadCompleted => _readEof; + + /// + /// Whether an earlier read hit truncation, an oversized length or a failed tag. Once set, + /// every further read throws; the stream never reports a clean end after a failure. + /// + public bool IsReadFaulted => _readFaulted; + + public override bool CanRead => !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed; + 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(); + public override void Flush() => _inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + // ================================ reading ================================ + + /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // A truncation or a failed tag is final. Without this latch a caller that swallows the + // first exception and reads again would find the transport already at FIN and get a 0 — + // truncation reported as a clean end of stream, which is exactly what must never happen. + if (_readFaulted) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + "The Shadowsocks read direction failed earlier and cannot continue."); + + if (_plainCount > 0) + return CopyLeftover(buffer); + + if (buffer.IsEmpty || _readEof) + return 0; + + try + { + while (true) + { + // What the window must hold before the next step can run: the salt, then a + // length block, then the payload and tag of the chunk whose length is known. + int available = _end - _start; + int need = _reader is null ? _saltSize + : _pendingLength < 0 ? LengthBlockSize + : _pendingLength + TagSize; + + if (available >= need) + { + if (_reader is null) + { + KeyReader(); + continue; + } + + if (_pendingLength < 0) + { + _pendingLength = OpenLength(); + continue; + } + + int plaintextLength = _pendingLength; + _pendingLength = -1; + + if (plaintextLength == 0) + { + // Legal on the wire and carrying nothing. It must still be opened — that + // checks its tag and advances the nonce — and it must NOT be reported as end + // of stream: 0 from Read means the peer closed, and it has not. + OpenPayload(0, Memory.Empty); + continue; + } + + // Fast path: the caller's buffer can hold the whole chunk, so the AEAD writes + // the plaintext straight into it. + if (buffer.Length >= plaintextLength) + { + OpenPayload(plaintextLength, buffer.Slice(0, plaintextLength)); + return plaintextLength; + } + + EnsurePlainCapacity(plaintextLength); + OpenPayload(plaintextLength, _receivePlain.AsMemory(0, plaintextLength)); + _plainOffset = 0; + _plainCount = plaintextLength; + return CopyLeftover(buffer); + } + + int read = await _inner.ReadAsync(FreeSpace(need - available), cancellationToken); + if (read == 0) + { + if (_reader is null) + throw SaltTruncated(available); + + if (_pendingLength >= 0) + throw Truncated($"inside a chunk whose payload is {_pendingLength} bytes plus a {TagSize}-byte tag"); + + if (available == 0) + { + // A FIN exactly at a chunk boundary: the one clean end of stream. + _readEof = true; + return 0; + } + + throw Truncated($"{available} bytes into the {LengthBlockSize}-byte length block of a chunk"); + } + + _end += read; + } + } + catch (ProxyProtocolException) + { + _readFaulted = true; + throw; + } + } + + /// + public override Task ReadAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + /// + public override int Read(Span buffer) + { + byte[] rented = ArrayPool.Shared.Rent(Math.Max(buffer.Length, 1)); + try + { + int read = ReadAsync(rented.AsMemory(0, buffer.Length), CancellationToken.None) + .AsTask().GetAwaiter().GetResult(); + rented.AsSpan(0, read).CopyTo(buffer); + return read; + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + /// + public override int Read(byte[] buffer, int offset, int count) + => ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None) + .AsTask().GetAwaiter().GetResult(); + + private int CopyLeftover(Memory buffer) + { + int count = Math.Min(buffer.Length, _plainCount); + _receivePlain!.AsMemory(_plainOffset, count).CopyTo(buffer); + _plainOffset += count; + _plainCount -= count; + return count; + } + + // The free tail of the receive buffer for the next transport read, which takes whatever + // arrives. The window is moved to the front only when `missing` more bytes would not fit + // behind it; a compacted buffer always holds a whole chunk, so this happens at most once per + // chunk and never for the salt or a length block alone. + private Memory FreeSpace(int missing) + { + byte[] buffer = _receiveBuffer ??= ArrayPool.Shared.Rent(MaxWireChunkSize); + + if (_start == _end) + { + _start = 0; + _end = 0; + } + else if (buffer.Length - _end < missing) + { + buffer.AsSpan(_start, _end - _start).CopyTo(buffer); + _end -= _start; + _start = 0; + } + + return buffer.AsMemory(_end); + } + + // Keys the read direction from the salt at the front of the window and consumes it. A server + // that cannot open the first chunk sends nothing at all, so a FIN before this is how a wrong + // password looks — and also how a dead target looks; the two are indistinguishable by design + // of the protocol. + private void KeyReader() + { + _reader = CreateReader(_receiveBuffer!.AsSpan(_start, _saltSize)); + _start += _saltSize; + } + + // Split out so the subkey can live on the stack: stackalloc is not allowed in an async method. + private Direction CreateReader(ReadOnlySpan serverSalt) + { + byte[] masterKey = _masterKey + ?? throw new InvalidOperationException("The Shadowsocks master key is no longer available."); + + Span subkey = stackalloc byte[ShadowsocksCipher.MaxKeySize]; + subkey = subkey.Slice(0, _saltSize); + try + { + ShadowsocksCipher.DeriveSubkey(masterKey, serverSalt, subkey); + return new Direction(_method, subkey); + } + finally + { + CryptographicOperations.ZeroMemory(subkey); + // Both subkeys exist now; the master key has done its work. + CryptographicOperations.ZeroMemory(masterKey); + _masterKey = null; + } + } + + private ProxyProtocolException SaltTruncated(int got) => + new(ProxyErrorCode.ConnectionFailed, + got == 0 + ? "The Shadowsocks server closed the connection without sending its salt. A server that " + + "cannot open the first chunk sends nothing, so a wrong password or cipher looks exactly like " + + "a target the server could not reach." + : $"The Shadowsocks server closed the connection {got} bytes into its {_saltSize}-byte salt.", + new EndOfStreamException()); + + // Opens the sealed length block at the front of the window, validates the cap and consumes + // the block. + private int OpenLength() + { + byte[] buffer = _receiveBuffer!; + Span length = stackalloc byte[LengthSize]; + try + { + _reader!.Open(buffer.AsSpan(_start, LengthSize), buffer.AsSpan(_start + LengthSize, TagSize), length); + } + catch (CryptographicException ex) + { + throw BadTag("length block", ex); + } + + _start += LengthBlockSize; + + int plaintextLength = BinaryPrimitives.ReadUInt16BigEndian(length); + if (plaintextLength > MaxPayloadSize) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"Shadowsocks chunk length 0x{plaintextLength:X4} ({plaintextLength}) exceeds the protocol maximum " + + $"0x{MaxPayloadSize:X4} ({MaxPayloadSize}): the two reserved high bits are set. The chunk is " + + $"rejected rather than masked to {plaintextLength & MaxPayloadSize}, because a masked length " + + "silently desynchronises the stream."); + + return plaintextLength; + } + + // Opens the sealed payload and tag at the front of the window into `plaintext`, which must + // be exactly plaintextLength bytes (possibly empty), and consumes them. + private void OpenPayload(int plaintextLength, Memory plaintext) + { + byte[] buffer = _receiveBuffer!; + try + { + _reader!.Open( + buffer.AsSpan(_start, plaintextLength), + buffer.AsSpan(_start + plaintextLength, TagSize), + plaintext.Span); + } + catch (CryptographicException ex) + { + throw BadTag("payload", ex); + } + + _start += plaintextLength + TagSize; + } + + private static ProxyProtocolException BadTag(string what, CryptographicException inner) => + // The nonce has already advanced, so nothing after this chunk can be opened either. + new(ProxyErrorCode.InvalidResponse, + $"A Shadowsocks chunk {what} failed authentication: it was not sealed with this session's key or was " + + "altered in transit. The stream cannot continue.", inner); + + private static ProxyProtocolException Truncated(string where) => + new(ProxyErrorCode.InvalidResponse, + $"The Shadowsocks stream ended {where}. Only a close exactly at a chunk boundary is a clean end of " + + "stream; this is truncation.", new EndOfStreamException()); + + // The leftover buffer is only needed when the caller's buffer is smaller than the incoming + // chunk; large-buffer readers never rent it. + [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_receivePlain))] + private void EnsurePlainCapacity(int plaintextLength) + { + if (_receivePlain is null) + _receivePlain = ArrayPool.Shared.Rent(Math.Max(plaintextLength, 4096)); + else if (_receivePlain.Length < plaintextLength) + { + ArrayPool.Shared.Return(_receivePlain, clearArray: true); + _receivePlain = ArrayPool.Shared.Rent(plaintextLength); + } + } + + // ================================ writing ================================ + + /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + while (!buffer.IsEmpty) + { + int length = SealRun(buffer.Span, out int consumed); + buffer = buffer.Slice(consumed); + await _inner.WriteAsync(_sendBuffer!.AsMemory(0, length), cancellationToken); + } + } + + /// + public override Task WriteAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + /// + public override void Write(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + while (!buffer.IsEmpty) + { + int length = SealRun(buffer, out int consumed); + buffer = buffer.Slice(consumed); + _inner.Write(_sendBuffer!.AsSpan(0, length)); + } + } + + /// + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + /// + /// Seals as exactly one chunk and writes it, prefixed with this + /// side's salt if that has not been sent yet. An empty payload produces an empty chunk — + /// legal on the wire, and the one case + /// never emits. + /// + /// exceeds . + public async ValueTask WriteChunkAsync(ReadOnlyMemory payload, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (payload.Length > MaxPayloadSize) + throw new ArgumentOutOfRangeException(nameof(payload), payload.Length, + $"A Shadowsocks chunk carries at most {MaxPayloadSize} bytes."); + + // At most one chunk's worth, so the run is exactly one chunk — an empty one for an empty payload. + int length = SealRun(payload.Span, out _); + await _inner.WriteAsync(_sendBuffer!.AsMemory(0, length), cancellationToken); + } + + // Frames and seals a run into _sendBuffer: the salt first if still pending, then consecutive + // chunks of `payload` (each at most MaxPayloadSize) for as long as the next one fits. Returns + // the wire length and reports how much of `payload` went in. The buffer always holds the salt + // plus one full chunk, so a run is never empty: an empty payload seals one empty chunk. + private int SealRun(ReadOnlySpan payload, out int consumed) + { + byte[] buffer = EnsureSendBuffer(payload.Length); + + int offset = 0; + if (_pendingSaltLength > 0) + { + // In the same buffer as the first chunk, so salt and header leave in one write. + Span salt = _pendingSalt; + salt.Slice(0, _pendingSaltLength).CopyTo(buffer); + offset = _pendingSaltLength; + _pendingSaltLength = 0; + } + + consumed = 0; + do + { + int count = Math.Min(payload.Length - consumed, MaxPayloadSize); + if (offset + LengthBlockSize + count + TagSize > buffer.Length) + break; + + offset = SealChunk(payload.Slice(consumed, count), buffer, offset); + consumed += count; + } + while (consumed < payload.Length); + + return offset; + } + + // Seals one chunk of `plaintext` at `offset` and returns the offset after it. + private int SealChunk(ReadOnlySpan plaintext, byte[] buffer, int offset) + { + Span length = stackalloc byte[LengthSize]; + BinaryPrimitives.WriteUInt16BigEndian(length, (ushort)plaintext.Length); + _writer.Seal(length, buffer.AsSpan(offset, LengthSize), buffer.AsSpan(offset + LengthSize, TagSize)); + offset += LengthBlockSize; + + _writer.Seal(plaintext, buffer.AsSpan(offset, plaintext.Length), buffer.AsSpan(offset + plaintext.Length, TagSize)); + return offset + plaintext.Length + TagSize; + } + + // The small buffer serves every Write of at most one chunk; the first Write that spans more + // than one chunk trades it for the large one, and a tunnel that never writes that much never + // touches the larger bucket. + private byte[] EnsureSendBuffer(int payloadLength) + { + int size = payloadLength > MaxPayloadSize ? LargeSendBufferSize : SmallSendBufferSize; + byte[]? buffer = _sendBuffer; + if (buffer is null || buffer.Length < size) + { + if (buffer is not null) + ArrayPool.Shared.Return(buffer, clearArray: true); + buffer = _sendBuffer = ArrayPool.Shared.Rent(size); + } + + return buffer; + } + + // ================================ disposal ================================ + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + try + { + // Transport first: a read still in flight on another thread targets these buffers, + // and closing the transport is what faults it. + if (!_leaveInnerOpen) + await _inner.DisposeAsync(); + } + finally + { + ReleaseResources(); + } + + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (_disposed) + { + base.Dispose(disposing); + return; + } + + _disposed = true; + if (disposing) + { + try + { + if (!_leaveInnerOpen) + _inner.Dispose(); + } + finally + { + ReleaseResources(); + } + } + + base.Dispose(disposing); + } + + private void ReleaseResources() + { + _writer.Dispose(); + _reader?.Dispose(); + _reader = null; + + if (_masterKey is not null) + { + CryptographicOperations.ZeroMemory(_masterKey); + _masterKey = null; + } + + Span pendingSalt = _pendingSalt; + pendingSalt.Clear(); + _pendingSaltLength = 0; + + if (_sendBuffer is not null) + { + ArrayPool.Shared.Return(_sendBuffer, clearArray: true); + _sendBuffer = null; + } + + if (_receiveBuffer is not null) + { + ArrayPool.Shared.Return(_receiveBuffer, clearArray: true); + _receiveBuffer = null; + } + + if (_receivePlain is not null) + { + ArrayPool.Shared.Return(_receivePlain, clearArray: true); + _receivePlain = null; + } + + _start = 0; + _end = 0; + _pendingLength = -1; + _plainOffset = 0; + _plainCount = 0; + } + + // ================================ one direction ================================ + + /// Inline storage for a salt of up to bytes. + [InlineArray(ShadowsocksCipher.MaxKeySize)] + private struct SaltBuffer + { + private byte _element0; + } + + /// + /// One direction of the stream: the AEAD instance keyed with that direction's subkey plus + /// its 12-byte little-endian counting nonce. + /// + private sealed class Direction : IDisposable + { + private readonly byte[] _nonce = new byte[ShadowsocksCipher.NonceSize]; + private readonly AesGcm? _aes; + private readonly ChaCha20Poly1305? _chacha; + + public Direction(ShadowsocksMethod method, ReadOnlySpan subkey) + { + if (method == ShadowsocksMethod.ChaCha20Poly1305) + _chacha = new ChaCha20Poly1305(subkey); + else + _aes = new AesGcm(subkey, ShadowsocksCipher.TagSize); + } + + /// The low 64 bits of the nonce, i.e. the operations performed so far. + public ulong Counter => BinaryPrimitives.ReadUInt64LittleEndian(_nonce); + + public void Seal(ReadOnlySpan plaintext, Span ciphertext, Span tag) + { + if (_aes is not null) + _aes.Encrypt(_nonce, plaintext, ciphertext, tag); + else + _chacha!.Encrypt(_nonce, plaintext, ciphertext, tag); + Increment(); + } + + public void Open(ReadOnlySpan ciphertext, ReadOnlySpan tag, Span plaintext) + { + // Advance even when the open fails: the spec counts operations, not successes, and a + // failed tag ends the stream anyway. + try + { + if (_aes is not null) + _aes.Decrypt(_nonce, ciphertext, tag, plaintext); + else + _chacha!.Decrypt(_nonce, ciphertext, tag, plaintext); + } + finally + { + Increment(); + } + } + + // The nonce "is incremented by one as if it were an unsigned little-endian integer". + private void Increment() + { + for (int i = 0; i < _nonce.Length; i++) + { + if (++_nonce[i] != 0) + return; + } + } + + public void Dispose() + { + _aes?.Dispose(); + _chacha?.Dispose(); + CryptographicOperations.ZeroMemory(_nonce); + } + } +} diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index 5b71600..3ea8a4b 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -21,7 +21,7 @@ public static class Proxy { /// /// Connects to a target host through a proxy described by a URL or share link of any - /// supported scheme, including vless, trojan and vmess. + /// supported scheme, including vless, trojan, vmess and ss. /// /// /// The proxy URL or share link. See for the schemes this accepts. @@ -34,7 +34,7 @@ public static class Proxy /// The overloads below cover only the classic schemes, and deliberately so: /// they skip the client object entirely, which is what makes them suitable for checking /// proxies by the thousand. This one goes through instead, - /// because VLESS, Trojan and VMess need the parsed configuration to negotiate at all. When + /// because VLESS, Trojan, VMess and Shadowsocks need the parsed configuration to negotiate at all. When /// you have a link and no reason to care which family it belongs to, use this. /// public static async ValueTask ConnectAsync(string proxyLink, string host, int port, @@ -123,12 +123,15 @@ public static ValueTask ConnectAsync(Uri proxyUri, Stream source, string /// /// /// http, https, socks4, socks4a, socks5, vless, - /// trojan or vmess. Credentials in the authority are honoured for the classic - /// schemes; the rest carry their configuration in the link itself. + /// trojan, vmess or ss. Credentials in the authority are honoured for the + /// classic schemes; the rest carry their configuration in the link itself. /// /// A client ready to . /// is empty or has no scheme. - /// The scheme is not one this library speaks. + /// + /// The scheme is not one this library speaks, or the link names a Shadowsocks cipher or plugin + /// this library does not speak (the message names it). + /// /// The scheme is known but the link is malformed. /// /// @@ -167,6 +170,9 @@ public static IProxyClient Create(string proxyLink) if (scheme.Equals("vmess", StringComparison.OrdinalIgnoreCase)) return Tag(new VmessClient(VmessShareLink.Parse(trimmed)), trimmed); + if (scheme.Equals("ss", StringComparison.OrdinalIgnoreCase)) + return Tag(new ShadowsocksClient(ShadowsocksShareLink.Parse(trimmed)), trimmed); + // The classic ones are host/port URIs, so they go through Uri for its authority parsing. if (scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || @@ -183,7 +189,7 @@ public static IProxyClient Create(string proxyLink) throw new NotSupportedException( $"Proxy scheme '{scheme}' is not supported. This library speaks http, https, socks4, " + - "socks4a, socks5, vless, trojan and vmess."); + "socks4a, socks5, vless, trojan, vmess and ss (Shadowsocks)."); } /// @@ -238,20 +244,27 @@ public static bool TryCreate( /// /// The proxy URI, including scheme, host, port and optional credentials. /// A client configured for the proxy the URI describes. - /// The URI scheme is not one this library speaks. + /// + /// The URI scheme is not one this library speaks, or the link names a Shadowsocks cipher or + /// plugin this library does not speak (the message names it). + /// /// /// Note for vmess://: a VMess share link is base64-encoded JSON rather than a /// host/port URI, and rejects a payload longer than its host-length limit or /// containing base64 padding — which covers most real-world links. Such a link cannot be turned /// into a at all, so prefer . The special case - /// below exists for the short links that are representable. + /// below exists for the short links that are representable. The same applies to + /// legacy ss:// links, whose whole authority is one base64 blob that + /// refuses as a host name: use or + /// . /// public static IProxyClient Create(Uri proxyUri) { ArgumentNullException.ThrowIfNull(proxyUri); - // VLESS, Trojan and VMess carry their whole configuration (uuid, security, sni, ...) in - // the URI, so they are parsed as share links rather than as host/port/credential triples. + // VLESS, Trojan, VMess and Shadowsocks carry their whole configuration (uuid, security, + // cipher, ...) in the URI, so they are parsed as share links rather than as + // host/port/credential triples. if (proxyUri.Scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) return Tag(new VlessClient(VlessShareLink.Parse(proxyUri.OriginalString)), proxyUri.OriginalString); @@ -261,6 +274,9 @@ public static IProxyClient Create(Uri proxyUri) if (proxyUri.Scheme.Equals("vmess", StringComparison.OrdinalIgnoreCase)) return Tag(new VmessClient(VmessShareLink.Parse(proxyUri.OriginalString)), proxyUri.OriginalString); + if (proxyUri.Scheme.Equals("ss", StringComparison.OrdinalIgnoreCase)) + return Tag(new ShadowsocksClient(ShadowsocksShareLink.Parse(proxyUri.OriginalString)), proxyUri.OriginalString); + ProxyType type = proxyUri.Scheme switch { "http" => ProxyType.Http, @@ -270,7 +286,7 @@ public static IProxyClient Create(Uri proxyUri) "socks5" => ProxyType.Socks5, _ => throw new NotSupportedException( $"Proxy scheme '{proxyUri.Scheme}' is not supported. This library speaks http, https, socks4, " + - "socks4a, socks5, vless, trojan and vmess.") + "socks4a, socks5, vless, trojan, vmess and ss (Shadowsocks).") }; return Tag(Create(type, proxyUri.Host, proxyUri.Port, ParseCredentials(proxyUri)), proxyUri.OriginalString); @@ -317,7 +333,7 @@ private static IProxyClient CreateClassic(ProxyType type, string host, int port) }; private const string ShareLinkFamilyMessage = - "VLESS, Trojan and VMess carry a configuration that host and port cannot express; " + + "VLESS, Trojan, VMess and Shadowsocks carry a configuration that host and port cannot express; " + "build them from a share link instead."; // Records the text a client was built from, so a caller holding only IProxyClient can report diff --git a/QuickProxyNet/ProxyType.cs b/QuickProxyNet/ProxyType.cs index 41a9e37..72e8424 100644 --- a/QuickProxyNet/ProxyType.cs +++ b/QuickProxyNet/ProxyType.cs @@ -9,5 +9,6 @@ public enum ProxyType Socks5, Vless, Vmess, - Trojan + Trojan, + Shadowsocks } diff --git a/README.md b/README.md index ccb4185..6291104 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ - **Zero runtime dependencies** — BCL only, no third-party packages - **Zero-alloc protocol logic** — `ArrayPool`, `stackalloc`, `Utf8Formatter`, `ValueTask` throughout - **5 classic proxy protocols** — HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 -- **3 VPN-style protocols** — VLESS, VMess (VMessAEAD), Trojan, over `tcp`, `ws` or `httpupgrade` +- **4 VPN-style protocols** — VLESS, VMess (VMessAEAD), Trojan, over `tcp`, `ws` or `httpupgrade`; Shadowsocks AEAD over `tcp` - **VLESS REALITY in-process** — no external binary: a managed TLS 1.3 client (ClientHello, X25519, key schedule, record layer) lives in the core package, and `VlessClient` uses it automatically when `security=reality` - **XTLS `xtls-rprx-vision`** — the flow used by ~95% of real-world REALITY nodes -- **Share-link parsing** — pass a `vless://`, `vmess://`, `trojan://`, `socks5://`, `http://`, … string directly; no `Uri` gymnastics +- **Share-link parsing** — pass a `vless://`, `vmess://`, `trojan://`, `ss://`, `socks5://`, `http://`, … string directly; no `Uri` gymnastics - **Static one-liner API** — `Proxy.ConnectAsync(link, host, port)` for mass checkers - **Structured error codes** — `ProxyProtocolException` with `ProxyErrorCode` enum for programmatic error handling - **Timeout support** — per-connection timeouts with `ProxyErrorCode.Timeout` @@ -36,7 +36,7 @@ dotnet add package QuickProxyNet `Proxy.ConnectAsync` and `Proxy.Create` accept the link as a **string** and dispatch on the scheme themselves. This matters for `vmess://` links: they are base64-encoded JSON, and `System.Uri` rejects most real-world ones (host length limit, base64 padding). You no longer have to inspect the scheme yourself to pick a parser. ```csharp -// Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess links +// Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess/ss links await using var stream = await Proxy.ConnectAsync( "socks5://user:pass@127.0.0.1:1080", "example.com", 443, @@ -98,8 +98,21 @@ Also not implemented: Vision's TLS-in-TLS splice. It is a throughput optimizatio | VLESS | Supported | `security=none`, `tls`, `reality`; flow `xtls-rprx-vision` | | Trojan | Supported | over TLS | | VMess | Supported | VMessAEAD, `alterId=0`, optional TLS | +| Shadowsocks | Supported | AEAD (SIP004/SIP007) over `tcp`: `aes-128-gcm`, `aes-192-gcm`, `aes-256-gcm`, `chacha20-ietf-poly1305` | | Hysteria2 / TUIC | **Not supported** | QUIC-based; the library has no datagram model | -| Shadowsocks | **Not supported** | — | + +### Shadowsocks + +`ss://` links in both grammars are accepted — the legacy `ss://base64(method:password@host:port)#tag` +and SIP002 `ss://userinfo@host:port/?plugin=…#tag` with base64 or plain `method:password` userinfo. +The four AEAD ciphers above are spoken over raw TCP. Everything else is refused **by name** with a +`NotSupportedException` before a byte is written, never silently downgraded: the AEAD-2022 +`2022-blake3-*` family (SIP022, needs BLAKE3), every legacy stream cipher (`rc4-md5`, `aes-*-cfb`, +`chacha20-ietf`, …), `none`/`plain`, `xchacha20-ietf-poly1305` (no XChaCha20 in the .NET BCL), +and any `plugin=` (SIP003 plugins are separate processes). No UDP, no `ws`/TLS transport for +Shadowsocks, no SIP008 JSON subscriptions. `chacha20-ietf-poly1305` needs an OS with +ChaCha20-Poly1305 — Windows 11 / Server 2022, not Windows 10. A wrong password is not reported as +one: the server sends nothing, so it looks exactly like a dead target (see `ShadowsocksClient`). ### Transports @@ -216,6 +229,7 @@ messages never contain the credential: a malformed user id is reported by length | `Vless` | VLESS (incl. REALITY, `xtls-rprx-vision`) | UUID | Yes | | `Vmess` | VMess (VMessAEAD) | UUID | Yes | | `Trojan` | Trojan | Password | Yes | +| `Shadowsocks` | Shadowsocks AEAD | Password + cipher | Yes | ## Configuration Options diff --git a/docs/shadowsocks.md b/docs/shadowsocks.md new file mode 100644 index 0000000..de39149 --- /dev/null +++ b/docs/shadowsocks.md @@ -0,0 +1,211 @@ +# Shadowsocks (AEAD) + +Shadowsocks AEAD — SIP004, дополненный SIP007 — самый простой из VPN-протоколов +в библиотеке по числу байтов на проводе и самый коварный по деталям. Нет TLS, +нет заголовка ответа, нет терминатора: соль, потом цепочка шифрованных чанков. +Все ловушки — в семантике длины, в счётчике nonce и в том, когда сервер +присылает свою соль. + +Спека: https://shadowsocks.org/doc/aead.html, URI — SIP002. + +## Стек + +```text +TCP connect -> salt || chunk(atyp addr port) -> AEAD chunk stream +``` + +Никакого TLS и никакого транспорта поверх: в этой версии только `tcp`. +Плагины SIP003 (`obfs-local`, `v2ray-plugin`, …) — отдельные процессы, +библиотека их не запускает и отвергает `plugin=` по имени. + +## Шифры + +| Имя | Ключ | Соль | Nonce | Тег | Где | +| --- | ---: | ---: | ---: | ---: | --- | +| `aes-128-gcm` | 16 | 16 | 12 | 16 | везде | +| `aes-192-gcm` | 24 | 24 | 12 | 16 | sing-box, libev; **не Xray** | +| `aes-256-gcm` | 32 | 32 | 12 | 16 | везде | +| `chacha20-ietf-poly1305` | 32 | 32 | 12 | 16 | везде; в .NET — только Windows 11+/Server 2022 | + +Правило: **длина соли равна длине ключа**. Таблица в спеке об этом молчит, +потому что не содержит `aes-192-gcm`; ответ дают исходники shadowsocks-crypto +(`salt_len() = key_len()`) и libev. + +Отвергаются по имени, с сообщением, где написано что нашли и что принимаем: + +- `2022-blake3-*` (SIP022) — другой протокол под той же схемой: BLAKE3, PSK + вместо пароля, заголовок с временной меткой. В BCL нет BLAKE3. +- потоковые шифры (`table`, `rc4`, `rc4-md5`, `chacha20`, `chacha20-ietf`, + `salsa20`, `bf-cfb`, `aes-*-ctr/cfb/cfb1/cfb8/cfb128/ofb`, `camellia-*`) — + спека объявляет их сломанными. +- `none`, `plain` — адрес цели и трафик в открытом виде. +- `xchacha20-ietf-poly1305` — развёрнут (Xray, sing-box, libev), но в BCL нет + XChaCha20. Отказ из-за .NET, а не из-за протокола. +- `aes-*-ccm`, `*-gcm-siv`, `sm4-*` — AEAD вне таблицы SIP004. + +## Ключи + +```text +master_key = EVP_BytesToKey(MD5, no salt, count 1)(utf8(password))[:key_len] + D_1 = MD5(password); D_i = MD5(D_{i-1} || password) +subkey = HKDF-SHA1(ikm = master_key, salt = salt, info = "ss-subkey", L = key_len) +``` + +Подключ — свой на каждое направление, от своей соли: соль клиента шифрует +клиент→сервер, соль сервера — сервер→клиент. Мастер-ключ общий. + +`info` — ровно девять байт ASCII `ss-subkey`, без кавычек и без NUL. + +## Nonce + +12 байт, little-endian счётчик от нуля, `+1` после **каждой** AEAD-операции. +Чанк — две операции (длина и данные), поэтому чанк сдвигает счётчик на два. +Направления считают независимо. Associated data пустые. + +```text +chunk 0: length 00 00 00 00 00 00 00 00 00 00 00 00 + payload 01 00 ... +chunk 1: length 02 00 ... + payload 03 00 ... +``` + +Ошибка в порядке байтов и ошибка в шаге — две разные ошибки, и вторая видна +только на втором чанке. Вектор `max_chunk_plus_one_16384` в +`ShadowsocksCryptoTest` существует ради неё. + +## Чанк + +```text ++---------------------+------------+-------------------+-------------+ +| encrypted length(2) | length tag | encrypted payload | payload tag | ++---------------------+------------+-------------------+-------------+ +| 2 | 16 | 0..0x3FFF | 16 | ++---------------------+------------+-------------------+-------------+ +``` + +Два байта длины — длина **открытого текста**, big-endian, и сами они — +открытый текст своей собственной AEAD-операции. У VMess ровно наоборот: +префикс несёт размер **запечатанного** блока и идёт открытым. Рамки похожи +внешне и противоположны по смыслу; общего кода у `ShadowsocksStream` и +`VmessStream` нет намеренно. + +Максимум `0x3FFF` = 16383. Расшифрованная длина больше — **отказ**, не маска. +go-shadowsocks2 и libev делают `& 0x3FFF` и молча превращают `0x8000` в ноль, +после чего поток расходится. shadowsocks-rust отказывает. Мы отказываем и +пишем в сообщении, во что бы превратилась длина после маски. + +Размер чанка на проводе: `n + 34`. Максимальный: 16417. + +## Первый пакет клиента + +```text +salt(key_len) || chunk( atyp || addr || port [|| первые данные] ) + +01 <4 байта IPv4> +03 +04 <16 байт IPv6> +``` + +Тот же `ProxyAddress.WriteTypeAndAddress(0x01, 0x03, 0x04)`, что у Trojan и +SOCKS5: порт **после** адреса. У VLESS/VMess коды и порядок другие. + +`ConnectAsync` пишет `salt || chunk(header)` сразу и возвращается. Заголовок +уходит отдельным коротким чанком — shadowsocks-rust склеивает его с первыми +данными ради маскировки, но для этого пришлось бы откладывать запись до +первого `Write`, а это вешает любой протокол, где первым говорит сервер. + +## Ответ сервера + +```text +salt(key_len) || chunk || chunk || ... +``` + +Ни заголовка, ни байта-подтверждения. Соль — первые `key_len` байт. + +**Сервер не присылает соль, пока цель не ответила.** Xray пишет её в +буферизованный writer и блокируется на первом чтении из цели; shadowsocks-rust +и go-shadowsocks2 пишут её из первого `Write`. Читать соль в `ConnectAsync` +значит повесить HTTP, TLS и Minecraft — та же ловушка, что заголовки ответа +VLESS и VMess (AGENTS.md, пункт 2). Поэтому соль читается **лениво, на первом +`Read`**, состоянием внутри `ShadowsocksStream`; отдельной прослойки вроде +`VmessResponseStream` не нужно — декодировать нечего. + +Векторы этого не проверят: у `DuplexTestStream` ответ лежит в буфере заранее. +Проверяют только docker-тесты против живых Xray и sing-box. + +## Конец потока + +Терминатора в протоколе нет. Правила: + +- FIN ровно на границе чанка — чистый конец, `Read` возвращает 0. +- FIN внутри соли, внутри блока длины, внутри данных — `ProxyProtocolException`, + никогда не 0. Иначе обрыв неотличим от нормального закрытия. +- Не сошёлся тег — `ProxyProtocolException(InvalidResponse)`; nonce уже + сдвинут, дальше открыть нечего. +- Пустой чанк (длина 0) — легален, открывается, сдвигает nonce и **не** является + концом потока. + +## Неверный пароль + +Сервер, который не смог открыть первый чанк, не отвечает ничего. Xray сверх +того «допивает» псевдослучайное число байт (`BehaviorSeedLimitedDrainer`), +чтобы отказ нельзя было отличить и по времени. С нашей стороны это +`ProxyProtocolException(ConnectionFailed)` на первом `Read` или таймаут — ровно +то же, что мёртвая цель. `ConnectAsync` успешен в обоих случаях; различить +их по проводу нельзя, и `ShadowsocksClient` об этом говорит в remarks, как +`TrojanClient` — о своей приманке. + +## Соль + +Свежая случайная на каждое соединение, из `RandomNumberGenerator`; нулевая +перебрасывается (так делает shadowsocks-crypto). Серверы держат фильтр +повторов — повторная соль означает сброшенное соединение. + +## URI + +Две грамматики, обе в ходу: + +```text +ss://base64(method:password@host:port)#tag (legacy) +ss://userinfo@host:port[/][?plugin=...][#tag] (SIP002) +userinfo = base64url(method:password) | method:password (percent-encoded) +``` + +Различение — как у shadowsocks-rust и v2rayN: сначала percent-decode, потом +если в результате есть `:` — это открытый `method:password`, иначе base64. + +Что встречается и учтено: + +- base64 без паддинга; оба алфавита, иногда вперемешку; +- паддинг в виде `%3D` — decode **до** base64, порядок важен; +- хвостовой `/`, обязательный перед `?plugin`; +- `#tag` percent-encoded; +- порт по умолчанию 8388; +- IPv6 в скобках; +- `&` как разделитель — `ShareLinkQuery.StripHtmlAmpPrefix`, иначе + `amp;plugin` отбросится как неизвестный ключ и клиент пойдёт голым + Shadowsocks на сервер с obfs. + +Парсер принимает любое имя шифра и любой плагин; отказывает по имени +конструктор `ShadowsocksClient`, до записи первого байта. + +SIP008 (JSON-подписки) — не URI, не поддерживается. + +## Проверка + +- `ShadowsocksCryptoTest` — векторы независимого генератора (Python, из текста + спеки): мастер-ключ, подключ, полный hex коротких потоков, SHA-256 длинных, + четыре первых пакета. Официальных сквозных векторов у протокола нет; генератор + проверяет себя по RFC 5869, RFC 8439, NIST GCM и бинарнику OpenSSL. +- `ShadowsocksStreamTest` — 100 КБ туда и обратно кусками случайной длины, + на записи и на чтении. +- `ShadowsocksHostilePeerTest` — длина `0x8000`, обрывы, испорченные теги, + молчащий сервер. +- `DockerProtocolTests` — Xray и sing-box, все четыре шифра, публичный путь + через `Proxy.Create("ss://…")`, неверный пароль. + +## Источники + +- Спека: https://shadowsocks.org/doc/aead.html, https://shadowsocks.org/doc/sip002.html +- shadowsocks-rust (MIT), shadowsocks-crypto (MIT), go-shadowsocks2 (Apache-2.0) — код сверялся +- shadowsocks-libev, sing-box (GPL-3.0), Xray-core (MPL-2.0) — только факты о поведении diff --git a/tests/docker/README.md b/tests/docker/README.md index f886bd4..33640c1 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -4,8 +4,8 @@ Real Xray-core and sing-box servers for `QuickProxyNet.Tests/Integration/DockerP Byte-exact vectors prove our crypto matches an independent implementation. They cannot prove a server *accepts* the handshake — framing, field order, the VMess option byte, the address-type -codes and the non-UUID id derivation all have to be right simultaneously for that. This stack is -the only thing in the repo that proves it. +codes, the non-UUID id derivation and the Shadowsocks lazy salt read all have to be right +simultaneously for that. This stack is the only thing in the repo that proves it. Two implementations are here on purpose: they disagree about what they tolerate, so one alone would silently bless a bug the other rejects. @@ -49,7 +49,10 @@ All three are expected to be present locally; nothing here builds an image. ## Host port map -Container ports are `10001..10010`; the host ports differ per server so both can run at once. +Container ports are `10001..10010` for the first batch and `10011..10014` for Shadowsocks; the +host ports differ per server so both can run at once. The Shadowsocks ports break the +`+24800`/`+24810` pattern on purpose — past `10010` it cannot hold for both servers — and take a +fresh decade each: xray `2482x`, sing-box `2483x`. | Host port | Server | Inbound | Credential | | --- | --- | --- | --- | @@ -72,9 +75,16 @@ Container ports are `10001..10010`; the host ports differ per server so both can | 24818 | sing-box | vmess over `ws`, path `/qpn-vmess-ws` | `66666666-6666-4666-8666-666666666666` | | 24819 | sing-box | trojan over `ws` + TLS, path `/qpn-trojan-ws` | `qpn-test-trojan-password` | | 24820 | sing-box | vless over `httpupgrade`, path `/qpn-hu` | `77777777-7777-4777-8777-777777777777` | - -Every credential above is synthetic test data committed on purpose — repdigit UUIDs and a literal -password. None of it is, or ever was, a real credential. The C# side mirrors this table in +| 24821 | xray | shadowsocks `aes-256-gcm` | `qpn-test-ss-password` | +| 24822 | xray | shadowsocks `chacha20-ietf-poly1305` | `qpn-test-ss-password` | +| 24823 | xray | shadowsocks `aes-128-gcm` | `qpn-test-ss-password` | +| 24831 | sing-box | shadowsocks `aes-256-gcm` | `qpn-test-ss-password` | +| 24832 | sing-box | shadowsocks `chacha20-ietf-poly1305` | `qpn-test-ss-password` | +| 24833 | sing-box | shadowsocks `aes-128-gcm` | `qpn-test-ss-password` | +| 24834 | sing-box | shadowsocks `aes-192-gcm` — **sing-box only** | `qpn-test-ss-password` | + +Every credential above is synthetic test data committed on purpose — repdigit UUIDs and literal +passwords. None of it is, or ever was, a real credential. The C# side mirrors this table in `QuickProxyNet.Tests/Integration/DockerEndpoints.cs`; keep the two in sync. ### The two VMess ports @@ -92,6 +102,26 @@ VLESS id is compared byte for byte on the server, so `Vless_NonUuidId_DerivesSam round-tripping means our derivation matches Xray's exactly — a unit vector could only ever pin that against ourselves. sing-box has no equivalent, so this inbound is Xray-only. +### The Shadowsocks ports + +Unlike VMess, a Shadowsocks inbound is bound to one cipher, so each cipher needs its own port. +`aes-192-gcm` is not in the SIP004 table: sing-box and shadowsocks-libev speak it, Xray's +`cipherFromString` has no case for it, so port 24834 has no Xray twin and +`Shadowsocks_Aes192Gcm_RoundTrip_SingBoxOnly` is a single-server test by necessity. Its 24-byte +salt is also the one size no permissive implementation could have vectored for us. + +Xray prints a deprecation warning for the `shadowsocks` inbound at startup. It is harmless and +not a failure. + +A Shadowsocks server sends its salt only after the target has replied, and sends **nothing** to a +client whose first chunk it cannot open — Xray additionally drains a pseudo-random number of bytes +before closing (`NewBehaviorSeedLimitedDrainer(seed, 16+38, 3266, 64)` in +`proxy/shadowsocks/protocol.go`: under 3400 bytes in total, 54 + 3266 + 64 at the very most). +`Shadowsocks_WrongPassword_ConnectSucceeds_ReadFails` writes an HTTP request plus 4096 filler +bytes so the drainer always runs out, and then expects the failure on the first read as a closed +connection (a FIN, or a RST because the surplus was left unread) — never a timeout, never at +connect time. A client whose first read simply hangs fails this test. + ## The echo target `alpine:3.20` running `nc -lk -p 8080 -e /bin/sh /echo/serve.sh`. Alpine's busybox has no `httpd` diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml index e6b61e1..b8e9b00 100644 --- a/tests/docker/docker-compose.yml +++ b/tests/docker/docker-compose.yml @@ -1,4 +1,4 @@ -# Integration-test servers for QuickProxyNet's VLESS / Trojan / VMess clients. +# Integration-test servers for QuickProxyNet's VLESS / Trojan / VMess / Shadowsocks clients. # # ALWAYS drive this file with the fixed project name so nothing is orphaned: # @@ -46,6 +46,9 @@ services: - "24808:10008" # vmess over ws, path /qpn-vmess-ws - "24809:10009" # trojan over ws + tls, path /qpn-trojan-ws - "24810:10010" # vless over httpupgrade, path /qpn-hu + - "24821:10011" # shadowsocks, aes-256-gcm + - "24822:10012" # shadowsocks, chacha20-ietf-poly1305 + - "24823:10013" # shadowsocks, aes-128-gcm depends_on: echo: condition: service_healthy @@ -69,6 +72,10 @@ services: - "24818:10008" # vmess over ws, path /qpn-vmess-ws - "24819:10009" # trojan over ws + tls, path /qpn-trojan-ws - "24820:10010" # vless over httpupgrade, path /qpn-hu + - "24831:10011" # shadowsocks, aes-256-gcm + - "24832:10012" # shadowsocks, chacha20-ietf-poly1305 + - "24833:10013" # shadowsocks, aes-128-gcm + - "24834:10014" # shadowsocks, aes-192-gcm (sing-box only: Xray has no such cipher) depends_on: echo: condition: service_healthy diff --git a/tests/docker/singbox/config.json b/tests/docker/singbox/config.json index e45183d..fe515ab 100644 --- a/tests/docker/singbox/config.json +++ b/tests/docker/singbox/config.json @@ -148,6 +148,42 @@ "type": "httpupgrade", "path": "/qpn-hu" } + }, + { + "type": "shadowsocks", + "tag": "ss-aes256", + "listen": "0.0.0.0", + "listen_port": 10011, + "method": "aes-256-gcm", + "password": "qpn-test-ss-password", + "network": "tcp" + }, + { + "type": "shadowsocks", + "tag": "ss-chacha", + "listen": "0.0.0.0", + "listen_port": 10012, + "method": "chacha20-ietf-poly1305", + "password": "qpn-test-ss-password", + "network": "tcp" + }, + { + "type": "shadowsocks", + "tag": "ss-aes128", + "listen": "0.0.0.0", + "listen_port": 10013, + "method": "aes-128-gcm", + "password": "qpn-test-ss-password", + "network": "tcp" + }, + { + "type": "shadowsocks", + "tag": "ss-aes192", + "listen": "0.0.0.0", + "listen_port": 10014, + "method": "aes-192-gcm", + "password": "qpn-test-ss-password", + "network": "tcp" } ], "outbounds": [ diff --git a/tests/docker/xray/config.json b/tests/docker/xray/config.json index 2c9de3a..fd2cf7c 100644 --- a/tests/docker/xray/config.json +++ b/tests/docker/xray/config.json @@ -219,6 +219,51 @@ "network": "tcp", "security": "none" } + }, + { + "tag": "ss-aes256", + "listen": "0.0.0.0", + "port": 10011, + "protocol": "shadowsocks", + "settings": { + "method": "aes-256-gcm", + "password": "qpn-test-ss-password", + "network": "tcp" + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + }, + { + "tag": "ss-chacha", + "listen": "0.0.0.0", + "port": 10012, + "protocol": "shadowsocks", + "settings": { + "method": "chacha20-ietf-poly1305", + "password": "qpn-test-ss-password", + "network": "tcp" + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + }, + { + "tag": "ss-aes128", + "listen": "0.0.0.0", + "port": 10013, + "protocol": "shadowsocks", + "settings": { + "method": "aes-128-gcm", + "password": "qpn-test-ss-password", + "network": "tcp" + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } } ], "outbounds": [ From 4b23f48d0fdca81ec6b40b4867a7c661da560d80 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Wed, 9 Sep 2026 21:34:09 +0500 Subject: [PATCH 03/35] fix(shadowsocks): stop reading a bare password as a cipher name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SIP002 userinfo made of base64 characters was decoded even when it was just the password: the bytes then hold a ':' by chance and everything before it became the method. 1057 of 8069 real ss:// links from a public list parsed that way. The client refused them at connect time, so the bytes were never wrong — but TryParse said yes, and a caller filtering a subscription kept them. The base64 branch now requires the decoded method to be short ASCII: letters, digits, '-', '_', '.', '+'. Text the producer wrote itself is untouched, so an unknown but well-formed name still parses and the client names it when it refuses. Parse rate over that list drops from 99.0% to the honest 86.9%. CorpusCheck learns ss://: its own row in the parse report, its own pool in --live, and a redactor that prints nothing at all for the legacy grammar, where method, password, host and port share one base64 blob. Co-Authored-By: Claude Opus 5 (1M context) --- .../ShadowsocksShareLinkTest.cs | 28 +++++++++ QuickProxyNet/Configs/ShadowsocksShareLink.cs | 27 +++++++++ tools/CorpusCheck/LiveProbe.cs | 59 ++++++++++++++++++- tools/CorpusCheck/Program.cs | 47 ++++++++++++++- 4 files changed, 155 insertions(+), 6 deletions(-) diff --git a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs index a0c3f1b..205e768 100644 --- a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs +++ b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs @@ -507,4 +507,32 @@ public async Task Client_ConnectAsync_HostNameTooLong_FailsBeforeWriting() Assert.Equal(ProxyErrorCode.StringTooLong, ex.ErrorCode); Assert.Empty(transport.WrittenBytes); } + + [Theory] + [InlineData("2bc31b80+786m+k9EXdvb28", "base64 whose bytes are binary noise holding a colon")] + [InlineData("5fa74a60-cc6c2VjcmV0nu8", "the same, url-safe alphabet")] + public void TryParse_RejectsBase64UserInfoThatDecodesToBinary(string userInfo, string _) + { + Assert.False(ShadowsocksShareLink.TryParse($"ss://{userInfo}@example.com:8388", out ShadowsocksOptions? rejected)); + Assert.Null(rejected); + + var ex = Assert.Throws( + () => ShadowsocksShareLink.Parse($"ss://{userInfo}@example.com:8388")); + Assert.Contains("not base64 of 'method:password'", ex.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("aes-256-gcm")] + [InlineData("2022-blake3-aes-256-gcm")] + [InlineData("AEAD_CHACHA20_POLY1305")] + [InlineData("some-cipher-we-never-heard-of")] + public void TryParse_KeepsAcceptingWellFormedNamesFromBase64UserInfo(string method) + { + string userInfo = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{method}:secret")) + .TrimEnd('='); + + Assert.True(ShadowsocksShareLink.TryParse($"ss://{userInfo}@example.com:8388", out var options)); + Assert.Equal(method, options.Method); + Assert.Equal("secret", options.Password); + } } diff --git a/QuickProxyNet/Configs/ShadowsocksShareLink.cs b/QuickProxyNet/Configs/ShadowsocksShareLink.cs index a5e7a14..9db953c 100644 --- a/QuickProxyNet/Configs/ShadowsocksShareLink.cs +++ b/QuickProxyNet/Configs/ShadowsocksShareLink.cs @@ -293,6 +293,13 @@ private static bool TrySplitUserInfo( return false; } + if (!LooksLikeCipherName(plain.Slice(0, colon))) + { + error = "Shadowsocks share link userinfo is not base64 of 'method:password': " + + "the decoded bytes do not start with a cipher name."; + return false; + } + method = plain.Slice(0, colon).ToString(); password = plain.Slice(colon + 1).ToString(); error = null; @@ -300,6 +307,26 @@ private static bool TrySplitUserInfo( } } + // Guards the base64 branch only. Userinfo that is a bare password made of base64 characters + // decodes to bytes that can hold a ':' by chance, and everything before it would then be read + // as a cipher name. A real name is short ASCII: letters, digits, '-', '_', '.' and '+' + // (aes-256-gcm, chacha20-ietf-poly1305, 2022-blake3-aes-256-gcm, AEAD_AES_256_GCM). Text the + // producer wrote itself is left alone: an unknown but well-formed name still parses, so a + // caller can inspect it, and the client names it when it refuses to connect. + private static bool LooksLikeCipherName(ReadOnlySpan value) + { + if (value.IsEmpty || value.Length > 40) + return false; + + foreach (char c in value) + { + if (!char.IsAsciiLetterOrDigit(c) && c is not ('-' or '_' or '.' or '+')) + return false; + } + + return true; + } + // host[:port], with a bracketed IPv6 literal allowed. Port defaults to 8388. private static bool TryParseHostPort( ReadOnlySpan hostPort, diff --git a/tools/CorpusCheck/LiveProbe.cs b/tools/CorpusCheck/LiveProbe.cs index a2e6e6f..faceed2 100644 --- a/tools/CorpusCheck/LiveProbe.cs +++ b/tools/CorpusCheck/LiveProbe.cs @@ -51,7 +51,7 @@ internal sealed record LiveNode( internal static class LiveSampler { /// Protocols sampled, in round-robin order. - private static readonly string[] Order = ["vless", "trojan", "vmess"]; + private static readonly string[] Order = ["vless", "trojan", "vmess", "ss"]; public static LiveSample Build(string corpusPath, int count, int seed) { @@ -59,7 +59,8 @@ public static LiveSample Build(string corpusPath, int count, int seed) { ["vless"] = new(), ["trojan"] = new(), - ["vmess"] = new() + ["vmess"] = new(), + ["ss"] = new() }; // One server, one probe: several share links often point at the same endpoint. @@ -77,6 +78,8 @@ public static LiveSample Build(string corpusPath, int count, int seed) ConsiderTrojan(pools["trojan"], line, seenEndpoints); else if (line.StartsWith("vmess://", StringComparison.OrdinalIgnoreCase)) ConsiderVmess(pools["vmess"], line, seenEndpoints); + else if (line.StartsWith("ss://", StringComparison.OrdinalIgnoreCase)) + ConsiderShadowsocks(pools["ss"], line, seenEndpoints); } var rng = new Random(seed); @@ -155,6 +158,54 @@ private static void ConsiderVless(Pool pool, string line, HashSet seen) () => new VlessClient(options))); } + private static void ConsiderShadowsocks(Pool pool, string line, HashSet seen) + { + pool.Seen++; + + if (IsHtmlEscaped(pool, line)) + return; + + if (!ShadowsocksShareLink.TryParse(line, out var options)) + { + pool.Exclude("does not parse"); + return; + } + + if (options.Plugin is { Length: > 0 } plugin) + { + pool.Exclude($"plugin={plugin.Split(';', 2)[0].ToLowerInvariant()} (not implemented)"); + return; + } + + ShadowsocksClient client; + try + { + client = new ShadowsocksClient(options); + } + catch (NotSupportedException ex) + { + pool.Exclude(Redactor.NormalizeReason(ex.Message)); + return; + } + catch (ArgumentException) + { + pool.Exclude("unusable method/password"); + return; + } + + if (!IsDialable(pool, options.Host, options.Port, seen)) + return; + + pool.Eligible.Add(new LiveNode( + "ss", + $"method={options.Method.ToLowerInvariant()}", + Redactor.RedactSsLink(line), + options.Host, + options.Port, + [options.Host, options.Password, options.Remark], + () => client)); + } + private static void ConsiderTrojan(Pool pool, string line, HashSet seen) { pool.Seen++; @@ -488,8 +539,9 @@ internal sealed class LiveRun private readonly ProtocolStats _vless = new("vless", "successful nodes by mode", null); private readonly ProtocolStats _trojan = new("trojan", "successful nodes by mode", null); private readonly ProtocolStats _vmess = new("vmess", "successful nodes by mode", null); + private readonly ProtocolStats _shadowsocks = new("ss", "successful nodes by mode", null); - private ProtocolStats[] All => [_vless, _trojan, _vmess]; + private ProtocolStats[] All => [_vless, _trojan, _vmess, _shadowsocks]; public void Record(LiveNode node, LiveResult result) { @@ -497,6 +549,7 @@ public void Record(LiveNode node, LiveResult result) { "vless" => _vless, "trojan" => _trojan, + "ss" => _shadowsocks, _ => _vmess }; diff --git a/tools/CorpusCheck/Program.cs b/tools/CorpusCheck/Program.cs index 5aff1e8..30ef4c4 100644 --- a/tools/CorpusCheck/Program.cs +++ b/tools/CorpusCheck/Program.cs @@ -267,6 +267,7 @@ internal sealed class CorpusRun private readonly ProtocolStats _vless = new("vless", breakdownNote: ParseNote); private readonly ProtocolStats _trojan = new("trojan", breakdownNote: ParseNote); private readonly ProtocolStats _vmess = new("vmess", breakdownNote: ParseNote); + private readonly ProtocolStats _shadowsocks = new("ss", breakdownNote: ParseNote); /// Schemes this library does not implement — reported, but not failures. private readonly SortedDictionary _otherSchemes = new(StringComparer.OrdinalIgnoreCase); @@ -309,6 +310,13 @@ public void Process(string line) else _vmess.RecordFailure(CaptureReason(() => VmessShareLink.Parse(line)), line, Redactor.RedactVmessLink); } + else if (line.StartsWith("ss://", StringComparison.OrdinalIgnoreCase)) + { + if (ShadowsocksShareLink.TryParse(line, out var options)) + _shadowsocks.RecordOk(ShadowsocksMode(options)); + else + _shadowsocks.RecordFailure(CaptureReason(() => ShadowsocksShareLink.Parse(line)), line, Redactor.RedactSsLink); + } else { int schemeEnd = line.IndexOf("://", StringComparison.Ordinal); @@ -324,6 +332,15 @@ public void Process(string line) } } + private static string ShadowsocksMode(ShadowsocksOptions options) + { + string method = options.Method.ToLowerInvariant(); + string plugin = options.Plugin is { Length: > 0 } value + ? value.Split(';', 2)[0].ToLowerInvariant() + : "none"; + return $"method={method}, plugin={plugin}"; + } + private static string VlessMode(VlessOptions options) { string security = options.Security switch @@ -368,7 +385,7 @@ public string BuildReport(string corpusPath) sb.AppendLine(); sb.AppendLine("| Protocol | Total | Parsed OK | Failed | OK % |"); sb.AppendLine("| --- | ---: | ---: | ---: | ---: |"); - foreach (var stats in new[] { _vless, _trojan, _vmess }) + foreach (var stats in new[] { _vless, _trojan, _vmess, _shadowsocks }) { sb.AppendLine( $"| {stats.Name} | {stats.Total} | {stats.Ok} | {stats.Failed} | " + @@ -387,10 +404,10 @@ public string BuildReport(string corpusPath) sb.AppendLine(); } - foreach (var stats in new[] { _vless, _trojan, _vmess }) + foreach (var stats in new[] { _vless, _trojan, _vmess, _shadowsocks }) stats.AppendBreakdown(sb); - foreach (var stats in new[] { _vless, _trojan, _vmess }) + foreach (var stats in new[] { _vless, _trojan, _vmess, _shadowsocks }) stats.AppendFailures(sb); return sb.ToString(); @@ -611,6 +628,30 @@ public static string RedactUriLink(string link) return sb.ToString(); } + /// + /// Redacts an ss:// link. The legacy grammar hides method, password, host and port + /// inside one base64 blob, so nothing in it may be echoed; the SIP002 grammar redacts like + /// any other userinfo link. + /// + public static string RedactSsLink(string link) + { + int schemeEnd = link.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd < 0) + return ""; + + string rest = link[(schemeEnd + 3)..]; + int hash = rest.IndexOf('#'); + if (hash >= 0) + rest = rest[..hash]; + + int q = rest.IndexOf('?'); + string authority = q >= 0 ? rest[..q] : rest; + + return authority.Contains('@', StringComparison.Ordinal) + ? RedactUriLink(link) + : "ss://"; + } + private static string RedactQuery(string query) { var sb = new StringBuilder(); From d26832f14cbdbc15f2f0de2db14952e73d950a83 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:05:59 +0500 Subject: [PATCH 04/35] fix(http): bracket an IPv6 literal target in CONNECT HttpHelper wrote the target host as given, so ::1 went out as "CONNECT ::1:443" and "Host: ::1:443". Neither parses: RFC 9112 takes the authority from RFC 3986, where an IPv6 literal is bracketed, and without the brackets the address's colons run into the port. SOCKS accepted both spellings, since IPAddress.TryParse takes brackets, so the same call worked or failed depending on the proxy family. A host that already carries brackets is left alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/HttpHelperTest.cs | 20 ++++++++++++++++++++ QuickProxyNet/Internal/HttpHelper.cs | 23 +++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/QuickProxyNet.Tests/HttpHelperTest.cs b/QuickProxyNet.Tests/HttpHelperTest.cs index 9c63175..f0cc051 100644 --- a/QuickProxyNet.Tests/HttpHelperTest.cs +++ b/QuickProxyNet.Tests/HttpHelperTest.cs @@ -82,6 +82,26 @@ await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "target.com", 8080, Assert.DoesNotContain("Proxy-Authorization", sent); } + [Theory] + [InlineData("2001:db8::1", "[2001:db8::1]")] + [InlineData("[2001:db8::1]", "[2001:db8::1]")] + [InlineData("::ffff:192.0.2.1", "[::ffff:192.0.2.1]")] + [InlineData("192.0.2.1", "192.0.2.1")] + [InlineData("target.com", "target.com")] + public async Task EstablishTunnel_BracketsIPv6LiteralTarget(string host, string authorityHost) + { + // "CONNECT 2001:db8::1:443" cannot be parsed: the address's colons run into the port. + // RFC 9112 §3.2.3 takes the authority from RFC 3986, where an IPv6 literal is bracketed. + var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); + var stream = new FakeProxyStream(response); + + await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, host, 443, null, + CancellationToken.None); + + var sent = Encoding.UTF8.GetString(stream.WrittenBytes); + Assert.StartsWith($"CONNECT {authorityHost}:443 HTTP/1.1\r\nHost: {authorityHost}:443\r\n", sent); + } + [Fact] public async Task EstablishTunnel_SendsCorrectCommand_WithAuth() { diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index 8e583ed..ed931ef 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -18,10 +18,14 @@ internal static class HttpHelper private static (byte[] buffer, int length) BuildConnectionCommand( string host, int port, NetworkCredential? credentials) { - int hostMaxBytes = Encoding.UTF8.GetMaxByteCount(host.Length); + // An IPv6 literal is bracketed in both places (RFC 9112 §3.2.3 takes the authority from + // RFC 3986): unbracketed, its colons run into the port. Host names and IPv4 literals never + // contain ':', and a caller may already have bracketed one. + bool bracket = host.Contains(':') && !host.StartsWith('['); + int hostMaxBytes = Encoding.UTF8.GetMaxByteCount(host.Length) + (bracket ? 2 : 0); // CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n - // 8 255 1 5 17 255 1 5 2 2 = ~551 bytes worst case + // 8 257 1 5 17 257 1 5 2 2 = ~555 bytes worst case int size = 8 + hostMaxBytes + 1 + 5 + 17 + hostMaxBytes + 1 + 5 + 4; if (credentials is not null) @@ -38,13 +42,13 @@ private static (byte[] buffer, int length) BuildConnectionCommand( // CONNECT {host}:{port} HTTP/1.1\r\n S_connect.CopyTo(buf.AsSpan(pos)); pos += S_connect.Length; - pos += Encoding.UTF8.GetBytes(host, buf.AsSpan(pos)); + pos += WriteHost(host, bracket, buf.AsSpan(pos)); buf[pos++] = (byte)':'; Utf8Formatter.TryFormat(port, buf.AsSpan(pos), out int portLen); pos += portLen; // Host: {host}:{port}\r\n S_http11Host.CopyTo(buf.AsSpan(pos)); pos += S_http11Host.Length; - pos += Encoding.UTF8.GetBytes(host, buf.AsSpan(pos)); + pos += WriteHost(host, bracket, buf.AsSpan(pos)); buf[pos++] = (byte)':'; Utf8Formatter.TryFormat(port, buf.AsSpan(pos), out portLen); pos += portLen; S_crlf.CopyTo(buf.AsSpan(pos)); pos += 2; @@ -81,6 +85,17 @@ private static (byte[] buffer, int length) BuildConnectionCommand( return (buf, pos); } + private static int WriteHost(string host, bool bracket, Span dest) + { + if (!bracket) + return Encoding.UTF8.GetBytes(host, dest); + + dest[0] = (byte)'['; + int length = Encoding.UTF8.GetBytes(host, dest.Slice(1)); + dest[1 + length] = (byte)']'; + return length + 2; + } + internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, Uri proxyUri, string host, int port, NetworkCredential? credentials, CancellationToken cancellationToken) { From 1d9f4b1f3bac644441dcd54418453328657ceb81 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:05:59 +0500 Subject: [PATCH 05/35] fix: reach a proxy at an IPv6 address Both socket paths, ProxyClient.CreateSocket and the Uri fast path in Proxy, created an IPv4-only socket. Connecting it to an IPv6 address throws NotSupportedException, which the connect guard turned into ConnectionFailed: a healthy node at an IPv6 address was reported dead, and a mass checker cannot tell that apart from a real failure. The socket is now dual-mode, Socket(SocketType, ProtocolType), which also lets a proxy name that resolves to both families try both, in the order the OS resolver returns them. A LocalEndPoint still decides the family, since binding is the caller choosing an interface. CreateSocket now disposes the socket when setup or the bind fails, instead of leaving the handle to the finalizer. The IPv6 tests need a machine that can listen on ::1 and report as skipped elsewhere. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/DualStackConnectTest.cs | 65 ++++++++++++++++ .../Helpers/LoopbackConnectProxy.cs | 78 +++++++++++++++++++ QuickProxyNet.Tests/SkipGates.cs | 37 +++++++++ QuickProxyNet/Clients/ProxyClient.cs | 36 ++++++--- QuickProxyNet/Proxy.cs | 4 +- 5 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 QuickProxyNet.Tests/DualStackConnectTest.cs create mode 100644 QuickProxyNet.Tests/Helpers/LoopbackConnectProxy.cs diff --git a/QuickProxyNet.Tests/DualStackConnectTest.cs b/QuickProxyNet.Tests/DualStackConnectTest.cs new file mode 100644 index 0000000..86e54c6 --- /dev/null +++ b/QuickProxyNet.Tests/DualStackConnectTest.cs @@ -0,0 +1,65 @@ +using System.Net; +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +/// +/// The socket opened to the proxy must reach it by either address family. It used to be created +/// IPv4-only, so a proxy at an IPv6 address failed with inside +/// the connect guard and reached the caller as : a +/// healthy node, reported dead. +/// +public class DualStackConnectTest +{ + private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(10); + + [IPv6LoopbackFact] + public async Task Client_ReachesProxyOnIPv6() + { + using var proxy = new LoopbackConnectProxy(IPAddress.IPv6Loopback); + var client = new HttpProxyClient("::1", proxy.Port); + using var cts = new CancellationTokenSource(Deadline); + + await using Stream stream = await client.ConnectAsync("example.com", 80, cts.Token); + + Assert.StartsWith("CONNECT example.com:80 HTTP/1.1\r\n", await proxy.Request); + } + + [IPv6LoopbackFact] + public async Task Client_WithTimeout_ReachesProxyOnIPv6() + { + using var proxy = new LoopbackConnectProxy(IPAddress.IPv6Loopback); + var client = new HttpProxyClient("::1", proxy.Port); + + await using Stream stream = await client.ConnectAsync("example.com", 80, Deadline); + + Assert.StartsWith("CONNECT example.com:80 HTTP/1.1\r\n", await proxy.Request); + } + + [IPv6LoopbackFact] + public async Task UriFastPath_ReachesProxyOnIPv6() + { + using var proxy = new LoopbackConnectProxy(IPAddress.IPv6Loopback); + + await using Stream stream = await Proxy.ConnectAsync( + new Uri($"http://[::1]:{proxy.Port}"), "example.com", 80, Deadline); + + Assert.StartsWith("CONNECT example.com:80 HTTP/1.1\r\n", await proxy.Request); + } + + [Fact] + public async Task Client_BoundToIPv4LocalEndPoint_ReachesProxyOnIPv4() + { + // A LocalEndPoint decides the family: an IPv4 address must be bound on an IPv4 socket, + // not tried on the dual-mode one an unbound client gets. + using var proxy = new LoopbackConnectProxy(IPAddress.Loopback); + var client = new HttpProxyClient("127.0.0.1", proxy.Port) + { + LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 0) + }; + + await using Stream stream = await client.ConnectAsync("example.com", 80, Deadline); + + Assert.StartsWith("CONNECT example.com:80 HTTP/1.1\r\n", await proxy.Request); + } +} diff --git a/QuickProxyNet.Tests/Helpers/LoopbackConnectProxy.cs b/QuickProxyNet.Tests/Helpers/LoopbackConnectProxy.cs new file mode 100644 index 0000000..37557ac --- /dev/null +++ b/QuickProxyNet.Tests/Helpers/LoopbackConnectProxy.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace QuickProxyNet.Tests.Helpers; + +/// +/// A real HTTP CONNECT proxy on a loopback socket, for what an in-memory stream cannot show: +/// which address family the client's socket can reach, and what a TLS handshake with the proxy +/// actually names. Serves one connection: reads the request head, answers 200, and holds the +/// connection open until disposed so the client never races a close against the reply. +/// +internal sealed class LoopbackConnectProxy : IDisposable +{ + private readonly TcpListener _listener; + private readonly X509Certificate2? _certificate; + private TcpClient? _accepted; + + /// The loopback address to listen on. + /// When set, the proxy speaks TLS first, as an HTTPS proxy does. + public LoopbackConnectProxy(IPAddress address, X509Certificate2? certificate = null) + { + _certificate = certificate; + _listener = new TcpListener(address, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + Request = ServeAsync(); + } + + public int Port { get; } + + /// The request head the client sent; completes once the 200 has been written. + public Task Request { get; } + + /// The server name the client's TLS handshake carried, when the proxy speaks TLS. + public string? ServerName { get; private set; } + + private async Task ServeAsync() + { + _accepted = await _listener.AcceptTcpClientAsync(); + Stream stream = _accepted.GetStream(); + + if (_certificate is { } certificate) + { + var tls = new SslStream(stream); + await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificateSelectionCallback = (_, name) => + { + ServerName = name; + return certificate; + } + }); + stream = tls; + } + + var buffer = new byte[4096]; + int length = 0; + while (buffer.AsSpan(0, length).IndexOf("\r\n\r\n"u8) < 0) + { + int read = await stream.ReadAsync(buffer.AsMemory(length)); + if (read == 0) + throw new IOException("The client closed the connection before finishing its request."); + length += read; + } + + await stream.WriteAsync("HTTP/1.1 200 Connection established\r\n\r\n"u8.ToArray()); + return Encoding.ASCII.GetString(buffer, 0, length); + } + + public void Dispose() + { + _accepted?.Dispose(); + _listener.Stop(); + } +} diff --git a/QuickProxyNet.Tests/SkipGates.cs b/QuickProxyNet.Tests/SkipGates.cs index e1bf391..b6e4306 100644 --- a/QuickProxyNet.Tests/SkipGates.cs +++ b/QuickProxyNet.Tests/SkipGates.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Net.Sockets; using System.Reflection; using System.Security.Cryptography; using Xunit.Sdk; @@ -163,3 +165,38 @@ public ChaCha20InlineDataAttribute(params object[] data) /// public override IEnumerable GetData(MethodInfo testMethod) => [_data]; } + +/// +/// A that reports the test as skipped on a machine that cannot +/// listen on the IPv6 loopback address. +/// +/// +/// alone does not decide it: a container can have the IPv6 +/// stack and no ::1, so the gate actually binds. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class IPv6LoopbackFactAttribute : FactAttribute +{ + private static readonly bool Available = CanBindIPv6Loopback(); + + /// Creates the attribute, deciding the skip state from the machine. + public IPv6LoopbackFactAttribute() => + Skip = Available ? null : "This machine cannot listen on the IPv6 loopback address (::1)."; + + private static bool CanBindIPv6Loopback() + { + if (!Socket.OSSupportsIPv6) + return false; + + try + { + using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.IPv6Loopback, 0)); + return true; + } + catch (SocketException) + { + return false; + } + } +} diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 84cbb9c..e2fb83a 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -96,17 +96,31 @@ private static string FormatUriHost(string host) => private Socket CreateSocket() { - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) - { - NoDelay = this.NoDelay, - SendTimeout = this.WriteTimeout, - ReceiveTimeout = this.ReadTimeout - }; - if (LingerState is not null) - socket.LingerState = LingerState; - if (LocalEndPoint is not null) - socket.Bind(LocalEndPoint); - return socket; + // Socket(SocketType, ProtocolType) is dual-mode wherever the OS has IPv6, so the proxy is + // reachable at an address of either family; an IPv4-only socket fails every IPv6 proxy. + // A LocalEndPoint is the caller choosing the interface, and with it the family. + IPEndPoint? local = LocalEndPoint; + var socket = local is null + ? new Socket(SocketType.Stream, ProtocolType.Tcp) + : new Socket(local.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + socket.NoDelay = NoDelay; + socket.SendTimeout = WriteTimeout; + socket.ReceiveTimeout = ReadTimeout; + if (LingerState is not null) + socket.LingerState = LingerState; + if (local is not null) + socket.Bind(local); + return socket; + } + catch + { + // A failed bind must not leak the handle: under a few thousand concurrent checks the + // leak is what turns one address-in-use into handle exhaustion. + socket.Dispose(); + throw; + } } public async ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index 3ea8a4b..f44b541 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -354,7 +354,9 @@ private static async ValueTask ConnectCoreAsync(Uri proxyUri, string hos var credentials = ParseCredentials(proxyUri); - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) + // Dual-mode, as in ProxyClient.CreateSocket: an IPv4-only socket cannot reach a proxy at an + // IPv6 address. + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true, LingerState = new LingerOption(true, 0) From e43e8e430ad6dbb19be55e37a0c1ae4cdb2d3b4d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:06:00 +0500 Subject: [PATCH 06/35] fix(https): check the proxy's certificate against the proxy's name HttpsProxyClient set TargetHost to the CONNECT target, so SNI named the site being tunnelled to and the proxy's certificate was checked against that name. Under the default validation every HTTPS proxy failed unless its certificate happened to name the target. The TLS session is with the proxy, so it now names the proxy, as the Uri path in ProxyConnector already did. The bug dates from the first commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/HttpsProxyClientTest.cs | 63 +++++++++++++++++++++ QuickProxyNet/Clients/HttpsProxyClient.cs | 8 ++- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 QuickProxyNet.Tests/HttpsProxyClientTest.cs diff --git a/QuickProxyNet.Tests/HttpsProxyClientTest.cs b/QuickProxyNet.Tests/HttpsProxyClientTest.cs new file mode 100644 index 0000000..d013ee5 --- /dev/null +++ b/QuickProxyNet.Tests/HttpsProxyClientTest.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +public class HttpsProxyClientTest +{ + /// + /// The TLS session is with the proxy, so the proxy's name is what SNI carries and what its + /// certificate is checked against. Both used to be the CONNECT target's, which failed every + /// HTTPS proxy under the default validation unless its certificate named the site being + /// tunnelled to. + /// + [Fact] + public async Task Handshake_NamesTheProxy_NotTheTarget() + { + using X509Certificate2 certificate = CreateSelfSignedCertificate("localhost"); + using var proxy = new LoopbackConnectProxy(IPAddress.Loopback, certificate); + + SslPolicyErrors? errors = null; + var client = new HttpsProxyClient("localhost", proxy.Port) + { + ServerCertificateValidationCallback = (_, _, _, policyErrors) => + { + errors = policyErrors; + return true; + } + }; + + // Connected by hand so "localhost" never goes to DNS: the client is only told the proxy + // is called that, and the name is all this test is about. + using var socket = new TcpClient(); + await socket.ConnectAsync(IPAddress.Loopback, proxy.Port); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await using Stream stream = await client.ConnectAsync(socket.GetStream(), "example.com", 443, cts.Token); + + Assert.StartsWith("CONNECT example.com:443 HTTP/1.1\r\n", await proxy.Request); + Assert.Equal("localhost", proxy.ServerName); + // Self-signed, so the chain is untrusted; the name, though, has to match. + Assert.NotNull(errors); + Assert.False(errors.Value.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch), $"Policy errors: {errors}"); + } + + private static X509Certificate2 CreateSelfSignedCertificate(string dnsName) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest($"CN={dnsName}", key, HashAlgorithmName.SHA256); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName(dnsName); + request.CertificateExtensions.Add(names.Build()); + + using X509Certificate2 ephemeral = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + // Schannel refuses a private key that exists only in memory; a PKCS#12 round trip gives + // the certificate a key it can use. + return X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pkcs12), password: null); + } +} diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index ae693a3..8e6fdeb 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -32,7 +32,9 @@ public HttpsProxyClient(string host, int port, NetworkCredential credentials) : public override ProxyType Type => ProxyType.Https; - private SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string host) + // The TLS session is with the proxy, not with the target the tunnel leads to, so the proxy's + // name is the one SNI carries and the certificate is checked against. + private SslClientAuthenticationOptions GetSslClientAuthenticationOptions() { return new SslClientAuthenticationOptions { @@ -43,7 +45,7 @@ private SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string CipherSuitesPolicy = SslCipherSuitesPolicy, ClientCertificates = ClientCertificates, EnabledSslProtocols = SslProtocols, - TargetHost = host + TargetHost = ProxyHost }; } @@ -57,7 +59,7 @@ public override async ValueTask ConnectAsync(Stream stream, string host, try { await TlsHandshake.AuthenticateAsync( - ssl, GetSslClientAuthenticationOptions(host), $"{ProxyHost}:{ProxyPort}", cancellationToken); + ssl, GetSslClientAuthenticationOptions(), $"{ProxyHost}:{ProxyPort}", cancellationToken); } catch { From 3b1c99343238aa675407196a4ade7d8dba76da6f Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:09:54 +0500 Subject: [PATCH 07/35] feat: take the connect target as an EndPoint IProxyClient.ConnectAsync and Proxy.ConnectAsync(link, ...) accept a DnsEndPoint or an IPEndPoint next to host and port. It is the shape Socket.ConnectAsync has and the one SocketsHttpHandler.ConnectCallback hands over, so an HttpClient goes through any proxy this library speaks with a one-line callback. On IProxyClient they are default interface members forwarding to the host-and-port overloads, so an implementation that does not derive from ProxyClient gets them without a change; ProxyClient implements them publicly so they are reachable on the class too. An IPv4-mapped IPv6 address, which is how a dual-mode socket reports an IPv4 peer, is sent as IPv4 rather than under an IPv6 address type. Any other EndPoint is an ArgumentException, which keeps ConnectAsync's exception contract. No EndPoint overloads were added next to the Uri ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- AGENTS.md | 3 + QuickProxyNet.Tests/EndPointConnectTest.cs | 135 +++++++++++++++++++++ QuickProxyNet/Clients/ProxyClient.cs | 41 +++++++ QuickProxyNet/IProxyClient.cs | 62 ++++++++++ QuickProxyNet/Proxy.cs | 40 ++++++ README.md | 12 ++ 6 files changed, 293 insertions(+) create mode 100644 QuickProxyNet.Tests/EndPointConnectTest.cs diff --git a/AGENTS.md b/AGENTS.md index 543c4ad..96b8089 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,9 @@ All public library types live in the `QuickProxyNet` namespace. - `ProxyUriExtensions` adds `Uri.ConnectThroughProxyAsync(...)`. - `IProxyClient` is the client contract; connection methods return `ValueTask`. `SourceLink` carries the text the client was built from. + A target is `host, port` or an `EndPoint` (`DnsEndPoint` / `IPEndPoint`); the + `EndPoint` overloads are default interface members that forward to the + host-and-port ones, so an implementation outside `ProxyClient` gets them free. - `ProxyClient` owns common socket setup, timeout handling, and argument validation. - `ProxyProtocolException` carries a structured `ProxyErrorCode`. diff --git a/QuickProxyNet.Tests/EndPointConnectTest.cs b/QuickProxyNet.Tests/EndPointConnectTest.cs new file mode 100644 index 0000000..2339692 --- /dev/null +++ b/QuickProxyNet.Tests/EndPointConnectTest.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +/// +/// ConnectAsync(EndPoint) names the target the way does: a +/// or an , never an address spelled as text. +/// +public class EndPointConnectTest +{ + private static byte[] HttpOk => Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); + + private static string Sent(FakeProxyStream stream) => Encoding.ASCII.GetString(stream.WrittenBytes); + + [Fact] + public async Task DnsEndPoint_IsSentAsTheName() + { + var stream = new FakeProxyStream(HttpOk); + var client = new HttpProxyClient("proxy.example", 8080); + + await client.ConnectAsync(stream, new DnsEndPoint("target.example", 443)); + + Assert.StartsWith("CONNECT target.example:443 HTTP/1.1\r\n", Sent(stream)); + } + + [Fact] + public async Task IPv6EndPoint_IsBracketedInConnect() + { + var stream = new FakeProxyStream(HttpOk); + var client = new HttpProxyClient("proxy.example", 8080); + + await client.ConnectAsync(stream, new IPEndPoint(IPAddress.Parse("2001:db8::1"), 443)); + + Assert.StartsWith("CONNECT [2001:db8::1]:443 HTTP/1.1\r\n", Sent(stream)); + } + + [Fact] + public async Task IPv4MappedEndPoint_GoesOnTheWireAsIPv4() + { + // A dual-mode socket reports an IPv4 peer as ::ffff:a.b.c.d. Passed through, SOCKS5 would + // send address type 4 and sixteen bytes for what is an IPv4 host. + byte[] reply = [5, 0, 5, 0, 0, 1, 0, 0, 0, 0, 0, 0]; + var stream = new FakeProxyStream(reply); + var client = new Socks5Client("proxy.example", 1080); + + await client.ConnectAsync(stream, new IPEndPoint(IPAddress.Parse("::ffff:192.0.2.1"), 443)); + + byte[] greeting = [5, 1, 0]; + byte[] request = [5, 1, 0, 1, 192, 0, 2, 1, 443 >> 8, 443 & 0xFF]; + Assert.Equal([.. greeting, .. request], stream.WrittenBytes); + } + + [Fact] + public async Task EndPointOfAnotherKind_IsAnArgumentException() + { + var client = new HttpProxyClient("proxy.example", 8080); + + await Assert.ThrowsAsync(() => + client.ConnectAsync(new FakeProxyStream(HttpOk), new UnixDomainSocketEndPoint("proxy.sock")).AsTask()); + } + + [Fact] + public async Task NullEndPoint_IsAnArgumentNullException() + { + var client = new HttpProxyClient("proxy.example", 8080); + + await Assert.ThrowsAsync(() => + client.ConnectAsync(new FakeProxyStream(HttpOk), (EndPoint)null!).AsTask()); + } + + [Fact] + public async Task InterfaceDefaults_ForwardToTheHostAndPortOverloads() + { + // An IProxyClient that does not derive from ProxyClient gets the EndPoint overloads for free. + var recording = new RecordingClient(); + IProxyClient client = recording; + var target = new IPEndPoint(IPAddress.Parse("::ffff:192.0.2.1"), 443); + + await client.ConnectAsync(target); + Assert.Equal(("192.0.2.1", 443, "host, port"), recording.Last); + + await client.ConnectAsync(target, TimeSpan.FromSeconds(1)); + Assert.Equal(("192.0.2.1", 443, "host, port, timeout"), recording.Last); + + await client.ConnectAsync(Stream.Null, target); + Assert.Equal(("192.0.2.1", 443, "source, host, port"), recording.Last); + } + + [Fact] + public async Task ProxyConnect_WithEndPoint_TunnelsToIt() + { + using var proxy = new LoopbackConnectProxy(IPAddress.Loopback); + + await using Stream stream = await Proxy.ConnectAsync( + $"http://127.0.0.1:{proxy.Port}", new DnsEndPoint("target.example", 80), TimeSpan.FromSeconds(10)); + + Assert.StartsWith("CONNECT target.example:80 HTTP/1.1\r\n", await proxy.Request); + } + + private sealed class RecordingClient : IProxyClient + { + public (string Host, int Port, string Overload) Last { get; private set; } + + public Uri ProxyUri { get; } = new("socks5://proxy.example:1080"); + public NetworkCredential? ProxyCredentials => null; + public string ProxyHost => "proxy.example"; + public int ProxyPort => 1080; + public ProxyType Type => ProxyType.Socks5; + public IPEndPoint? LocalEndPoint { get; set; } + public LingerOption? LingerState { get; set; } + public bool NoDelay { get; set; } + public int WriteTimeout { get; set; } + public int ReadTimeout { get; set; } + + public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) => + Record(host, port, "host, port"); + + public ValueTask ConnectAsync(Stream source, string host, int port, + CancellationToken cancellationToken = default) => + Record(host, port, "source, host, port"); + + public ValueTask ConnectAsync(string host, int port, TimeSpan timeout, + CancellationToken cancellationToken = default) => + Record(host, port, "host, port, timeout"); + + private ValueTask Record(string host, int port, string overload) + { + Last = (host, port, overload); + return ValueTask.FromResult(Stream.Null); + } + } +} diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index e2fb83a..0e3be31 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -245,6 +245,29 @@ public virtual async ValueTask ConnectAsync(string host, int port, TimeS public abstract ValueTask ConnectAsync(Stream source, string host, int port, CancellationToken cancellationToken = default); + /// + public async ValueTask ConnectAsync(EndPoint target, CancellationToken cancellationToken = default) + { + var (host, port) = SplitTarget(target); + return await ConnectAsync(host, port, cancellationToken); + } + + /// + public async ValueTask ConnectAsync(EndPoint target, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + var (host, port) = SplitTarget(target); + return await ConnectAsync(host, port, timeout, cancellationToken); + } + + /// + public async ValueTask ConnectAsync(Stream source, EndPoint target, + CancellationToken cancellationToken = default) + { + var (host, port) = SplitTarget(target); + return await ConnectAsync(source, host, port, cancellationToken); + } + internal static void ValidateArguments(string host, int port) { if (host == null) @@ -257,4 +280,22 @@ internal static void ValidateArguments(string host, int port) if (port <= 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port)); } + + // The EndPoint overloads spell the target the way the host-and-port ones take it. An + // IPv4-mapped address is an IPv4 host however a dual-mode socket reports it; left as IPv6, + // SOCKS5, VLESS, VMess and Trojan would all put an IPv6 address type on the wire for it. + internal static (string Host, int Port) SplitTarget(EndPoint target) + { + ArgumentNullException.ThrowIfNull(target); + + return target switch + { + DnsEndPoint dns => (dns.Host, dns.Port), + IPEndPoint ip => ( + (ip.Address.IsIPv4MappedToIPv6 ? ip.Address.MapToIPv4() : ip.Address).ToString(), ip.Port), + _ => throw new ArgumentException( + $"A proxy target must be a {nameof(DnsEndPoint)} or an {nameof(IPEndPoint)}, not {target.GetType().Name}.", + nameof(target)) + }; + } } diff --git a/QuickProxyNet/IProxyClient.cs b/QuickProxyNet/IProxyClient.cs index 1444435..4ca39bc 100644 --- a/QuickProxyNet/IProxyClient.cs +++ b/QuickProxyNet/IProxyClient.cs @@ -98,4 +98,66 @@ public interface IProxyClient /// A cancellation token that can be used to cancel the connection attempt. /// A representing the asynchronous operation and yielding the connected . ValueTask ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// Asynchronously connects to a target endpoint through the proxy. + /// + /// + /// A , whose name the proxy resolves and whose address family is + /// therefore not a constraint, or an . + /// + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + /// + /// is neither a nor an . + /// + /// + /// The shape has, and the one + /// SocketsHttpHandler.ConnectCallback hands over. An IPv4 address carried as IPv6 + /// (::ffff:a.b.c.d, as a dual-mode socket reports its peers) is sent as the IPv4 + /// address it is. + /// + async ValueTask ConnectAsync(EndPoint target, CancellationToken cancellationToken = default) + { + var (host, port) = ProxyClient.SplitTarget(target); + return await ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously connects to a target endpoint through the proxy, with a specified timeout. + /// + /// + /// A or an , as for + /// . + /// + /// The maximum time to wait for the connection to complete. + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + /// + /// is neither a nor an . + /// + async ValueTask ConnectAsync(EndPoint target, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var (host, port) = ProxyClient.SplitTarget(target); + return await ConnectAsync(host, port, timeout, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously connects to a target endpoint through the proxy, using an existing stream. + /// + /// The source to use for establishing the connection. + /// + /// A or an , as for + /// . + /// + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + /// + /// is neither a nor an . + /// + async ValueTask ConnectAsync(Stream source, EndPoint target, CancellationToken cancellationToken = default) + { + var (host, port) = ProxyClient.SplitTarget(target); + return await ConnectAsync(source, host, port, cancellationToken).ConfigureAwait(false); + } } \ No newline at end of file diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index f44b541..1f806b2 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -61,6 +61,46 @@ public static async ValueTask ConnectAsync(string proxyLink, string host return await client.ConnectAsync(host, port, timeout, cancellationToken).ConfigureAwait(false); } + /// + /// Connects to a target endpoint through a proxy described by a URL or share link. + /// + /// The proxy URL or share link, in any scheme accepts. + /// + /// A , whose name the proxy resolves, or an . + /// + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + /// + /// is neither a nor an . + /// + public static async ValueTask ConnectAsync(string proxyLink, EndPoint target, + CancellationToken cancellationToken = default) + { + IProxyClient client = Create(proxyLink); + return await client.ConnectAsync(target, cancellationToken).ConfigureAwait(false); + } + + /// + /// Connects to a target endpoint through a proxy described by a URL or share link, giving up + /// after . + /// + /// The proxy URL or share link, in any scheme accepts. + /// + /// A , whose name the proxy resolves, or an . + /// + /// Maximum time to wait for the connection to complete. + /// A token to cancel the operation. + /// A connected tunneled through the proxy. + /// + /// is neither a nor an . + /// + public static async ValueTask ConnectAsync(string proxyLink, EndPoint target, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + IProxyClient client = Create(proxyLink); + return await client.ConnectAsync(target, timeout, cancellationToken).ConfigureAwait(false); + } + /// /// Connects to a target host through the specified proxy. /// Opens a socket, negotiates the tunnel, and returns the connected stream. diff --git a/README.md b/README.md index 6291104..09fc7f4 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,18 @@ client.ReadTimeout = 5000; await using var stream = await client.ConnectAsync("example.com", 443); ``` +### Target as an EndPoint + +`IProxyClient.ConnectAsync` and `Proxy.ConnectAsync(link, ...)` also take the target as an `EndPoint`, the way `Socket.ConnectAsync` does: a `DnsEndPoint` for a name the proxy resolves, or an `IPEndPoint`. That is what `SocketsHttpHandler.ConnectCallback` hands over, so an `HttpClient` goes through any proxy this library speaks in one line: + +```csharp +var proxy = Proxy.Create("vless://..."); +using var http = new HttpClient(new SocketsHttpHandler +{ + ConnectCallback = (context, ct) => proxy.ConnectAsync(context.DnsEndPoint, ct) +}); +``` + ### With explicit proxy type and credentials ```csharp From 850a1c36ab28b6fde4d847b38f843120b81ebbc1 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:34:46 +0500 Subject: [PATCH 08/35] fix(socks): keep an over-long string inside the exception contract EncodeString wrote into the rented request buffer and cast the length with checked((byte)). ArrayPool rounds the 513-byte request up to 1024, so a username, password or host of 256 to about 1000 UTF-8 bytes fit the buffer, overflowed the cast, and left ConnectAsync as a raw OverflowException instead of ProxyErrorCode.SocksStringTooLong. The write is now capped at 255 bytes with Encoding.UTF8.TryGetBytes, as ProxyAddress already does. The request buffer, which held the SOCKS5 username and password or the SOCKS4 user id, now goes back to the pool cleared when there were credentials. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/Socks5HelperTest.cs | 45 ++ QuickProxyNet/Internal/SocksHelper.cs | 615 ++++++++++++------------ 2 files changed, 353 insertions(+), 307 deletions(-) diff --git a/QuickProxyNet.Tests/Socks5HelperTest.cs b/QuickProxyNet.Tests/Socks5HelperTest.cs index 4e6ce8f..2865dff 100644 --- a/QuickProxyNet.Tests/Socks5HelperTest.cs +++ b/QuickProxyNet.Tests/Socks5HelperTest.cs @@ -143,6 +143,51 @@ public async Task Socks5_ConnectFailed_Throws() Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); } + // ArrayPool rounds the 513-byte request buffer up to 1024, so a string of 256 to about 1000 + // UTF-8 bytes fits the buffer but not the one-byte length field. It used to escape as a raw + // OverflowException, outside ConnectAsync's exception contract. + [Theory] + [InlineData(256)] + [InlineData(600)] + [InlineData(2000)] + public async Task Socks5_UsernameOver255Bytes_IsSocksStringTooLong(int length) + { + var stream = new FakeProxyStream([5, 2]); + var creds = new NetworkCredential(new string('u', length), "pass"); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks5TunnelAsync(stream, "example.com", 443, creds, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.SocksStringTooLong, ex.ErrorCode); + } + + [Fact] + public async Task Socks5_HostUnder255CharsButOver255Bytes_IsSocksStringTooLong() + { + // 200 Cyrillic letters pass the 255-character argument check and encode to 400 bytes. + var stream = new FakeProxyStream([5, 0]); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks5TunnelAsync(stream, new string('ж', 200), 443, null, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.SocksStringTooLong, ex.ErrorCode); + } + + [Fact] + public async Task Socks4_UserIdOver255Bytes_IsSocksStringTooLong() + { + var stream = new FakeProxyStream([]); + var creds = new NetworkCredential(new string('u', 300), ""); + + var ex = await Assert.ThrowsAsync( + () => SocksHelper.EstablishSocks4TunnelAsync(stream, false, "127.0.0.1", 443, creds, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.SocksStringTooLong, ex.ErrorCode); + } + [Fact] public async Task Socks5_WrongVersion_Throws() { diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index aa103d3..8e444e9 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -1,307 +1,308 @@ -using System.Buffers; -using System.Buffers.Binary; -using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using System.Text; - -namespace QuickProxyNet; - -internal static class SocksHelper -{ - // Largest possible message size is 513 bytes (Socks5 username & password auth) - private const int BufferSize = 513; - private const int ProtocolVersion4 = 4; - private const int ProtocolVersion5 = 5; - private const int SubnegotiationVersion = 1; // Socks5 username & password auth - private const byte METHOD_NO_AUTH = 0; - private const byte METHOD_USERNAME_PASSWORD = 2; - private const byte CMD_CONNECT = 1; - private const byte ATYP_IPV4 = 1; - private const byte ATYP_DOMAIN_NAME = 3; - private const byte ATYP_IPV6 = 4; - private const byte Socks5_Success = 0; - private const byte Socks4_Success = 90; - private const byte Socks4_AuthFailed = 93; - - - internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string host, int port, - NetworkCredential? credentials, CancellationToken cancellationToken) - { - var buffer = ArrayPool.Shared.Rent(BufferSize); - try - { - // https://tools.ietf.org/html/rfc1928 - - // +----+----------+----------+ - // |VER | NMETHODS | METHODS | - // +----+----------+----------+ - // | 1 | 1 | 1 to 255 | - // +----+----------+----------+ - buffer[0] = ProtocolVersion5; - if (credentials is null) - { - buffer[1] = 1; - buffer[2] = METHOD_NO_AUTH; - } - else - { - buffer[1] = 2; - buffer[2] = METHOD_NO_AUTH; - buffer[3] = METHOD_USERNAME_PASSWORD; - } - - await stream.WriteAsync(buffer.AsMemory(0, buffer[1] + 2), cancellationToken).ConfigureAwait(false); - - // +----+--------+ - // |VER | METHOD | - // +----+--------+ - // | 1 | 1 | - // +----+--------+ - await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); - VerifyProtocolVersion(ProtocolVersion5, buffer[0]); - - switch (buffer[1]) - { - case METHOD_NO_AUTH: - // continue - break; - - case METHOD_USERNAME_PASSWORD: - { - // https://tools.ietf.org/html/rfc1929 - if (credentials is null) - // If the server is behaving well, it shouldn't pick username and password auth - // because we don't claim to support it when we don't have credentials. - // Just being defensive here. - throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, "SOCKS server requested username & password authentication."); - - // +----+------+----------+------+----------+ - // |VER | ULEN | UNAME | PLEN | PASSWD | - // +----+------+----------+------+----------+ - // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | - // +----+------+----------+------+----------+ - buffer[0] = SubnegotiationVersion; - var usernameLength = EncodeString(credentials.UserName, buffer.AsSpan(2), - nameof(credentials.UserName)); - buffer[1] = usernameLength; - var passwordLength = EncodeString(credentials.Password, buffer.AsSpan(3 + usernameLength), - nameof(credentials.Password)); - buffer[2 + usernameLength] = passwordLength; - await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), cancellationToken) - .ConfigureAwait(false); - - // +----+--------+ - // |VER | STATUS | - // +----+--------+ - // | 1 | 1 | - // +----+--------+ - await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); - if (buffer[0] != SubnegotiationVersion) - throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, - $"Unexpected SOCKS5 auth subnegotiation version. Expected {SubnegotiationVersion}, got {buffer[0]}."); - if (buffer[1] != Socks5_Success) - throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); - break; - } - - default: - throw new ProxyProtocolException(ProxyErrorCode.SocksNoAuthMethod, "SOCKS server did not return a suitable authentication method."); - } - - - // +----+-----+-------+------+----------+----------+ - // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | - // +----+-----+-------+------+----------+----------+ - // | 1 | 1 | X'00' | 1 | Variable | 2 | - // +----+-----+-------+------+----------+----------+ - buffer[0] = ProtocolVersion5; - buffer[1] = CMD_CONNECT; - buffer[2] = 0; - int addressLength; - - if (IPAddress.TryParse(host, out var hostIP)) - { - if (hostIP.AddressFamily == AddressFamily.InterNetwork) - { - buffer[3] = ATYP_IPV4; - hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); - Debug.Assert(bytesWritten == 4); - addressLength = 4; - } - else - { - Debug.Assert(hostIP.AddressFamily == AddressFamily.InterNetworkV6); - buffer[3] = ATYP_IPV6; - hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); - Debug.Assert(bytesWritten == 16); - addressLength = 16; - } - } - else - { - buffer[3] = ATYP_DOMAIN_NAME; - var hostLength = EncodeString(host, buffer.AsSpan(5), nameof(host)); - buffer[4] = hostLength; - addressLength = hostLength + 1; - } - - BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)port); - - await stream.WriteAsync(buffer.AsMemory(0, addressLength + 6), cancellationToken).ConfigureAwait(false); - - // +----+-----+-------+------+----------+----------+ - // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT | - // +----+-----+-------+------+----------+----------+ - // | 1 | 1 | X'00' | 1 | Variable | 2 | - // +----+-----+-------+------+----------+----------+ - await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), cancellationToken).ConfigureAwait(false); - VerifyProtocolVersion(ProtocolVersion5, buffer[0]); - if (buffer[1] != Socks5_Success) - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"SOCKS5 server rejected connection to {host}:{port} (reply code: 0x{buffer[1]:X2})."); - var bytesToSkip = buffer[3] switch - { - ATYP_IPV4 => 5, - ATYP_IPV6 => 17, - ATYP_DOMAIN_NAME => buffer[4] + 2, - _ => throw new ProxyProtocolException(ProxyErrorCode.SocksBadAddressType, "SOCKS server returned an unknown address type.") - }; - await stream.ReadExactlyAsync(buffer.AsMemory(0, bytesToSkip), cancellationToken).ConfigureAwait(false); - // response address not used - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - internal static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool isVersion4a, string host, int port, - NetworkCredential? credentials, CancellationToken cancellationToken) - { - var buffer = ArrayPool.Shared.Rent(BufferSize); - - try - { - // https://www.openssh.com/txt/socks4.protocol - - // +----+----+----+----+----+----+----+----+----+----+....+----+ - // | VN | CD | DSTPORT | DSTIP | USERID |NULL| - // +----+----+----+----+----+----+----+----+----+----+....+----+ - // 1 1 2 4 variable 1 - buffer[0] = ProtocolVersion4; - buffer[1] = CMD_CONNECT; - - BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(2), (ushort)port); - - IPAddress? ipv4Address = null; - if (IPAddress.TryParse(host, out var hostIP)) - { - if (hostIP.AddressFamily == AddressFamily.InterNetwork) - ipv4Address = hostIP; - else if (hostIP.IsIPv4MappedToIPv6) - ipv4Address = hostIP.MapToIPv4(); - else - throw new ProxyProtocolException(ProxyErrorCode.SocksIPv6NotSupported, "SOCKS4 does not support IPv6 addresses."); - } - else if (!isVersion4a) - { - // Socks4 does not support domain names - try to resolve it here - IPAddress[] addresses; - try - { - addresses = - await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationToken) - .ConfigureAwait(false); - } - catch (Exception ex) - { - throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address.", ex); - } - - if (addresses.Length == 0) - throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address."); - - ipv4Address = addresses[0]; - } - - if (ipv4Address is null) - { - Debug.Assert(isVersion4a); - buffer[4] = 0; - buffer[5] = 0; - buffer[6] = 0; - buffer[7] = 255; - } - else - { - ipv4Address.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); - Debug.Assert(bytesWritten == 4); - } - - var usernameLength = EncodeString(credentials?.UserName, buffer.AsSpan(8), nameof(credentials.UserName)); - buffer[8 + usernameLength] = 0; - var totalLength = 9 + usernameLength; - - if (ipv4Address is null) - { - // https://www.openssh.com/txt/socks4a.protocol - var hostLength = EncodeString(host, buffer.AsSpan(totalLength), nameof(host)); - buffer[totalLength + hostLength] = 0; - totalLength += hostLength + 1; - } - - await stream.WriteAsync(buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); - - // +----+----+----+----+----+----+----+----+ - // | VN | CD | DSTPORT | DSTIP | - // +----+----+----+----+----+----+----+----+ - // 1 1 2 4 - - - await stream.ReadExactlyAsync(buffer.AsMemory(0, 8), cancellationToken).ConfigureAwait(false); - - if (buffer[0] != 0) - throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, - $"Unexpected SOCKS4 reply version. Expected 0, got {buffer[0]}."); - - switch (buffer[1]) - { - case Socks4_Success: - // Nothing to do - break; - case Socks4_AuthFailed: - throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); - default: - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); - } - // response address not used - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName) - { - try - { - return checked((byte)Encoding.UTF8.GetBytes(chars, buffer)); - } - catch (ArgumentException) - { - Debug.Assert(Encoding.UTF8.GetByteCount(chars) > 255); - throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, $"Encoding the {parameterName} took more than the maximum of 255 bytes"); - } - } - - private static void VerifyProtocolVersion(byte expected, byte version) - { - if (expected != version) - throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, - $"Unexpected SOCKS protocol version. Required {expected}, got {version}."); - } - - -} \ No newline at end of file +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet; + +internal static class SocksHelper +{ + // Largest possible message size is 513 bytes (Socks5 username & password auth) + private const int BufferSize = 513; + private const int ProtocolVersion4 = 4; + private const int ProtocolVersion5 = 5; + private const int SubnegotiationVersion = 1; // Socks5 username & password auth + private const byte METHOD_NO_AUTH = 0; + private const byte METHOD_USERNAME_PASSWORD = 2; + private const byte CMD_CONNECT = 1; + private const byte ATYP_IPV4 = 1; + private const byte ATYP_DOMAIN_NAME = 3; + private const byte ATYP_IPV6 = 4; + private const byte Socks5_Success = 0; + private const byte Socks4_Success = 90; + private const byte Socks4_AuthFailed = 93; + + + internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + // https://tools.ietf.org/html/rfc1928 + + // +----+----------+----------+ + // |VER | NMETHODS | METHODS | + // +----+----------+----------+ + // | 1 | 1 | 1 to 255 | + // +----+----------+----------+ + buffer[0] = ProtocolVersion5; + if (credentials is null) + { + buffer[1] = 1; + buffer[2] = METHOD_NO_AUTH; + } + else + { + buffer[1] = 2; + buffer[2] = METHOD_NO_AUTH; + buffer[3] = METHOD_USERNAME_PASSWORD; + } + + await stream.WriteAsync(buffer.AsMemory(0, buffer[1] + 2), cancellationToken).ConfigureAwait(false); + + // +----+--------+ + // |VER | METHOD | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); + VerifyProtocolVersion(ProtocolVersion5, buffer[0]); + + switch (buffer[1]) + { + case METHOD_NO_AUTH: + // continue + break; + + case METHOD_USERNAME_PASSWORD: + { + // https://tools.ietf.org/html/rfc1929 + if (credentials is null) + // If the server is behaving well, it shouldn't pick username and password auth + // because we don't claim to support it when we don't have credentials. + // Just being defensive here. + throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, "SOCKS server requested username & password authentication."); + + // +----+------+----------+------+----------+ + // |VER | ULEN | UNAME | PLEN | PASSWD | + // +----+------+----------+------+----------+ + // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | + // +----+------+----------+------+----------+ + buffer[0] = SubnegotiationVersion; + var usernameLength = EncodeString(credentials.UserName, buffer.AsSpan(2), + nameof(credentials.UserName)); + buffer[1] = usernameLength; + var passwordLength = EncodeString(credentials.Password, buffer.AsSpan(3 + usernameLength), + nameof(credentials.Password)); + buffer[2 + usernameLength] = passwordLength; + await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), cancellationToken) + .ConfigureAwait(false); + + // +----+--------+ + // |VER | STATUS | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); + if (buffer[0] != SubnegotiationVersion) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS5 auth subnegotiation version. Expected {SubnegotiationVersion}, got {buffer[0]}."); + if (buffer[1] != Socks5_Success) + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); + break; + } + + default: + throw new ProxyProtocolException(ProxyErrorCode.SocksNoAuthMethod, "SOCKS server did not return a suitable authentication method."); + } + + + // +----+-----+-------+------+----------+----------+ + // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + buffer[0] = ProtocolVersion5; + buffer[1] = CMD_CONNECT; + buffer[2] = 0; + int addressLength; + + if (IPAddress.TryParse(host, out var hostIP)) + { + if (hostIP.AddressFamily == AddressFamily.InterNetwork) + { + buffer[3] = ATYP_IPV4; + hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); + Debug.Assert(bytesWritten == 4); + addressLength = 4; + } + else + { + Debug.Assert(hostIP.AddressFamily == AddressFamily.InterNetworkV6); + buffer[3] = ATYP_IPV6; + hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); + Debug.Assert(bytesWritten == 16); + addressLength = 16; + } + } + else + { + buffer[3] = ATYP_DOMAIN_NAME; + var hostLength = EncodeString(host, buffer.AsSpan(5), nameof(host)); + buffer[4] = hostLength; + addressLength = hostLength + 1; + } + + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)port); + + await stream.WriteAsync(buffer.AsMemory(0, addressLength + 6), cancellationToken).ConfigureAwait(false); + + // +----+-----+-------+------+----------+----------+ + // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), cancellationToken).ConfigureAwait(false); + VerifyProtocolVersion(ProtocolVersion5, buffer[0]); + if (buffer[1] != Socks5_Success) + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"SOCKS5 server rejected connection to {host}:{port} (reply code: 0x{buffer[1]:X2})."); + var bytesToSkip = buffer[3] switch + { + ATYP_IPV4 => 5, + ATYP_IPV6 => 17, + ATYP_DOMAIN_NAME => buffer[4] + 2, + _ => throw new ProxyProtocolException(ProxyErrorCode.SocksBadAddressType, "SOCKS server returned an unknown address type.") + }; + await stream.ReadExactlyAsync(buffer.AsMemory(0, bytesToSkip), cancellationToken).ConfigureAwait(false); + // response address not used + } + finally + { + // The request held the username and password (SOCKS5) or the user id (SOCKS4). + ArrayPool.Shared.Return(buffer, clearArray: credentials is not null); + } + } + + internal static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool isVersion4a, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(BufferSize); + + try + { + // https://www.openssh.com/txt/socks4.protocol + + // +----+----+----+----+----+----+----+----+----+----+....+----+ + // | VN | CD | DSTPORT | DSTIP | USERID |NULL| + // +----+----+----+----+----+----+----+----+----+----+....+----+ + // 1 1 2 4 variable 1 + buffer[0] = ProtocolVersion4; + buffer[1] = CMD_CONNECT; + + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(2), (ushort)port); + + IPAddress? ipv4Address = null; + if (IPAddress.TryParse(host, out var hostIP)) + { + if (hostIP.AddressFamily == AddressFamily.InterNetwork) + ipv4Address = hostIP; + else if (hostIP.IsIPv4MappedToIPv6) + ipv4Address = hostIP.MapToIPv4(); + else + throw new ProxyProtocolException(ProxyErrorCode.SocksIPv6NotSupported, "SOCKS4 does not support IPv6 addresses."); + } + else if (!isVersion4a) + { + // Socks4 does not support domain names - try to resolve it here + IPAddress[] addresses; + try + { + addresses = + await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address.", ex); + } + + if (addresses.Length == 0) + throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address."); + + ipv4Address = addresses[0]; + } + + if (ipv4Address is null) + { + Debug.Assert(isVersion4a); + buffer[4] = 0; + buffer[5] = 0; + buffer[6] = 0; + buffer[7] = 255; + } + else + { + ipv4Address.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); + Debug.Assert(bytesWritten == 4); + } + + var usernameLength = EncodeString(credentials?.UserName, buffer.AsSpan(8), nameof(credentials.UserName)); + buffer[8 + usernameLength] = 0; + var totalLength = 9 + usernameLength; + + if (ipv4Address is null) + { + // https://www.openssh.com/txt/socks4a.protocol + var hostLength = EncodeString(host, buffer.AsSpan(totalLength), nameof(host)); + buffer[totalLength + hostLength] = 0; + totalLength += hostLength + 1; + } + + await stream.WriteAsync(buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); + + // +----+----+----+----+----+----+----+----+ + // | VN | CD | DSTPORT | DSTIP | + // +----+----+----+----+----+----+----+----+ + // 1 1 2 4 + + + await stream.ReadExactlyAsync(buffer.AsMemory(0, 8), cancellationToken).ConfigureAwait(false); + + if (buffer[0] != 0) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS4 reply version. Expected 0, got {buffer[0]}."); + + switch (buffer[1]) + { + case Socks4_Success: + // Nothing to do + break; + case Socks4_AuthFailed: + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); + default: + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); + } + // response address not used + } + finally + { + // The request held the username and password (SOCKS5) or the user id (SOCKS4). + ArrayPool.Shared.Return(buffer, clearArray: credentials is not null); + } + } + + private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName) + { + // The length goes out as a single byte, so the write is capped at 255 whatever room the + // rented buffer has. ArrayPool rounds 513 up to 1024, and a string that fit the buffer but + // not the length byte used to escape ConnectAsync as an OverflowException from the cast. + if (!Encoding.UTF8.TryGetBytes(chars, buffer[..Math.Min(buffer.Length, 255)], out int written)) + throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, + $"Encoding the {parameterName} took more than the maximum of 255 bytes"); + + return (byte)written; + } + + private static void VerifyProtocolVersion(byte expected, byte version) + { + if (expected != version) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS protocol version. Required {expected}, got {version}."); + } + + +} \ No newline at end of file From 09ba5618190aabaa1ca4a234230f94d7f49a5b12 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:34:46 +0500 Subject: [PATCH 09/35] fix(http): clear the pooled buffers that held credentials The scratch buffer holding user:password and the CONNECT request holding its base64 both went back to ArrayPool.Shared as they were, against the repo's own rule for credential buffers. Not unit-tested: whether the next Rent hands back the same array is a pool implementation detail, so such a test could pass without the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet/Internal/HttpHelper.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index ed931ef..ce08a6b 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -73,7 +73,7 @@ private static (byte[] buffer, int length) BuildConnectionCommand( } finally { - ArrayPool.Shared.Return(credBuf); + ArrayPool.Shared.Return(credBuf, clearArray: true); } S_crlf.CopyTo(buf.AsSpan(pos)); pos += 2; @@ -106,7 +106,8 @@ internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, } finally { - ArrayPool.Shared.Return(cmd); + // With credentials, the request carries base64(user:password). + ArrayPool.Shared.Return(cmd, clearArray: credentials is not null); } var parser = new HttpResponseParser(); From 11c1f8170917c5eb3870f79bfd0c10021f08cee1 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:34:46 +0500 Subject: [PATCH 10/35] fix: stop leaving tunnel bytes in pooled arrays on synchronous span I/O RealityTlsStream and WebSocketStream did not override Read(Span) and Write(ReadOnlySpan), so Stream's fallback ran: it rents an array, reads or writes through it and returns it to the shared pool uncleared, leaving decrypted application data, or under WebSocket the tunnel bytes including a VLESS id, for the next renter to read. RealityTlsStream now copies a record already in hand straight into the span and only goes through the async path to wait for the next record; its write copies into a rented array and clears it. WebSocketStream keeps the rented array but clears it. ReadByte and WriteByte on RealityTlsStream no longer allocate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/TlsRecordStreamTest.cs | 35 +++++++++++ .../Internal/Reality/RealityTlsStream.cs | 58 +++++++++++++++++-- .../Internal/Transports/WebSocketStream.cs | 32 ++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index 066e44f..d9a7780 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -393,6 +393,41 @@ public async Task AfterHandshake_EmptyRecordFlood_EndsTheReadInAnError() Assert.Contains("no application data", ex.Message); } + /// + /// The synchronous span overloads are overridden rather than inherited — Stream's fallback + /// leaves decrypted data in an uncleared pooled array — so they must carry the same bytes. + /// + [Fact] + public void AfterHandshake_SyncSpanWriteAndRead_RoundTripThroughTheRecordLayer() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + + var wire = new MemoryStream(); + var writerRecords = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }; + using (var writer = new RealityTlsStream(wire, writerRecords, [])) + { + writer.Write("hel"u8); + writer.WriteByte((byte)'l'); + writer.Write("o, world"u8); + } + + var transport = new MemoryStream(wire.ToArray()); + var readerRecords = new TlsRecordStream(transport) { Read = new TlsRecordProtection(suite, secret) }; + using var reader = new RealityTlsStream(transport, readerRecords, []); + + Assert.Equal((int)'h', reader.ReadByte()); + + var received = new MemoryStream(); + Span chunk = stackalloc byte[3]; + int read; + while ((read = reader.Read(chunk)) > 0) + received.Write(chunk[..read]); + + Assert.Equal("ello, world", System.Text.Encoding.ASCII.GetString(received.ToArray())); + Assert.Equal(-1, reader.ReadByte()); + } + /// A tampered record does not open. [Fact] public async Task TamperedRecord_FailsItsTagCheck() diff --git a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs index adc3396..36ef05b 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -78,7 +79,7 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken return new ValueTask(0); if (!_pending.IsEmpty) - return new ValueTask(TakePending(buffer)); + return new ValueTask(TakePending(buffer.Span)); if (_receivedCloseNotify) return new ValueTask(0); @@ -98,14 +99,14 @@ private async ValueTask ReadFromRecordsAsync(Memory buffer, Cancellat return 0; } - return TakePending(buffer); + return TakePending(buffer.Span); } /// Copies out of the record in hand and advances past what was taken. - private int TakePending(Memory buffer) + private int TakePending(Span buffer) { int count = Math.Min(buffer.Length, _pending.Length); - _pending.Span[..count].CopyTo(buffer.Span); + _pending.Span[..count].CopyTo(buffer); _pending = _pending[count..]; return count; @@ -219,12 +220,57 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - public override int Read(byte[] buffer, int offset, int count) => - ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + /// Overridden rather than inherited: 's own span overloads rent an array, + /// go through it and hand it back to the shared pool uncleared, which would leave decrypted + /// application data in memory the next renter reads. A record already in hand is copied + /// straight out; only waiting for the next one goes through the async path. + /// + public override int Read(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (buffer.IsEmpty) + return 0; + + if (_pending.IsEmpty && + (_receivedCloseNotify || !FillAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult())) + return 0; + + return TakePending(buffer); + } + + public override int ReadByte() + { + Span one = stackalloc byte[1]; + return Read(one) == 1 ? one[0] : -1; + } public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + /// + /// The record layer takes memory, not a span, so the data is copied into a rented array — + /// which, unlike in 's own fallback, is cleared before it goes back. + /// + public override void Write(ReadOnlySpan buffer) + { + byte[] rented = ArrayPool.Shared.Rent(buffer.Length); + try + { + buffer.CopyTo(rented); + Write(rented, 0, buffer.Length); + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + public override void WriteByte(byte value) => Write(new ReadOnlySpan(in value)); + public override void Flush() => _transport.Flush(); public override Task FlushAsync(CancellationToken cancellationToken) => _transport.FlushAsync(cancellationToken); diff --git a/QuickProxyNet/Internal/Transports/WebSocketStream.cs b/QuickProxyNet/Internal/Transports/WebSocketStream.cs index f8f3be8..d644e44 100644 --- a/QuickProxyNet/Internal/Transports/WebSocketStream.cs +++ b/QuickProxyNet/Internal/Transports/WebSocketStream.cs @@ -133,6 +133,38 @@ public override Task WriteAsync( byte[] buffer, int offset, int count, CancellationToken cancellationToken) => WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + // Stream's own span overloads rent an array, go through it and hand it back to the shared pool + // uncleared, which would leave tunnel bytes — a VLESS id among them — in memory the next + // renter reads. These take the same route but clear the array on the way back. + public override int Read(Span buffer) + { + byte[] rented = System.Buffers.ArrayPool.Shared.Rent(buffer.Length); + try + { + int read = Read(rented, 0, buffer.Length); + rented.AsSpan(0, read).CopyTo(buffer); + return read; + } + finally + { + System.Buffers.ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + public override void Write(ReadOnlySpan buffer) + { + byte[] rented = System.Buffers.ArrayPool.Shared.Rent(buffer.Length); + try + { + buffer.CopyTo(rented); + Write(rented, 0, buffer.Length); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(rented, clearArray: true); + } + } + protected override void Dispose(bool disposing) { if (disposing) From 5065bec7aa1e5938eebe590fbdf3aa59a59e455d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:34:46 +0500 Subject: [PATCH 11/35] fix(vmess): an '@' in the remark no longer rejects a base64 link The grammar check looked for '@' anywhere in the payload, including the '#remark' producers append after the base64, so "#@channel" or "#Node @ Telegram" sent the link to the URI parser and it failed as "not a well-formed URI". The '@' now only counts before the fragment. Also, "ps":"" no longer hides a remark given in the fragment. An empty ps with no fragment still gives an empty remark. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/VmessClientTest.cs | 21 +++++++++++++++++++++ QuickProxyNet/Configs/VmessShareLink.cs | 25 ++++++++++++++++--------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index 01148a7..cc328e3 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -340,6 +340,27 @@ public void TryParse_EmptyFragmentAfterBase64_IsIgnored() Assert.Null(o.Remark); } + // Telegram-style "@channel" tags are common in remarks. An '@' selects the URI grammar only + // before the fragment; after it, it is part of the name, and it used to reject the link. + [Theory] + [InlineData("#@channel", "@channel")] + [InlineData("#Node @ Telegram", "Node @ Telegram")] + [InlineData("#Node%20%40%20Telegram", "Node @ Telegram")] + public void TryParse_AtSignInFragmentAfterBase64_IsPartOfTheRemark(string fragment, string remark) + { + Assert.True(VmessShareLink.TryParse(Link(MinimalJson()) + fragment, out var o)); + Assert.Equal(ProxyHost, o.Host); + Assert.Equal(remark, o.Remark); + } + + [Fact] + public void TryParse_EmptyJsonPs_FallsBackToTheFragment() + { + string json = MinimalJson(extra: ",\"ps\":\"\""); + Assert.True(VmessShareLink.TryParse(Link(json) + "#from fragment", out var o)); + Assert.Equal("from fragment", o.Remark); + } + // ===================== grammar 2: the standard URI form ===================== // // vmess://{uuid}@{host}:{port}?{query}#{remark} — 48 links in the corpus. The query diff --git a/QuickProxyNet/Configs/VmessShareLink.cs b/QuickProxyNet/Configs/VmessShareLink.cs index 1769e93..427fc55 100644 --- a/QuickProxyNet/Configs/VmessShareLink.cs +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -28,8 +28,9 @@ namespace QuickProxyNet; /// /// /// -/// A payload containing @ selects the second grammar; that character occurs in -/// neither base64 alphabet, so the choice is unambiguous. +/// A payload containing @ before any # selects the second grammar; that +/// character occurs in neither base64 alphabet, so the choice is unambiguous. After the +/// # it is part of the remark. /// /// /// Recognized JSON fields: add, port, id, aid/alterId, @@ -98,16 +99,17 @@ private static bool TryParse( return false; } - // Two grammars exist in the wild. Neither base64 alphabet contains '@', so its - // presence unambiguously means the standard URI form. - if (payload.IndexOf('@') >= 0) + // Two grammars exist in the wild. Neither base64 alphabet contains '@', so its presence + // before any '#fragment' unambiguously means the standard URI form. Only before it: a + // remark after the base64 is free text, and "@channel" tags there are common. + int hash = payload.IndexOf('#'); + if ((hash >= 0 ? payload[..hash] : payload).IndexOf('@') >= 0) return TryParseStandardUri(link.ToString(), out options, out error); // v2rayN base64-JSON. Producers routinely append the remark as a '#fragment' // *after* the base64, which then fails to decode. '#' is not in either alphabet // either, so everything from it onwards is the remark, not payload. string? fragmentRemark = null; - int hash = payload.IndexOf('#'); if (hash >= 0) { ReadOnlySpan fragment = payload[(hash + 1)..]; @@ -607,6 +609,13 @@ private static bool TryParseJson( if (string.IsNullOrEmpty(sni)) sni = host; + // 'ps' is authoritative; the '#fragment' form is the fallback for producers that + // append the remark after the base64 instead of putting it in the JSON. An empty 'ps' + // counts as absent: those same producers write "ps":"" and put the name in the fragment. + string? remark = GetString(psField); + if (string.IsNullOrEmpty(remark) && fragmentRemark is not null) + remark = fragmentRemark; + options = new VmessOptions { Id = id, @@ -621,9 +630,7 @@ private static bool TryParseJson( Path = GetString(pathField), HostHeader = GetString(hostField), AllowInsecure = GetBoolean(allowInsecureField) || GetBoolean(skipCertVerifyField), - // 'ps' is authoritative; the '#fragment' form is the fallback for producers - // that append the remark after the base64 instead of putting it in the JSON. - Remark = GetString(psField) ?? fragmentRemark + Remark = remark }; error = null; return true; From 2c406cf8d3427e7302657b8626388a5975668b9c Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:34:46 +0500 Subject: [PATCH 12/35] fix: decode escaped credentials in a classic proxy link ParseCredentials split Uri.UserInfo as it was, still percent-encoded, so socks5://user:p%40ss@host sent "p%40ss" to the proxy. A password with ':', '@' or '/' can only be written into a link escaped, which made exactly those unusable. Each half is now unescaped after the split. The client constructor composed ProxyUri from the raw credentials, so a password with '@', '#', '/' or '?' made it throw UriFormatException, including from Proxy.Create(type, host, port, credentials). They are escaped now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/ProxyFactoryTest.cs | 29 +++++++++++++++++++++++++ QuickProxyNet/Clients/ProxyClient.cs | 5 ++++- QuickProxyNet/Proxy.cs | 9 +++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index f395244..fc12638 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -88,6 +88,35 @@ public void Create_ClassicScheme_KeepsCredentials() Assert.Equal("pass", client.ProxyCredentials?.Password); } + // A ':', '@' or '/' in a password can only be written into a link escaped. The escaped text + // used to go to the proxy as the password, so exactly those passwords failed to authenticate. + [Theory] + [InlineData("socks5://us%40er:p%3Ass@127.0.0.1:1080", "us@er", "p:ss")] + [InlineData("http://user:p%40ss%2Fw%23rd@127.0.0.1:8080", "user", "p@ss/w#rd")] + [InlineData("socks5://user:100%25@127.0.0.1:1080", "user", "100%")] + public void Create_ClassicScheme_DecodesEscapedCredentials(string link, string user, string password) + { + var client = Create(link); + + Assert.Equal(user, client.ProxyCredentials?.UserName); + Assert.Equal(password, client.ProxyCredentials?.Password); + } + + // These used to throw UriFormatException from the client constructor, which composed the + // credentials into ProxyUri unescaped. + [Theory] + [InlineData("p@ss")] + [InlineData("p#ss")] + [InlineData("p/ss")] + [InlineData("p?ss")] + [InlineData("p:ss")] + public void Create_ExplicitCredentials_WithUriReservedCharacters_AreKept(string password) + { + var client = Proxy.Create(ProxyType.Socks5, "127.0.0.1", 1080, new NetworkCredential("user", password)); + + Assert.Equal(password, client.ProxyCredentials?.Password); + } + [Fact] public void Create_IsCaseInsensitiveAndIgnoresSurroundingWhitespace() { diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 0e3be31..aa7a87a 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -60,7 +60,10 @@ protected ProxyClient(string protocol, string host, int port, NetworkCredential ProxyHost = host; ProxyPort = port == 0 ? 1080 : port; - ProxyUri = new Uri($"{protocol}://{credentials.UserName}:{credentials.Password}@{FormatUriHost(host)}:{port}"); + // Escaped: unescaped, a password with '@', '#', '/' or '?' made this constructor throw + // UriFormatException, and one with ':' split in the wrong place when read back. + ProxyUri = new Uri( + $"{protocol}://{Uri.EscapeDataString(credentials.UserName)}:{Uri.EscapeDataString(credentials.Password)}@{FormatUriHost(host)}:{port}"); ProxyCredentials = credentials; } diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index 1f806b2..a3d00e1 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -458,13 +458,16 @@ private static async ValueTask ConnectCoreAsync(Uri proxyUri, string hos if (string.IsNullOrEmpty(proxyUri.UserInfo)) return null; + // Uri.UserInfo is still percent-encoded. A ':', '@' or '/' in a password can only be + // written into a link escaped, so passing the escaped text on failed authentication for + // exactly those passwords. Split first, so an escaped ':' stays inside its half. var sep = proxyUri.UserInfo.IndexOf(':'); if (sep < 0) - return new NetworkCredential(proxyUri.UserInfo, string.Empty); + return new NetworkCredential(Uri.UnescapeDataString(proxyUri.UserInfo), string.Empty); return new NetworkCredential( - proxyUri.UserInfo.Substring(0, sep), - proxyUri.UserInfo.Substring(sep + 1)); + Uri.UnescapeDataString(proxyUri.UserInfo.Substring(0, sep)), + Uri.UnescapeDataString(proxyUri.UserInfo.Substring(sep + 1))); } } From 539ac050b8fd71502a3a59edab2fb8767c0b2ef5 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:40:35 +0500 Subject: [PATCH 13/35] feat!: take Uri out of the public API BREAKING CHANGE, for 5.0.0. Removed: - IProxyClient.ProxyUri and ProxyClient.ProxyUri - Proxy.ConnectAsync(Uri, string, int, ...) with and without a timeout - Proxy.ConnectAsync(Uri, Stream, string, int, ...) - ProxyUriExtensions, with both ConnectThroughProxyAsync overloads A Uri cannot hold most vmess:// links or legacy ss:// ones. For vless, trojan, vmess and ss it kept only scheme://host:port, dropping the uuid, sni and transport. It could not hold a password with '@' at all, and anything that logged ProxyUri logged the password in clear. Instead: - Proxy.ConnectAsync(string link, ...) and Proxy.Create(string) take every scheme. - Proxy.Create(Uri) stays, as an adapter for WebProxy.Address and IWebProxy.GetProxy. - ProxyClient.ToString() is scheme://host:port, without credentials, for logs. - ProxyHost is unbracketed for IPv6 however the host arrived; a Uri authority used to give "[::1]" where a share link gave "::1". Removing the Uri fast path also removes three defects that lived only there: its https branch authenticated TLS directly instead of through TlsHandshake, so a certificate failure escaped as AuthenticationException; it rethrew raw IOException and SocketException from the handshake; and it carried a third copy of the connect-timeout logic. ProxyConnector now dispatches on ProxyType rather than scheme strings, and HttpHelper no longer takes the Uri it never used. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- AGENTS.md | 6 +- QuickProxyNet.Tests/ConnectTest.cs | 13 +- QuickProxyNet.Tests/DualStackConnectTest.cs | 4 +- QuickProxyNet.Tests/EndPointConnectTest.cs | 1 - QuickProxyNet.Tests/HttpHelperTest.cs | 18 +- QuickProxyNet.Tests/PrefixedStreamTest.cs | 2 +- QuickProxyNet.Tests/ProxyFactoryTest.cs | 35 +- .../ShadowsocksShareLinkTest.cs | 2 +- QuickProxyNet.Tests/TrojanTest.cs | 2 +- QuickProxyNet.Tests/VlessTest.cs | 2 +- QuickProxyNet.Tests/VmessClientTest.cs | 2 +- QuickProxyNet/Clients/HttpProxyClient.cs | 54 ++- QuickProxyNet/Clients/HttpsProxyClient.cs | 2 +- QuickProxyNet/Clients/ProxyClient.cs | 67 +--- QuickProxyNet/Clients/Socks4Client.cs | 58 ++-- QuickProxyNet/Clients/Socks4aClient.cs | 62 ++-- QuickProxyNet/Clients/Socks5Client.cs | 58 ++-- QuickProxyNet/IProxyClient.cs | 326 +++++++++--------- QuickProxyNet/Internal/HttpHelper.cs | 4 +- QuickProxyNet/Proxy.cs | 177 +--------- QuickProxyNet/ProxyConnector.cs | 98 ++---- QuickProxyNet/README.md | 7 - README.md | 7 - 23 files changed, 392 insertions(+), 615 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96b8089..1cafd74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,11 @@ All public library types live in the `QuickProxyNet` namespace. - `Proxy` is the static entry point: one-call `ConnectAsync(...)` helpers, plus `Create(...)` / `TryCreate(...)` building a client from a share-link `string`, from a `Uri`, or from explicit proxy settings. -- `ProxyUriExtensions` adds `Uri.ConnectThroughProxyAsync(...)`. +- There are no `Uri` connect overloads and no `IProxyClient.ProxyUri` (removed in + 5.0.0). A `Uri` cannot hold most `vmess://` links, keeps nothing but host and port + for the other VPN-style families, and could not even hold a password containing + `@`. `Proxy.Create(Uri)` stays as an adapter for `WebProxy.Address` and + `IWebProxy.GetProxy`; `ProxyClient.ToString()` is `scheme://host:port` for logs. - `IProxyClient` is the client contract; connection methods return `ValueTask`. `SourceLink` carries the text the client was built from. A target is `host, port` or an `EndPoint` (`DnsEndPoint` / `IPEndPoint`); the diff --git a/QuickProxyNet.Tests/ConnectTest.cs b/QuickProxyNet.Tests/ConnectTest.cs index 039b89a..26fc7c4 100644 --- a/QuickProxyNet.Tests/ConnectTest.cs +++ b/QuickProxyNet.Tests/ConnectTest.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; namespace QuickProxyNet.Tests; @@ -19,8 +20,7 @@ public class ConnectTest [EnvFact("HTTP_PROXY_URI")] public async Task HttpProxy_ConnectAndSendRequest() { - var uri = new Uri(Env("HTTP_PROXY_URI")); - await using var stream = await Proxy.ConnectAsync(uri, TargetHost, TargetPort, + await using var stream = await Proxy.ConnectAsync(Env("HTTP_PROXY_URI"), TargetHost, TargetPort, TimeSpan.FromSeconds(10)); // Send a minimal HTTP GET and verify we get a response @@ -38,8 +38,7 @@ public async Task HttpProxy_ConnectAndSendRequest() [EnvFact("SOCKS5_PROXY_URI")] public async Task Socks5Proxy_ConnectAndSendRequest() { - var uri = new Uri(Env("SOCKS5_PROXY_URI")); - await using var stream = await Proxy.ConnectAsync(uri, TargetHost, TargetPort, + await using var stream = await Proxy.ConnectAsync(Env("SOCKS5_PROXY_URI"), TargetHost, TargetPort, TimeSpan.FromSeconds(10)); var request = Encoding.UTF8.GetBytes($"GET / HTTP/1.1\r\nHost: {TargetHost}\r\nConnection: close\r\n\r\n"); @@ -54,12 +53,12 @@ public async Task Socks5Proxy_ConnectAndSendRequest() } [AnyEnvFact("HTTP_PROXY_URI", "SOCKS5_PROXY_URI")] - public async Task ExtensionMethod_ConnectThroughProxy() + public async Task Client_ConnectsToADnsEndPoint() { var proxyUrl = SkipGates.IsSet("HTTP_PROXY_URI") ? Env("HTTP_PROXY_URI") : Env("SOCKS5_PROXY_URI"); - var uri = new Uri(proxyUrl); - await using var stream = await uri.ConnectThroughProxyAsync(TargetHost, TargetPort, + IProxyClient client = Proxy.Create(proxyUrl); + await using var stream = await client.ConnectAsync(new DnsEndPoint(TargetHost, TargetPort), TimeSpan.FromSeconds(10)); var request = Encoding.UTF8.GetBytes($"GET / HTTP/1.1\r\nHost: {TargetHost}\r\nConnection: close\r\n\r\n"); diff --git a/QuickProxyNet.Tests/DualStackConnectTest.cs b/QuickProxyNet.Tests/DualStackConnectTest.cs index 86e54c6..da4444e 100644 --- a/QuickProxyNet.Tests/DualStackConnectTest.cs +++ b/QuickProxyNet.Tests/DualStackConnectTest.cs @@ -37,12 +37,12 @@ public async Task Client_WithTimeout_ReachesProxyOnIPv6() } [IPv6LoopbackFact] - public async Task UriFastPath_ReachesProxyOnIPv6() + public async Task Link_WithBracketedIPv6_ReachesProxyOnIPv6() { using var proxy = new LoopbackConnectProxy(IPAddress.IPv6Loopback); await using Stream stream = await Proxy.ConnectAsync( - new Uri($"http://[::1]:{proxy.Port}"), "example.com", 80, Deadline); + $"http://[::1]:{proxy.Port}", "example.com", 80, Deadline); Assert.StartsWith("CONNECT example.com:80 HTTP/1.1\r\n", await proxy.Request); } diff --git a/QuickProxyNet.Tests/EndPointConnectTest.cs b/QuickProxyNet.Tests/EndPointConnectTest.cs index 2339692..8fc7d5f 100644 --- a/QuickProxyNet.Tests/EndPointConnectTest.cs +++ b/QuickProxyNet.Tests/EndPointConnectTest.cs @@ -104,7 +104,6 @@ private sealed class RecordingClient : IProxyClient { public (string Host, int Port, string Overload) Last { get; private set; } - public Uri ProxyUri { get; } = new("socks5://proxy.example:1080"); public NetworkCredential? ProxyCredentials => null; public string ProxyHost => "proxy.example"; public int ProxyPort => 1080; diff --git a/QuickProxyNet.Tests/HttpHelperTest.cs b/QuickProxyNet.Tests/HttpHelperTest.cs index f0cc051..eee9720 100644 --- a/QuickProxyNet.Tests/HttpHelperTest.cs +++ b/QuickProxyNet.Tests/HttpHelperTest.cs @@ -5,15 +5,13 @@ namespace QuickProxyNet.Tests; public class HttpHelperTest { - private static readonly Uri ProxyUri = new("http://proxy.example.com:8080"); - [Fact] public async Task EstablishTunnel_200_ReturnsStream() { var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); var stream = new FakeProxyStream(response); - var result = await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + var result = await HttpHelper.EstablishHttpTunnelAsync(stream,"example.com", 443, null, CancellationToken.None); // Should return the same stream (no overread) @@ -27,7 +25,7 @@ public async Task EstablishTunnel_200_WithOverread_ReturnsPrefixedStream() var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\nHELLO"); var stream = new FakeProxyStream(response); - var result = await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + var result = await HttpHelper.EstablishHttpTunnelAsync(stream,"example.com", 443, null, CancellationToken.None); // Should NOT be the same stream — it's a PrefixedStream wrapping the overread bytes @@ -48,7 +46,7 @@ public async Task EstablishTunnel_407_ThrowsAuthRequired() var stream = new FakeProxyStream(response); var ex = await Assert.ThrowsAsync( - () => HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + () => HttpHelper.EstablishHttpTunnelAsync(stream,"example.com", 443, null, CancellationToken.None).AsTask()); Assert.Equal(ProxyErrorCode.AuthRequired, ex.ErrorCode); @@ -61,7 +59,7 @@ public async Task EstablishTunnel_403_ThrowsConnectionFailed() var stream = new FakeProxyStream(response); var ex = await Assert.ThrowsAsync( - () => HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + () => HttpHelper.EstablishHttpTunnelAsync(stream,"example.com", 443, null, CancellationToken.None).AsTask()); Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); @@ -73,7 +71,7 @@ public async Task EstablishTunnel_SendsCorrectCommand_NoAuth() var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); var stream = new FakeProxyStream(response); - await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "target.com", 8080, null, + await HttpHelper.EstablishHttpTunnelAsync(stream,"target.com", 8080, null, CancellationToken.None); var sent = Encoding.UTF8.GetString(stream.WrittenBytes); @@ -95,7 +93,7 @@ public async Task EstablishTunnel_BracketsIPv6LiteralTarget(string host, string var response = Encoding.UTF8.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); var stream = new FakeProxyStream(response); - await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, host, 443, null, + await HttpHelper.EstablishHttpTunnelAsync(stream,host, 443, null, CancellationToken.None); var sent = Encoding.UTF8.GetString(stream.WrittenBytes); @@ -109,7 +107,7 @@ public async Task EstablishTunnel_SendsCorrectCommand_WithAuth() var stream = new FakeProxyStream(response); var creds = new System.Net.NetworkCredential("user", "pass"); - await HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "target.com", 443, creds, + await HttpHelper.EstablishHttpTunnelAsync(stream,"target.com", 443, creds, CancellationToken.None); var sent = Encoding.UTF8.GetString(stream.WrittenBytes); @@ -128,7 +126,7 @@ public async Task EstablishTunnel_ProxyClosed_ThrowsProxyProtocolException() var stream = new FakeProxyStream([]); var ex = await Assert.ThrowsAsync( - () => HttpHelper.EstablishHttpTunnelAsync(stream, ProxyUri, "example.com", 443, null, + () => HttpHelper.EstablishHttpTunnelAsync(stream,"example.com", 443, null, CancellationToken.None).AsTask()); Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); } diff --git a/QuickProxyNet.Tests/PrefixedStreamTest.cs b/QuickProxyNet.Tests/PrefixedStreamTest.cs index 9e70886..3d3b0f4 100644 --- a/QuickProxyNet.Tests/PrefixedStreamTest.cs +++ b/QuickProxyNet.Tests/PrefixedStreamTest.cs @@ -18,7 +18,7 @@ private static async Task CreatePrefixedStreamAsync(byte[] overreadBytes var fakeStream = new Helpers.FakeProxyStream(combined); var result = await HttpHelper.EstablishHttpTunnelAsync( - fakeStream, new Uri("http://proxy:8080"), "target", 443, null, CancellationToken.None); + fakeStream, "target", 443, null, CancellationToken.None); return result; } diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index fc12638..f7e8e40 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -103,7 +103,7 @@ public void Create_ClassicScheme_DecodesEscapedCredentials(string link, string u } // These used to throw UriFormatException from the client constructor, which composed the - // credentials into ProxyUri unescaped. + // credentials into a Uri unescaped. [Theory] [InlineData("p@ss")] [InlineData("p#ss")] @@ -173,7 +173,7 @@ public void Create_Shadowsocks_ReturnsAShadowsocksClient_AndKeepsTheLink() Assert.Equal("aes-256-gcm", client.Options.Method); Assert.Equal("password", client.Options.Password); Assert.Equal(link, client.SourceLink); - Assert.Equal("ss://example.com:8388/", client.ProxyUri.ToString()); + Assert.Equal("ss://example.com:8388", client.ToString()); } [Fact] @@ -374,7 +374,7 @@ public void TryCreate_NeverThrows_WhateverTheInput() } } - // --- SourceLink: the text a client came from, which ProxyUri cannot reconstruct. + // --- SourceLink: the text a client came from, which ToString() cannot reconstruct. [Fact] public void SourceLink_FromShareLink_KeepsTheWholeLink() @@ -385,10 +385,33 @@ public void SourceLink_FromShareLink_KeepsTheWholeLink() IProxyClient client = Proxy.Create(link); Assert.Equal(link, client.SourceLink); - // And the reason SourceLink has to exist: ProxyUri has dropped everything that makes the + // And the reason SourceLink has to exist: nothing else on the client keeps what makes the // node reachable — the uuid, the sni, the transport. - Assert.Equal("vless://example.com:443/", client.ProxyUri.ToString()); - Assert.DoesNotContain("cdn.example.com", client.ProxyUri.ToString()); + Assert.Equal("vless://example.com:443", client.ToString()); + Assert.DoesNotContain("cdn.example.com", client.ToString()); + } + + // --- ToString and ProxyHost, which took over from ProxyUri (removed in 5.0.0). + + [Fact] + public void ToString_NamesTheProxy_WithoutCredentials() + { + IProxyClient client = Proxy.Create("socks5://user:secret@proxy.example:1080"); + + Assert.Equal("socks5://proxy.example:1080", client.ToString()); + } + + [Theory] + [InlineData("socks5://[::1]:1080")] + [InlineData("http://[2001:db8::1]:8080")] + [InlineData("trojan://pw@[2001:db8::1]:443")] + public void ProxyHost_IsUnbracketed_WhateverTheLinkFamily(string link) + { + // A Uri authority hands the host over as "[::1]", a share-link parser as "::1". + IProxyClient client = Proxy.Create(link); + + Assert.DoesNotContain("[", client.ProxyHost); + Assert.Contains($"://[{client.ProxyHost}]:", client.ToString()); } [Fact] diff --git a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs index 205e768..cbb22a6 100644 --- a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs +++ b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs @@ -467,7 +467,7 @@ public void Client_IPv6Host_ConstructsWithoutThrowing() Assert.Equal("2001:db8::1", client.ProxyHost); Assert.Equal(8388, client.ProxyPort); Assert.Equal(ProxyType.Shadowsocks, client.Type); - Assert.Equal("ss", client.ProxyUri.Scheme); + Assert.Equal("ss://[2001:db8::1]:8388", client.ToString()); } [Fact] diff --git a/QuickProxyNet.Tests/TrojanTest.cs b/QuickProxyNet.Tests/TrojanTest.cs index 5540472..ec6894d 100644 --- a/QuickProxyNet.Tests/TrojanTest.cs +++ b/QuickProxyNet.Tests/TrojanTest.cs @@ -161,7 +161,7 @@ public void Client_EmptyPassword_ThrowsAtConstruction() [Fact] public void Client_IPv6Host_ConstructsWithoutThrowing() { - // The base ProxyClient ctor must bracket the IPv6 literal when composing ProxyUri. + // An IPv6 literal must pass through the base ProxyClient constructor and stay unbracketed. var client = new TrojanClient(TrojanShareLink.Parse("trojan://secret@[2001:db8::1]:443")); Assert.Equal("2001:db8::1", client.ProxyHost); Assert.Equal(443, client.ProxyPort); diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index 014ae24..096a2b7 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -319,7 +319,7 @@ public void Client_NullOptions_ThrowsArgumentNull() [Fact] public void Client_IPv6Host_ConstructsWithoutThrowing() { - // The base ProxyClient ctor must bracket the IPv6 literal when composing ProxyUri. + // An IPv6 literal must pass through the base ProxyClient constructor and stay unbracketed. var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@[2001:db8::1]:443?security=none")); Assert.Equal("2001:db8::1", client.ProxyHost); Assert.Equal(443, client.ProxyPort); diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index cc328e3..ceddc5e 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -688,7 +688,7 @@ public void Client_ExposesTypeAndOptions() Assert.Equal(ProxyHost, client.ProxyHost); Assert.Equal(ProxyPort, client.ProxyPort); Assert.Same(options, client.Options); - Assert.Equal("vmess", client.ProxyUri.Scheme); + Assert.Equal($"vmess://{ProxyHost}:{ProxyPort}", client.ToString()); } [Fact] diff --git a/QuickProxyNet/Clients/HttpProxyClient.cs b/QuickProxyNet/Clients/HttpProxyClient.cs index dbd0aac..e329162 100644 --- a/QuickProxyNet/Clients/HttpProxyClient.cs +++ b/QuickProxyNet/Clients/HttpProxyClient.cs @@ -1,28 +1,26 @@ -using System.Net; - -namespace QuickProxyNet; - -/// -/// Provides functionality for connecting to a server using an HTTP proxy. -/// Supports the HTTP CONNECT method for tunneling connections. -/// -public class HttpProxyClient : ProxyClient -{ - public HttpProxyClient(string host, int port) : base("http", host, port) - { - } - - public HttpProxyClient(string host, int port, NetworkCredential credentials) : base("http", host, port, credentials) - { - } - - public override ProxyType Type => ProxyType.Http; - - public override async ValueTask ConnectAsync(Stream stream, string host, int port, - CancellationToken cancellationToken = default) - { - var result = - await ProxyConnector.ConnectToProxyAsync(stream, ProxyUri, host, port, ProxyCredentials, cancellationToken); - return result; - } -} \ No newline at end of file +using System.Net; + +namespace QuickProxyNet; + +/// +/// Provides functionality for connecting to a server using an HTTP proxy. +/// Supports the HTTP CONNECT method for tunneling connections. +/// +public class HttpProxyClient : ProxyClient +{ + public HttpProxyClient(string host, int port) : base("http", host, port) + { + } + + public HttpProxyClient(string host, int port, NetworkCredential credentials) : base("http", host, port, credentials) + { + } + + public override ProxyType Type => ProxyType.Http; + + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); + } +} \ No newline at end of file diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index 8e6fdeb..b3e6518 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -67,7 +67,7 @@ await TlsHandshake.AuthenticateAsync( throw; } - return await HttpHelper.EstablishHttpTunnelAsync(ssl, ProxyUri, host, port, ProxyCredentials, + return await HttpHelper.EstablishHttpTunnelAsync(ssl, host, port, ProxyCredentials, cancellationToken); } } diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index aa7a87a..43e9c63 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -6,24 +6,7 @@ namespace QuickProxyNet; public abstract class ProxyClient : IProxyClient { - private ProxyClient(Uri uri) - { - ProxyUri = uri; - - ProxyHost = uri.Host; - ProxyPort = uri.Port; - - if (!string.IsNullOrWhiteSpace(uri.UserInfo)) - { - var sep = uri.UserInfo.IndexOf(':'); - if (sep < 0) - throw new ArgumentException("Invalid credentials format.", nameof(uri.UserInfo)); - - ProxyCredentials = new NetworkCredential( - uri.UserInfo.Substring(0, sep), - uri.UserInfo.Substring(sep + 1)); - } - } + private readonly string _scheme; protected ProxyClient(string protocol, string host, int port) { @@ -37,45 +20,29 @@ protected ProxyClient(string protocol, string host, int port) if (port < 0 || port > 65535) throw new ArgumentOutOfRangeException(nameof(port)); - ProxyHost = host; + _scheme = protocol; + // An IPv6 literal is kept unbracketed however it arrived: a Uri authority hands over + // "[::1]", a parsed share link "::1", and code comparing hosts should not see both. + ProxyHost = host.Length > 2 && host[0] == '[' && host[^1] == ']' ? host[1..^1] : host; ProxyPort = port == 0 ? 1080 : port; - ProxyUri = new Uri($"{protocol}://{FormatUriHost(host)}:{port}"); } protected ProxyClient(string protocol, string host, int port, NetworkCredential credentials) + : this(protocol, host, port) { - if (host == null) - throw new ArgumentNullException(nameof(host)); - - if (host.Length == 0 || host.Length > 255) - throw new ArgumentException("The length of the host name must be between 0 and 256 characters.", - nameof(host)); - - if (port < 0 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port)); - - if (credentials == null) - throw new ArgumentNullException(nameof(credentials)); - - ProxyHost = host; - ProxyPort = port == 0 ? 1080 : port; - - // Escaped: unescaped, a password with '@', '#', '/' or '?' made this constructor throw - // UriFormatException, and one with ':' split in the wrong place when read back. - ProxyUri = new Uri( - $"{protocol}://{Uri.EscapeDataString(credentials.UserName)}:{Uri.EscapeDataString(credentials.Password)}@{FormatUriHost(host)}:{port}"); - ProxyCredentials = credentials; + ProxyCredentials = credentials ?? throw new ArgumentNullException(nameof(credentials)); } - // An IPv6 literal must be bracketed in a URI ("[2001:db8::1]"), otherwise the Uri - // parser reads the address's colons as a port separator and throws. Host names and - // IPv4 literals never contain ':', so this only affects IPv6 endpoints. - // An IPv6 literal needs brackets inside a URI; one that already has them (a hand-built - // options object may carry "[::1]") must not get a second pair. - private static string FormatUriHost(string host) => - host.Contains(':') && !host.StartsWith('[') ? $"[{host}]" : host; - - public Uri ProxyUri { get; private set; } + /// + /// The proxy as scheme://host:port, for logs and diagnostics. + /// + /// + /// Never carries credentials, and for the share-link families none of what it takes to + /// connect either: the uuid, sni and transport are in . + /// + public override string ToString() => ProxyHost.Contains(':') + ? $"{_scheme}://[{ProxyHost}]:{ProxyPort}" + : $"{_scheme}://{ProxyHost}:{ProxyPort}"; /// /// Set by the factory methods when a link was the input. diff --git a/QuickProxyNet/Clients/Socks4Client.cs b/QuickProxyNet/Clients/Socks4Client.cs index 8be048f..edd1847 100644 --- a/QuickProxyNet/Clients/Socks4Client.cs +++ b/QuickProxyNet/Clients/Socks4Client.cs @@ -1,30 +1,28 @@ -using System.Net; - -namespace QuickProxyNet; - -/// -/// Provides functionality for connecting to a server using a SOCKS4 proxy. -/// Supports basic SOCKS4 proxy features, such as IP-based connections, -/// but does not support domain name resolution through the proxy. -/// -public class Socks4Client : ProxyClient -{ - public Socks4Client(string host, int port) : base("socks4", host, port) - { - } - - public Socks4Client(string host, int port, NetworkCredential credentials) : base("socks4", host, port, credentials) - { - } - - public override ProxyType Type => ProxyType.Socks4; - - - public override async ValueTask ConnectAsync(Stream stream, string host, int port, - CancellationToken cancellationToken = default) - { - var result = - await ProxyConnector.ConnectToProxyAsync(stream, ProxyUri, host, port, ProxyCredentials, cancellationToken); - return result; - } -} \ No newline at end of file +using System.Net; + +namespace QuickProxyNet; + +/// +/// Provides functionality for connecting to a server using a SOCKS4 proxy. +/// Supports basic SOCKS4 proxy features, such as IP-based connections, +/// but does not support domain name resolution through the proxy. +/// +public class Socks4Client : ProxyClient +{ + public Socks4Client(string host, int port) : base("socks4", host, port) + { + } + + public Socks4Client(string host, int port, NetworkCredential credentials) : base("socks4", host, port, credentials) + { + } + + public override ProxyType Type => ProxyType.Socks4; + + + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); + } +} \ No newline at end of file diff --git a/QuickProxyNet/Clients/Socks4aClient.cs b/QuickProxyNet/Clients/Socks4aClient.cs index 2f3f7d3..3511c46 100644 --- a/QuickProxyNet/Clients/Socks4aClient.cs +++ b/QuickProxyNet/Clients/Socks4aClient.cs @@ -1,32 +1,30 @@ -using System.Net; - -namespace QuickProxyNet; - -/// -/// Provides functionality for connecting to a server using a SOCKS4a proxy. -/// Extends SOCKS4 to allow domain name resolution through the proxy itself, -/// making it useful in networks where direct DNS resolution is restricted. -/// -public class Socks4aClient : ProxyClient -{ - public Socks4aClient(string host, int port) : base("socks4a", host, port) - { - } - - public Socks4aClient(string host, int port, NetworkCredential credentials) : base("socks4a", host, port, - credentials) - { - } - - - public override ProxyType Type => ProxyType.Socks4a; - - - public override async ValueTask ConnectAsync(Stream stream, string host, int port, - CancellationToken cancellationToken = default) - { - var result = - await ProxyConnector.ConnectToProxyAsync(stream, ProxyUri, host, port, ProxyCredentials, cancellationToken); - return result; - } -} \ No newline at end of file +using System.Net; + +namespace QuickProxyNet; + +/// +/// Provides functionality for connecting to a server using a SOCKS4a proxy. +/// Extends SOCKS4 to allow domain name resolution through the proxy itself, +/// making it useful in networks where direct DNS resolution is restricted. +/// +public class Socks4aClient : ProxyClient +{ + public Socks4aClient(string host, int port) : base("socks4a", host, port) + { + } + + public Socks4aClient(string host, int port, NetworkCredential credentials) : base("socks4a", host, port, + credentials) + { + } + + + public override ProxyType Type => ProxyType.Socks4a; + + + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); + } +} \ No newline at end of file diff --git a/QuickProxyNet/Clients/Socks5Client.cs b/QuickProxyNet/Clients/Socks5Client.cs index 787a5fd..ba31430 100644 --- a/QuickProxyNet/Clients/Socks5Client.cs +++ b/QuickProxyNet/Clients/Socks5Client.cs @@ -1,30 +1,28 @@ -using System.Net; - -namespace QuickProxyNet; - -/// -/// Provides functionality for connecting to a server using a SOCKS5 proxy. -/// Supports more advanced features than SOCKS4, including domain name resolution -/// through the proxy and authentication mechanisms if required. -/// -public class Socks5Client : ProxyClient -{ - public Socks5Client(string host, int port) : base("socks5", host, port) - { - } - - public Socks5Client(string host, int port, NetworkCredential credentials) : base("socks5", host, port, credentials) - { - } - - public override ProxyType Type => ProxyType.Socks5; - - - public override async ValueTask ConnectAsync(Stream stream, string host, int port, - CancellationToken cancellationToken = default) - { - var result = - await ProxyConnector.ConnectToProxyAsync(stream, ProxyUri, host, port, ProxyCredentials, cancellationToken); - return result; - } -} \ No newline at end of file +using System.Net; + +namespace QuickProxyNet; + +/// +/// Provides functionality for connecting to a server using a SOCKS5 proxy. +/// Supports more advanced features than SOCKS4, including domain name resolution +/// through the proxy and authentication mechanisms if required. +/// +public class Socks5Client : ProxyClient +{ + public Socks5Client(string host, int port) : base("socks5", host, port) + { + } + + public Socks5Client(string host, int port, NetworkCredential credentials) : base("socks5", host, port, credentials) + { + } + + public override ProxyType Type => ProxyType.Socks5; + + + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); + } +} \ No newline at end of file diff --git a/QuickProxyNet/IProxyClient.cs b/QuickProxyNet/IProxyClient.cs index 4ca39bc..21a6eea 100644 --- a/QuickProxyNet/IProxyClient.cs +++ b/QuickProxyNet/IProxyClient.cs @@ -1,163 +1,163 @@ -using System.Net; -using System.Net.Sockets; - -namespace QuickProxyNet; - - -/// -/// Represents a client for connecting through a proxy server. -/// -public interface IProxyClient -{ - Uri ProxyUri { get; } - - /// - /// The share link or URL this client was built from, or when it was - /// built from explicit settings. - /// - /// - /// cannot stand in for this. For VLESS, Trojan and VMess it is only - /// scheme://host:port — a node written out that way has lost its uuid, sni, flow and - /// transport, and cannot be connected to again. Code that checks a list of nodes and reports - /// the ones that worked needs the text that came in, not a normalised summary of it. - /// - string? SourceLink => null; - - /// - /// Gets the credentials used to authenticate with the proxy server, if required. - /// - NetworkCredential? ProxyCredentials { get; } - - /// - /// Gets the hostname or IP address of the proxy server. - /// - string ProxyHost { get; } - - /// - /// Gets the port number of the proxy server. - /// - int ProxyPort { get; } - - /// - /// Gets the type of the proxy, such as HTTP, SOCKS4, SOCKS4a, or SOCKS5. - /// - ProxyType Type { get; } - - /// - /// Gets or sets the local endpoint to use when establishing a connection. - /// If not set, the default endpoint will be used. - /// - IPEndPoint? LocalEndPoint { get; set; } - - /// - /// Gets or sets the linger state for the connection, determining how to handle lingering connections when closing. - /// - LingerOption? LingerState { get; set; } - - /// - /// Gets or sets a value that specifies whether to disable the Nagle algorithm for this connection. - /// If set to true, it reduces latency for small data transfers by sending packets immediately. - /// - bool NoDelay { get; set; } - - /// - /// Gets or sets the write timeout in milliseconds for sending data through the proxy. - /// - int WriteTimeout { get; set; } - - /// - /// Gets or sets the read timeout in milliseconds for receiving data through the proxy. - /// - int ReadTimeout { get; set; } - - /// - /// Asynchronously connects to a target host and port through the proxy. - /// - /// The target host to connect to. - /// The target port on the host. - /// A cancellation token that can be used to cancel the connection attempt. - /// A representing the asynchronous operation and yielding the connected . - ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default); - - /// - /// Asynchronously connects to a target host and port through the proxy, using an existing stream. - /// - /// The source to use for establishing the connection. - /// The target host to connect to. - /// The target port on the host. - /// A cancellation token that can be used to cancel the connection attempt. - /// A representing the asynchronous operation and yielding the connected . - ValueTask ConnectAsync(Stream source, string host, int port, CancellationToken cancellationToken = default); - - /// - /// Asynchronously connects to a target host and port through the proxy, with a specified timeout. - /// - /// The target host to connect to. - /// The target port on the host. - /// The maximum time, in milliseconds, to wait for a connection to the host. - /// A cancellation token that can be used to cancel the connection attempt. - /// A representing the asynchronous operation and yielding the connected . - ValueTask ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default); - - /// - /// Asynchronously connects to a target endpoint through the proxy. - /// - /// - /// A , whose name the proxy resolves and whose address family is - /// therefore not a constraint, or an . - /// - /// A cancellation token that can be used to cancel the connection attempt. - /// A representing the asynchronous operation and yielding the connected . - /// - /// is neither a nor an . - /// - /// - /// The shape has, and the one - /// SocketsHttpHandler.ConnectCallback hands over. An IPv4 address carried as IPv6 - /// (::ffff:a.b.c.d, as a dual-mode socket reports its peers) is sent as the IPv4 - /// address it is. - /// - async ValueTask ConnectAsync(EndPoint target, CancellationToken cancellationToken = default) - { - var (host, port) = ProxyClient.SplitTarget(target); - return await ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); - } - - /// - /// Asynchronously connects to a target endpoint through the proxy, with a specified timeout. - /// - /// - /// A or an , as for - /// . - /// - /// The maximum time to wait for the connection to complete. - /// A cancellation token that can be used to cancel the connection attempt. - /// A representing the asynchronous operation and yielding the connected . - /// - /// is neither a nor an . - /// - async ValueTask ConnectAsync(EndPoint target, TimeSpan timeout, CancellationToken cancellationToken = default) - { - var (host, port) = ProxyClient.SplitTarget(target); - return await ConnectAsync(host, port, timeout, cancellationToken).ConfigureAwait(false); - } - - /// - /// Asynchronously connects to a target endpoint through the proxy, using an existing stream. - /// - /// The source to use for establishing the connection. - /// - /// A or an , as for - /// . - /// - /// A cancellation token that can be used to cancel the connection attempt. - /// A representing the asynchronous operation and yielding the connected . - /// - /// is neither a nor an . - /// - async ValueTask ConnectAsync(Stream source, EndPoint target, CancellationToken cancellationToken = default) - { - var (host, port) = ProxyClient.SplitTarget(target); - return await ConnectAsync(source, host, port, cancellationToken).ConfigureAwait(false); - } -} \ No newline at end of file +using System.Net; +using System.Net.Sockets; + +namespace QuickProxyNet; + + +/// +/// Represents a client for connecting through a proxy server. +/// +public interface IProxyClient +{ + /// + /// The share link or URL this client was built from, or when it was + /// built from explicit settings. + /// + /// + /// Nothing else on the client can stand in for this. For VLESS, Trojan, VMess and Shadowsocks, + /// , and only say where to + /// connect: a node written out as scheme://host:port has lost its uuid, sni, flow and + /// transport, and cannot be connected to again. Code that checks a list of nodes and reports + /// the ones that worked needs the text that came in, not a normalised summary of it. + /// + string? SourceLink => null; + + /// + /// Gets the credentials used to authenticate with the proxy server, if required. + /// + NetworkCredential? ProxyCredentials { get; } + + /// + /// Gets the hostname or IP address of the proxy server. An IPv6 address is given without + /// brackets. + /// + string ProxyHost { get; } + + /// + /// Gets the port number of the proxy server. + /// + int ProxyPort { get; } + + /// + /// Gets the type of the proxy, such as HTTP, SOCKS4, SOCKS4a, or SOCKS5. + /// + ProxyType Type { get; } + + /// + /// Gets or sets the local endpoint to use when establishing a connection. + /// If not set, the default endpoint will be used. + /// + IPEndPoint? LocalEndPoint { get; set; } + + /// + /// Gets or sets the linger state for the connection, determining how to handle lingering connections when closing. + /// + LingerOption? LingerState { get; set; } + + /// + /// Gets or sets a value that specifies whether to disable the Nagle algorithm for this connection. + /// If set to true, it reduces latency for small data transfers by sending packets immediately. + /// + bool NoDelay { get; set; } + + /// + /// Gets or sets the write timeout in milliseconds for sending data through the proxy. + /// + int WriteTimeout { get; set; } + + /// + /// Gets or sets the read timeout in milliseconds for receiving data through the proxy. + /// + int ReadTimeout { get; set; } + + /// + /// Asynchronously connects to a target host and port through the proxy. + /// + /// The target host to connect to. + /// The target port on the host. + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default); + + /// + /// Asynchronously connects to a target host and port through the proxy, using an existing stream. + /// + /// The source to use for establishing the connection. + /// The target host to connect to. + /// The target port on the host. + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + ValueTask ConnectAsync(Stream source, string host, int port, CancellationToken cancellationToken = default); + + /// + /// Asynchronously connects to a target host and port through the proxy, with a specified timeout. + /// + /// The target host to connect to. + /// The target port on the host. + /// The maximum time, in milliseconds, to wait for a connection to the host. + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + ValueTask ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// Asynchronously connects to a target endpoint through the proxy. + /// + /// + /// A , whose name the proxy resolves and whose address family is + /// therefore not a constraint, or an . + /// + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + /// + /// is neither a nor an . + /// + /// + /// The shape has, and the one + /// SocketsHttpHandler.ConnectCallback hands over. An IPv4 address carried as IPv6 + /// (::ffff:a.b.c.d, as a dual-mode socket reports its peers) is sent as the IPv4 + /// address it is. + /// + async ValueTask ConnectAsync(EndPoint target, CancellationToken cancellationToken = default) + { + var (host, port) = ProxyClient.SplitTarget(target); + return await ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously connects to a target endpoint through the proxy, with a specified timeout. + /// + /// + /// A or an , as for + /// . + /// + /// The maximum time to wait for the connection to complete. + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + /// + /// is neither a nor an . + /// + async ValueTask ConnectAsync(EndPoint target, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var (host, port) = ProxyClient.SplitTarget(target); + return await ConnectAsync(host, port, timeout, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously connects to a target endpoint through the proxy, using an existing stream. + /// + /// The source to use for establishing the connection. + /// + /// A or an , as for + /// . + /// + /// A cancellation token that can be used to cancel the connection attempt. + /// A representing the asynchronous operation and yielding the connected . + /// + /// is neither a nor an . + /// + async ValueTask ConnectAsync(Stream source, EndPoint target, CancellationToken cancellationToken = default) + { + var (host, port) = ProxyClient.SplitTarget(target); + return await ConnectAsync(source, host, port, cancellationToken).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index ce08a6b..2e21af1 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -96,8 +96,8 @@ private static int WriteHost(string host, bool bracket, Span dest) return length + 2; } - internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, Uri proxyUri, string host, - int port, NetworkCredential? credentials, CancellationToken cancellationToken) + internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) { var (cmd, cmdLen) = BuildConnectionCommand(host, port, credentials); try diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index a3d00e1..b73d70a 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -1,19 +1,16 @@ using System.Diagnostics.CodeAnalysis; using System.Net; -using System.Net.Sockets; -using System.Runtime.CompilerServices; namespace QuickProxyNet; /// -/// Provides static convenience methods for connecting through a proxy in a single call. -/// No intermediate is allocated — the socket and tunnel -/// negotiation happen inline, making this ideal for mass proxy checking. +/// The static entry point: connect through a proxy in one call, or build a client from a share +/// link, a or explicit settings. /// /// /// /// await using var stream = await Proxy.ConnectAsync( -/// new Uri("socks5://user:pass@127.0.0.1:1080"), +/// "socks5://user:pass@127.0.0.1:1080", /// "example.com", 443); /// /// @@ -31,11 +28,8 @@ public static class Proxy /// A token to cancel the operation. /// A connected tunneled through the proxy. /// - /// The overloads below cover only the classic schemes, and deliberately so: - /// they skip the client object entirely, which is what makes them suitable for checking - /// proxies by the thousand. This one goes through instead, - /// because VLESS, Trojan, VMess and Shadowsocks need the parsed configuration to negotiate at all. When - /// you have a link and no reason to care which family it belongs to, use this. + /// The link is parsed on every call. To connect through the same proxy repeatedly, build the + /// client once with and reuse it. /// public static async ValueTask ConnectAsync(string proxyLink, string host, int port, CancellationToken cancellationToken = default) @@ -101,63 +95,6 @@ public static async ValueTask ConnectAsync(string proxyLink, EndPoint ta return await client.ConnectAsync(target, timeout, cancellationToken).ConfigureAwait(false); } - /// - /// Connects to a target host through the specified proxy. - /// Opens a socket, negotiates the tunnel, and returns the connected stream. - /// The caller owns the returned and must dispose it. - /// - /// - /// Proxy URI including scheme, host, port, and optional credentials. - /// Supported schemes: http, https, socks4, socks4a, socks5. - /// - /// The target host to connect to through the proxy. - /// The target port. - /// A token to cancel the operation. - /// A connected tunneled through the proxy. - public static ValueTask ConnectAsync(Uri proxyUri, string host, int port, - CancellationToken cancellationToken = default) - { - return ConnectCoreAsync(proxyUri, host, port, timeout: null, cancellationToken); - } - - /// - /// Connects to a target host through the specified proxy with a timeout. - /// - /// - /// Proxy URI including scheme, host, port, and optional credentials. - /// Supported schemes: http, https, socks4, socks4a, socks5. - /// - /// The target host to connect to through the proxy. - /// The target port. - /// Maximum time to wait for the connection to complete. - /// A token to cancel the operation. - /// A connected tunneled through the proxy. - public static ValueTask ConnectAsync(Uri proxyUri, string host, int port, - TimeSpan timeout, CancellationToken cancellationToken = default) - { - return ConnectCoreAsync(proxyUri, host, port, timeout, cancellationToken); - } - - /// - /// Negotiates a proxy tunnel over an existing stream (e.g. for proxy chaining). - /// No socket is created — the caller provides an already-connected stream to the proxy. - /// - /// - /// Proxy URI including scheme and optional credentials. - /// Supported schemes: http, https, socks4, socks4a, socks5. - /// - /// An already-connected stream to the proxy server. - /// The target host to connect to through the proxy. - /// The target port. - /// A token to cancel the operation. - /// A connected tunneled through the proxy. - public static ValueTask ConnectAsync(Uri proxyUri, Stream source, string host, int port, - CancellationToken cancellationToken = default) - { - var credentials = ParseCredentials(proxyUri); - return ProxyConnector.ConnectToProxyAsync(source, proxyUri, host, port, credentials, cancellationToken); - } - /// /// Creates an from a proxy URL or share link, whatever its scheme. /// @@ -377,7 +314,7 @@ private static IProxyClient CreateClassic(ProxyType type, string host, int port) "build them from a share link instead."; // Records the text a client was built from, so a caller holding only IProxyClient can report - // the node it checked. ProxyUri cannot stand in: for the share-link families it is only + // the node it checked. ToString() cannot stand in: for the share-link families it is only // scheme://host:port, and writing a vless node out that way drops its uuid, sni and transport. private static IProxyClient Tag(IProxyClient client, string link) { @@ -386,73 +323,6 @@ private static IProxyClient Tag(IProxyClient client, string link) return client; } - private static async ValueTask ConnectCoreAsync(Uri proxyUri, string host, int port, - TimeSpan? timeout, CancellationToken cancellationToken) - { - ProxyClient.ValidateArguments(host, port); - cancellationToken.ThrowIfCancellationRequested(); - - var credentials = ParseCredentials(proxyUri); - - // Dual-mode, as in ProxyClient.CreateSocket: an IPv4-only socket cannot reach a proxy at an - // IPv6 address. - var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) - { - NoDelay = true, - LingerState = new LingerOption(true, 0) - }; - - ITimer? timer = null; - StrongBox? timedOut = null; - if (timeout.HasValue) - { - timedOut = new StrongBox(false); - timer = TimeProvider.System.CreateTimer( - static s => - { - var state = (Tuple>)s!; - Volatile.Write(ref state.Item2.Value, true); - state.Item1.Dispose(); - }, - Tuple.Create(socket, timedOut), timeout.Value, Timeout.InfiniteTimeSpan); - } - - try - { - await socket.ConnectAsync(proxyUri.Host, proxyUri.Port, cancellationToken); - } - catch (Exception ex) - { - if (timer is not null) await timer.DisposeAsync(); - socket.Dispose(); - if (timedOut is not null && Volatile.Read(ref timedOut.Value)) - throw new ProxyProtocolException(ProxyErrorCode.Timeout, - $"Connection to proxy {proxyUri.Host}:{proxyUri.Port} timed out after {timeout!.Value}.", ex); - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"Failed to connect to proxy {proxyUri.Host}:{proxyUri.Port} for target {host}:{port}.", ex); - } - - var stream = new NetworkStream(socket, ownsSocket: true); - try - { - var result = await ProxyConnector.ConnectToProxyAsync(stream, proxyUri, host, port, credentials, - cancellationToken); - // Dispose timer before returning to prevent race where timer fires - // and destroys the socket after we hand the stream to the caller. - if (timer is not null) await timer.DisposeAsync(); - return result; - } - catch (Exception ex) - { - if (timer is not null) await timer.DisposeAsync(); - await stream.DisposeAsync(); - if (timedOut is not null && Volatile.Read(ref timedOut.Value)) - throw new ProxyProtocolException(ProxyErrorCode.Timeout, - $"Connection to proxy {proxyUri.Host}:{proxyUri.Port} timed out after {timeout!.Value}.", ex); - throw; - } - } - private static NetworkCredential? ParseCredentials(Uri proxyUri) { if (string.IsNullOrEmpty(proxyUri.UserInfo)) @@ -470,38 +340,3 @@ private static async ValueTask ConnectCoreAsync(Uri proxyUri, string hos Uri.UnescapeDataString(proxyUri.UserInfo.Substring(sep + 1))); } } - -/// -/// Extension methods for connecting through proxies via . -/// -public static class ProxyUriExtensions -{ - /// - /// Connects to a target host through the proxy specified by this URI. - /// - /// The proxy URI (scheme://[user:pass@]host:port). - /// The target host. - /// The target port. - /// A token to cancel the operation. - /// A connected tunneled through the proxy. - public static ValueTask ConnectThroughProxyAsync(this Uri proxyUri, string host, int port, - CancellationToken cancellationToken = default) - { - return Proxy.ConnectAsync(proxyUri, host, port, cancellationToken); - } - - /// - /// Connects to a target host through the proxy specified by this URI, with a timeout. - /// - /// The proxy URI (scheme://[user:pass@]host:port). - /// The target host. - /// The target port. - /// Maximum time to wait for the connection. - /// A token to cancel the operation. - /// A connected tunneled through the proxy. - public static ValueTask ConnectThroughProxyAsync(this Uri proxyUri, string host, int port, - TimeSpan timeout, CancellationToken cancellationToken = default) - { - return Proxy.ConnectAsync(proxyUri, host, port, timeout, cancellationToken); - } -} diff --git a/QuickProxyNet/ProxyConnector.cs b/QuickProxyNet/ProxyConnector.cs index c4d314a..eb6094f 100644 --- a/QuickProxyNet/ProxyConnector.cs +++ b/QuickProxyNet/ProxyConnector.cs @@ -1,70 +1,50 @@ -using System.Net; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; +using System.Net; namespace QuickProxyNet; internal static class ProxyConnector { - public static async ValueTask ConnectToProxyAsync(Stream stream, Uri proxyUri, string host, int port, - NetworkCredential? proxyCredentials, CancellationToken cancellationToken) + /// + /// Negotiates a SOCKS or HTTP CONNECT tunnel over a stream already connected to the proxy, + /// disposing the stream when negotiation fails or is cancelled. + /// + /// + /// HTTPS is not negotiated here. Its TLS session is with the proxy and belongs to + /// , which runs it through so that a + /// certificate failure reaches the caller as a proxy error rather than as a raw + /// AuthenticationException. + /// + public static async ValueTask ConnectToProxyAsync(Stream stream, ProxyType type, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) { await using (cancellationToken.Register(static s => ((Stream)s!).Dispose(), stream)) { try { - var credentials = proxyCredentials?.GetCredential(proxyUri, proxyUri.Scheme); - - if (string.Equals(proxyUri.Scheme, "socks5", StringComparison.OrdinalIgnoreCase)) - { - await SocksHelper.EstablishSocks5TunnelAsync(stream, host, port, credentials, cancellationToken) - .ConfigureAwait(false); - return stream; - } - - if (string.Equals(proxyUri.Scheme, "socks4a", StringComparison.OrdinalIgnoreCase)) - { - await SocksHelper - .EstablishSocks4TunnelAsync(stream, true, host, port, credentials, cancellationToken) - .ConfigureAwait(false); - return stream; - } - - if (string.Equals(proxyUri.Scheme, "socks4", StringComparison.OrdinalIgnoreCase)) - { - await SocksHelper - .EstablishSocks4TunnelAsync(stream, false, host, port, credentials, cancellationToken) - .ConfigureAwait(false); - return stream; - } - - if (string.Equals(proxyUri.Scheme, "http", StringComparison.OrdinalIgnoreCase)) - { - var result = await HttpHelper.EstablishHttpTunnelAsync(stream, proxyUri, host, port, credentials, - cancellationToken); - return result; - } - - if (string.Equals(proxyUri.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + switch (type) { - var ssl = new SslStream(stream, false); - try - { - await ssl.AuthenticateAsClientAsync(DefaultSslOptions(proxyUri.Host), cancellationToken); - return await HttpHelper.EstablishHttpTunnelAsync(ssl, proxyUri, host, port, credentials, - cancellationToken); - } - catch - { - await ssl.DisposeAsync().ConfigureAwait(false); - // SslStream(leaveOpen:false) disposes inner stream, - // so skip the outer catch to avoid double-dispose. - throw; - } + case ProxyType.Socks5: + await SocksHelper.EstablishSocks5TunnelAsync(stream, host, port, credentials, cancellationToken) + .ConfigureAwait(false); + return stream; + + case ProxyType.Socks4: + case ProxyType.Socks4a: + await SocksHelper + .EstablishSocks4TunnelAsync(stream, type == ProxyType.Socks4a, host, port, credentials, + cancellationToken) + .ConfigureAwait(false); + return stream; + + case ProxyType.Http: + return await HttpHelper + .EstablishHttpTunnelAsync(stream, host, port, credentials, cancellationToken) + .ConfigureAwait(false); + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, + "Only SOCKS and plain HTTP tunnels are negotiated here."); } - - throw new NotSupportedException($"Unsupported proxy scheme: {proxyUri.Scheme}"); } catch { @@ -73,10 +53,4 @@ await SocksHelper } } } - - private static SslClientAuthenticationOptions DefaultSslOptions(string targetHost) => new() - { - EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, - TargetHost = targetHost - }; -} \ No newline at end of file +} diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index 46b3cd8..60e98bb 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -20,13 +20,6 @@ await using var stream = await Proxy.ConnectAsync( TimeSpan.FromSeconds(5)); ``` -Or via extension method: - -```csharp -await using var stream = await new Uri("http://proxy:8080") - .ConnectThroughProxyAsync("example.com", 443); -``` - ## Features - Zero runtime dependencies (BCL only) diff --git a/README.md b/README.md index 09fc7f4..a9f4273 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,6 @@ await using var stream = await Proxy.ConnectAsync( TimeSpan.FromSeconds(10)); ``` -### Extension method on Uri - -```csharp -var proxy = new Uri("http://proxy.example.com:8080"); -await using var stream = await proxy.ConnectThroughProxyAsync("example.com", 443); -``` - ### Factory API (when you need to configure the client) ```csharp From a8e7420f995df6dcd66276f3f7348e425f3251fd Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:46:32 +0500 Subject: [PATCH 14/35] fix: one connect path, with the timeout as a cancellation ProxyClient had the connect-and-handshake logic twice, and the timeout overload ran it under a timer that disposed the socket and set a flag. Three defects followed from that shape: - the timer was disposed only after the handshake returned, so a timeout firing in that window disposed the socket of a stream already handed back, and no exception was raised; - new NetworkStream sat outside the guard, so a socket disposed underneath it threw a raw IOException; - an invalid timeout threw from CreateTimer after the socket existed, and nothing disposed it. Both overloads now share ConnectCoreAsync. The timeout is a CancellationTokenSource linked to the caller's token, passed to the connect and to every await of the handshake, and nothing it registers outlives the call. An invalid timeout is rejected before any socket exists. The caller's own cancellation is now OperationCanceledException carrying the caller's token, in every phase; the TCP connect used to report it as ConnectionFailed while the handshake threw it bare. A timeout stays ProxyErrorCode.Timeout. AGENTS.md item 18 records the rule. ConnectCancellationTest pins the contract. Only its invalid-timeout cases fail on the old code: the race, and a cancellation landing in the TCP connect itself, cannot be reproduced deterministically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- AGENTS.md | 7 + .../ConnectCancellationTest.cs | 120 ++++++++++++++ QuickProxyNet/Clients/ProxyClient.cs | 146 ++++++++---------- 3 files changed, 194 insertions(+), 79 deletions(-) create mode 100644 QuickProxyNet.Tests/ConnectCancellationTest.cs diff --git a/AGENTS.md b/AGENTS.md index 1cafd74..2b2d249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -311,6 +311,13 @@ not "clean up" any of them without reading the reasoning first. against it catch the first and let the other two crash the process, which is right: one is a dead node, the others are a bug in the calling code. + Stopping an attempt is not a failure and has its own shape. The caller's own + cancellation is `OperationCanceledException` carrying the caller's token, in every + phase; before 5.0.0 the TCP connect reported it as `ConnectionFailed` and the + handshake threw it bare. A timeout is `ProxyErrorCode.Timeout` and never an + `OperationCanceledException`. Both run through one linked token source, checked + caller-first, because the caller's cancellation cancels the linked source too. + Two paths used to break it, and both were invisible from inside the library — it took a checker running the public API over thousands of real nodes to see them. `CreateSocket()` sat *outside* the guarded region in both overloads, so a bind diff --git a/QuickProxyNet.Tests/ConnectCancellationTest.cs b/QuickProxyNet.Tests/ConnectCancellationTest.cs new file mode 100644 index 0000000..8ba45dc --- /dev/null +++ b/QuickProxyNet.Tests/ConnectCancellationTest.cs @@ -0,0 +1,120 @@ +using System.Net; +using System.Net.Sockets; + +namespace QuickProxyNet.Tests; + +/// +/// How a connect attempt ends when it is stopped rather than refused. The caller's own +/// cancellation is with the caller's token, a timeout is +/// , and a timeout that is still pending must not turn the +/// caller's cancellation into a timeout. +/// +/// +/// Every case stops the attempt during the SOCKS5 handshake: the proxy accepts and never answers +/// the greeting. Stopping the TCP connect itself would need an address that neither answers nor +/// refuses, and no such address is reliable on a developer machine or in CI. +/// +public class ConnectCancellationTest +{ + private static readonly TimeSpan Short = TimeSpan.FromMilliseconds(300); + private static readonly TimeSpan Long = TimeSpan.FromSeconds(30); + + [Fact] + public async Task CallerCancel_IsOperationCanceled_WithTheCallersToken() + { + using var proxy = new SilentProxy(); + var client = new Socks5Client("127.0.0.1", proxy.Port); + using var cts = new CancellationTokenSource(Short); + + var ex = await Assert.ThrowsAnyAsync( + async () => await client.ConnectAsync("example.com", 80, cts.Token)); + + Assert.Equal(cts.Token, ex.CancellationToken); + } + + [Fact] + public async Task CallerCancel_WhileATimeoutIsPending_IsOperationCanceled_NotTimeout() + { + using var proxy = new SilentProxy(); + var client = new Socks5Client("127.0.0.1", proxy.Port); + using var cts = new CancellationTokenSource(Short); + + var ex = await Assert.ThrowsAnyAsync( + async () => await client.ConnectAsync("example.com", 80, Long, cts.Token)); + + Assert.Equal(cts.Token, ex.CancellationToken); + } + + [Fact] + public async Task Timeout_IsTimeout() + { + using var proxy = new SilentProxy(); + var client = new Socks5Client("127.0.0.1", proxy.Port); + + var ex = await Assert.ThrowsAsync( + async () => await client.ConnectAsync("example.com", 80, Short)); + + Assert.Equal(ProxyErrorCode.Timeout, ex.ErrorCode); + } + + [Fact] + public async Task Timeout_WithACallerTokenThatNeverFires_IsStillTimeout() + { + using var proxy = new SilentProxy(); + var client = new Socks5Client("127.0.0.1", proxy.Port); + using var cts = new CancellationTokenSource(); + + var ex = await Assert.ThrowsAsync( + async () => await client.ConnectAsync("example.com", 80, Short, cts.Token)); + + Assert.Equal(ProxyErrorCode.Timeout, ex.ErrorCode); + } + + [Theory] + [InlineData(-2)] + [InlineData(-5000)] + public async Task NegativeTimeout_IsArgumentOutOfRange(int milliseconds) + { + var client = new Socks5Client("127.0.0.1", 1080); + + var ex = await Assert.ThrowsAsync( + async () => await client.ConnectAsync("example.com", 80, TimeSpan.FromMilliseconds(milliseconds))); + + Assert.Equal("timeout", ex.ParamName); + } + + [Fact] + public async Task InfiniteTimeout_IsAccepted() + { + using var proxy = new SilentProxy(); + var client = new Socks5Client("127.0.0.1", proxy.Port); + using var cts = new CancellationTokenSource(Short); + + await Assert.ThrowsAnyAsync( + async () => await client.ConnectAsync("example.com", 80, Timeout.InfiniteTimeSpan, cts.Token)); + } + + /// Accepts one connection and never writes a byte. + private sealed class SilentProxy : IDisposable + { + private readonly TcpListener _listener; + private readonly Task _accepted; + + public SilentProxy() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _accepted = _listener.AcceptTcpClientAsync(); + } + + public int Port { get; } + + public void Dispose() + { + _listener.Stop(); + if (_accepted.IsCompletedSuccessfully) + _accepted.Result.Dispose(); + } + } +} diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 43e9c63..6807606 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -93,12 +92,38 @@ private Socket CreateSocket() } } - public async ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) + public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) => + ConnectCoreAsync(host, port, timeout: null, cancellationToken); + + public virtual ValueTask ConnectAsync(string host, int port, TimeSpan timeout, + CancellationToken cancellationToken = default) => + ConnectCoreAsync(host, port, timeout, cancellationToken); + + /// The longest delay accepts. + private static readonly TimeSpan MaxTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + + private async ValueTask ConnectCoreAsync(string host, int port, TimeSpan? timeout, + CancellationToken cancellationToken) { ValidateArguments(host, port); + // Checked before anything is allocated. The timer this replaced rejected the same values, + // but only once the socket existed, and nothing disposed that socket. + if (timeout is { } limit && limit != Timeout.InfiniteTimeSpan && (limit < TimeSpan.Zero || limit > MaxTimeout)) + throw new ArgumentOutOfRangeException(nameof(timeout), limit, + "A timeout must be between zero and about 49 days, or Timeout.InfiniteTimeSpan."); + cancellationToken.ThrowIfCancellationRequested(); + // The timeout is a cancellation like the caller's own, handed to every await of the connect + // and the handshake. It used to be a timer that disposed the socket: it could fire after + // the handshake had produced the stream, and the caller got a dead stream and no error. + using CancellationTokenSource? timeoutSource = timeout is null + ? null + : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource?.CancelAfter(timeout!.Value); + CancellationToken token = timeoutSource?.Token ?? cancellationToken; + Socket socket; try { @@ -114,102 +139,65 @@ public async ValueTask ConnectAsync(string host, int port, CancellationT $"Could not open a socket for proxy {ProxyHost}:{ProxyPort}.", ex); } + NetworkStream stream; try { - await socket.ConnectAsync(ProxyHost, ProxyPort, cancellationToken); + await socket.ConnectAsync(ProxyHost, ProxyPort, token); + // Inside the guard: on a socket that failed underneath, the constructor throws a raw + // IOException of its own. + stream = new NetworkStream(socket, ownsSocket: true); } catch (Exception ex) { socket.Dispose(); - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"Failed to connect to proxy {ProxyHost}:{ProxyPort} for target {host}:{port}.", ex); + throw Stopped(ex, timeout, timeoutSource, cancellationToken) + ?? new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Failed to connect to proxy {ProxyHost}:{ProxyPort} for target {host}:{port}.", ex); } - var stream = new NetworkStream(socket, true); try { - return await ConnectAsync(stream, host, port, cancellationToken); - } - catch (Exception ex) when (ex is IOException or SocketException) - { - // The proxy closed or reset the connection while we were still negotiating. That is - // the same failure class as "could not connect" from the caller's point of view, and - // it must arrive as one: a raw IOException here is the one place the "all protocol - // errors are ProxyProtocolException" promise was not kept. - await stream.DisposeAsync(); - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"Proxy {ProxyHost}:{ProxyPort} closed the connection during the handshake for target {host}:{port}.", ex); + return await ConnectAsync(stream, host, port, token); } - catch + catch (Exception ex) { await stream.DisposeAsync(); - throw; + + // The proxy closing or resetting the connection mid-negotiation is the same failure as + // "could not connect" to the caller, and must arrive as one. + Exception? translated = Stopped(ex, timeout, timeoutSource, cancellationToken) + ?? (ex is IOException or SocketException + ? new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"Proxy {ProxyHost}:{ProxyPort} closed the connection during the handshake for target {host}:{port}.", ex) + : null); + + if (translated is null) + throw; + throw translated; } } - public virtual async ValueTask ConnectAsync(string host, int port, TimeSpan timeout, - CancellationToken cancellationToken = default) + /// + /// What a failure means when the attempt was stopped rather than refused, or null when it was not. + /// + /// + /// The caller's own cancellation is checked first, because it cancels the linked timeout source + /// too. It is in every phase; it used to arrive wrapped + /// as from the TCP connect and bare from the + /// handshake. A timeout is never an . + /// + private Exception? Stopped(Exception ex, TimeSpan? timeout, CancellationTokenSource? timeoutSource, + CancellationToken cancellationToken) { - ValidateArguments(host, port); + if (cancellationToken.IsCancellationRequested) + return new OperationCanceledException( + $"The connection to proxy {ProxyHost}:{ProxyPort} was canceled.", ex, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + if (timeoutSource is { IsCancellationRequested: true }) + return new ProxyProtocolException(ProxyErrorCode.Timeout, + $"Connection to proxy {ProxyHost}:{ProxyPort} timed out after {timeout}.", ex); - Socket socket; - try - { - socket = CreateSocket(); - } - catch (SocketException ex) - { - // CreateSocket binds LocalEndPoint and allocates a handle, so it fails for reasons a - // caller must see as a connection failure like any other: an address already in use, - // or handle exhaustion under a few thousand concurrent checks. Left outside the guard - // this was the one path that escaped ConnectAsync as a raw SocketException. - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"Could not open a socket for proxy {ProxyHost}:{ProxyPort}.", ex); - } - - var timedOut = new StrongBox(false); - - await using ITimer timer = TimeProvider.System.CreateTimer( - static s => - { - var state = (Tuple>)s!; - Volatile.Write(ref state.Item2.Value, true); - state.Item1.Dispose(); - }, - Tuple.Create(socket, timedOut), timeout, Timeout.InfiniteTimeSpan); - - try - { - await socket.ConnectAsync(ProxyHost, ProxyPort, cancellationToken); - } - catch (Exception ex) - { - socket.Dispose(); - if (Volatile.Read(ref timedOut.Value)) - throw new ProxyProtocolException(ProxyErrorCode.Timeout, - $"Connection to proxy {ProxyHost}:{ProxyPort} timed out after {timeout}.", ex); - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"Failed to connect to proxy {ProxyHost}:{ProxyPort} for target {host}:{port}.", ex); - } - - var stream = new NetworkStream(socket, true); - try - { - return await ConnectAsync(stream, host, port, cancellationToken); - } - catch (Exception ex) - { - await stream.DisposeAsync(); - if (Volatile.Read(ref timedOut.Value)) - throw new ProxyProtocolException(ProxyErrorCode.Timeout, - $"Connection to proxy {ProxyHost}:{ProxyPort} timed out after {timeout}.", ex); - if (ex is IOException or SocketException) - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"Proxy {ProxyHost}:{ProxyPort} closed the connection during the handshake for target {host}:{port}.", ex); - throw; - } + return null; } public abstract ValueTask ConnectAsync(Stream source, string host, int port, From 491244d8e946b05444c1a3159079f53a4d6c4a94 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:50:54 +0500 Subject: [PATCH 15/35] perf(reality): expand HKDF as its single block Every TLS 1.3 key-schedule output fits in the first HKDF-Expand block, T(1) = HMAC(secret, HkdfLabel || 0x01), and the REALITY auth key is Extract followed by that same one block. Both now call HMACSHA256/384.HashData directly instead of HKDF.Expand and HKDF.DeriveKey, which allocate on every call on net9 and net10; a handshake makes sixteen Expand calls and one DeriveKey. ExpandLabel refuses an output longer than a hash and a hash other than SHA-256 or SHA-384, and zeroes its scratch block. The PRK is zeroed too. Pinned by TlsKeyScheduleTest (RFC 8448's trace) and RealityAuthTest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet/Internal/Reality/RealityAuth.cs | 19 +++++----- .../Internal/Reality/TlsKeySchedule.cs | 36 +++++++++++++++++-- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/QuickProxyNet/Internal/Reality/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs index a56a3aa..0b7bb43 100644 --- a/QuickProxyNet/Internal/Reality/RealityAuth.cs +++ b/QuickProxyNet/Internal/Reality/RealityAuth.cs @@ -79,22 +79,25 @@ public static void DeriveAuthKey( throw new ArgumentException("The client random is 32 bytes.", nameof(clientRandom)); Span shared = stackalloc byte[X25519.KeySize]; + Span prk = stackalloc byte[AuthKeySize]; + Span info = stackalloc byte[HkdfInfo.Length + 1]; try { X25519.Agree(shared, clientPrivateKey, serverPublicKey); - // Not derived in place: HKDF reads the input while writing the output, and the two - // are the same size here, so aliasing them would be a silent corruption. - HKDF.DeriveKey( - HashAlgorithmName.SHA256, - ikm: shared, - output: authKey, - salt: clientRandom[..20], - info: HkdfInfo); + // HKDF-SHA256 with a 32-byte output is Extract, then the single Expand block + // HMAC(prk, info || 0x01). Spelled out because HKDF.DeriveKey allocates about 300 + // bytes a call on net9 and net10. Not derived in place: the shared secret, the PRK and + // the auth key are all 32 bytes, and aliasing any two would corrupt silently. + HKDF.Extract(HashAlgorithmName.SHA256, shared, clientRandom[..20], prk); + HkdfInfo.CopyTo(info); + info[^1] = 0x01; + HMACSHA256.HashData(prk, info, authKey); } finally { CryptographicOperations.ZeroMemory(shared); + CryptographicOperations.ZeroMemory(prk); } } diff --git a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs index b2fff0a..46a2399 100644 --- a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs +++ b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs @@ -41,9 +41,16 @@ public static void ExpandLabel( ReadOnlySpan context, Span output) { - // HkdfLabel = uint16 length || opaque label<7..255> || opaque context<0..255> + int hashLength = HashLength(hash); + if (output.Length > hashLength) + throw new ArgumentException( + $"TLS 1.3 never expands past one hash length ({hashLength} bytes); {output.Length} were asked for.", + nameof(output)); + + // HkdfLabel = uint16 length || opaque label<7..255> || opaque context<0..255>, followed + // here by the one-byte block counter HKDF-Expand appends. int labelLength = LabelPrefix.Length + label.Length; - Span info = stackalloc byte[2 + 1 + labelLength + 1 + context.Length]; + Span info = stackalloc byte[2 + 1 + labelLength + 1 + context.Length + 1]; info[0] = (byte)(output.Length >> 8); info[1] = (byte)output.Length; @@ -52,10 +59,33 @@ public static void ExpandLabel( label.CopyTo(info[(3 + LabelPrefix.Length)..]); info[3 + labelLength] = (byte)context.Length; context.CopyTo(info[(4 + labelLength)..]); + info[^1] = 0x01; + + // HKDF-Expand is T(1) || T(2) || ..., and every TLS 1.3 output fits in T(1) = + // HMAC(secret, HkdfLabel || 0x01). Computed directly because HKDF.Expand allocates about + // 300 bytes a call on net9 and net10, and a handshake makes sixteen calls. Into a scratch + // block rather than into output, which may be shorter than a block and may alias secret. + Span block = stackalloc byte[hashLength]; + try + { + if (hashLength == 48) + HMACSHA384.HashData(secret, info, block); + else + HMACSHA256.HashData(secret, info, block); - HKDF.Expand(hash, secret, output, info); + block[..output.Length].CopyTo(output); + } + finally + { + CryptographicOperations.ZeroMemory(block); + } } + private static int HashLength(HashAlgorithmName hash) => + hash == HashAlgorithmName.SHA256 ? 32 + : hash == HashAlgorithmName.SHA384 ? 48 + : throw new ArgumentException($"TLS 1.3 cipher suites hash with SHA-256 or SHA-384, not {hash.Name}.", nameof(hash)); + /// /// Derive-Secret: with a transcript hash as the context. /// From ef545d3e7ac146d91135f1fd43289354f6a7a1be Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:50:54 +0500 Subject: [PATCH 16/35] perf: stop boxing async state machines in the tunnel streams A plain async ValueTask override boxes its state machine whenever it completes asynchronously, which for a tunnel stream is most reads, for as long as the connection lives, once per layer. PrefixedStream, VlessResponseStream and VmessResponseStream are pass-throughs once their prefix or header is consumed, so their ReadAsync is no longer async: it returns the inner ValueTask directly and keeps an async method only for the header read. VmessStream, WebSocketStream and VisionStream do real work per call, so their async methods use PoolingAsyncValueTaskMethodBuilder, as ShadowsocksStream and RealityTlsStream already did. VisionStream's fill loops are now Stream.ReadAtLeast/ReadAtLeastAsync, and their throwOnEof parameter, which every caller passed as false, is gone. One visible difference: a read on a disposed pass-through stream now throws ObjectDisposedException synchronously instead of from the returned ValueTask. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet/Internal/PrefixedStream.cs | 8 +-- .../Internal/Transports/WebSocketStream.cs | 12 +++-- QuickProxyNet/Internal/VisionStream.cs | 51 ++++++++----------- QuickProxyNet/Internal/VlessResponseStream.cs | 15 ++++-- .../Internal/Vmess/VmessResponseStream.cs | 15 ++++-- QuickProxyNet/Internal/Vmess/VmessStream.cs | 4 ++ 6 files changed, 61 insertions(+), 44 deletions(-) diff --git a/QuickProxyNet/Internal/PrefixedStream.cs b/QuickProxyNet/Internal/PrefixedStream.cs index c10875c..5973102 100644 --- a/QuickProxyNet/Internal/PrefixedStream.cs +++ b/QuickProxyNet/Internal/PrefixedStream.cs @@ -44,16 +44,18 @@ public override int Read(Span buffer) public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); - public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + // Not async: once the prefix is used up this is a pass-through, and an async method would box + // its state machine on every read that does not complete at once, for the life of the tunnel. + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) { if (_offset < prefix.Length) { int count = Math.Min(buffer.Length, prefix.Length - _offset); prefix.AsMemory(_offset, count).CopyTo(buffer); _offset += count; - return count; + return new ValueTask(count); } - return await inner.ReadAsync(buffer, ct).ConfigureAwait(false); + return inner.ReadAsync(buffer, ct); } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => diff --git a/QuickProxyNet/Internal/Transports/WebSocketStream.cs b/QuickProxyNet/Internal/Transports/WebSocketStream.cs index d644e44..6bda6fb 100644 --- a/QuickProxyNet/Internal/Transports/WebSocketStream.cs +++ b/QuickProxyNet/Internal/Transports/WebSocketStream.cs @@ -1,4 +1,6 @@ +using System.Buffers; using System.Net.WebSockets; +using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -57,6 +59,7 @@ public override long Position public override void Flush() => _inner.Flush(); public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { @@ -99,6 +102,7 @@ public override async ValueTask ReadAsync( } } + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { @@ -138,7 +142,7 @@ public override Task WriteAsync( // renter reads. These take the same route but clear the array on the way back. public override int Read(Span buffer) { - byte[] rented = System.Buffers.ArrayPool.Shared.Rent(buffer.Length); + byte[] rented = ArrayPool.Shared.Rent(buffer.Length); try { int read = Read(rented, 0, buffer.Length); @@ -147,13 +151,13 @@ public override int Read(Span buffer) } finally { - System.Buffers.ArrayPool.Shared.Return(rented, clearArray: true); + ArrayPool.Shared.Return(rented, clearArray: true); } } public override void Write(ReadOnlySpan buffer) { - byte[] rented = System.Buffers.ArrayPool.Shared.Rent(buffer.Length); + byte[] rented = ArrayPool.Shared.Rent(buffer.Length); try { buffer.CopyTo(rented); @@ -161,7 +165,7 @@ public override void Write(ReadOnlySpan buffer) } finally { - System.Buffers.ArrayPool.Shared.Return(rented, clearArray: true); + ArrayPool.Shared.Return(rented, clearArray: true); } } diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs index 3791d65..a2df149 100644 --- a/QuickProxyNet/Internal/VisionStream.cs +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace QuickProxyNet; @@ -131,6 +132,7 @@ public override long Position // ================================ reading ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { @@ -171,7 +173,7 @@ public override async ValueTask ReadAsync( continue; } - await FillAsync(HeaderSize, throwOnEof: false, cancellationToken).ConfigureAwait(false); + await FillAsync(HeaderSize, cancellationToken).ConfigureAwait(false); if (Buffered == 0) return 0; // a clean close on a frame boundary is the end of the stream @@ -239,7 +241,7 @@ public override int Read(Span buffer) continue; } - Fill(HeaderSize, throwOnEof: false); + Fill(HeaderSize); if (Buffered == 0) return 0; @@ -322,44 +324,30 @@ private int DrainInto(Span destination) return taken; } - /// Buffers at least bytes, compacting first if needed. - private async ValueTask FillAsync(int count, bool throwOnEof, CancellationToken cancellationToken) + /// + /// Buffers at least bytes, compacting first if needed. Stops short only + /// at end of stream, which the caller tells apart by what is . + /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + private async ValueTask FillAsync(int count, CancellationToken cancellationToken) { Compact(count); + if (Buffered >= count) + return; - while (Buffered < count) - { - int read = await _inner.ReadAsync(_buffer.AsMemory(_end, _buffer.Length - _end), cancellationToken) - .ConfigureAwait(false); - if (read == 0) - { - if (throwOnEof) - throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame header, mid-response."); - return; - } - - _end += read; - } + int read = await _inner.ReadAtLeastAsync( + _buffer.AsMemory(_end), count - Buffered, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); + _end += read; } - private void Fill(int count, bool throwOnEof) + private void Fill(int count) { Compact(count); - - while (Buffered < count) - { - int read = _inner.Read(_buffer.AsSpan(_end)); - if (read == 0) - { - if (throwOnEof) - throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame header, mid-response."); - return; - } - - _end += read; - } + if (Buffered < count) + _end += _inner.ReadAtLeast(_buffer.AsSpan(_end), count - Buffered, throwOnEndOfStream: false); } + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] private async ValueTask FillSomeAsync(CancellationToken cancellationToken) { Compact(1); @@ -399,6 +387,7 @@ private void Compact(int count) // ================================ writing ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { diff --git a/QuickProxyNet/Internal/VlessResponseStream.cs b/QuickProxyNet/Internal/VlessResponseStream.cs index 79c72f4..fc40b7d 100644 --- a/QuickProxyNet/Internal/VlessResponseStream.cs +++ b/QuickProxyNet/Internal/VlessResponseStream.cs @@ -129,14 +129,23 @@ public override long Position // ================================ reading ================================ /// - public override async ValueTask ReadAsync( + /// + /// Not async once the header is in: from then on this is a pass-through, and an async method + /// would box its state machine on every read that does not complete at once. + /// + public override ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); - if (!_headerRead) - await ReadHeaderAsync(cancellationToken).ConfigureAwait(false); + return _headerRead + ? _inner.ReadAsync(buffer, cancellationToken) + : ReadAfterHeaderAsync(buffer, cancellationToken); + } + private async ValueTask ReadAfterHeaderAsync(Memory buffer, CancellationToken cancellationToken) + { + await ReadHeaderAsync(cancellationToken).ConfigureAwait(false); return await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); } diff --git a/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs b/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs index 14d8b94..630aef7 100644 --- a/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs +++ b/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs @@ -130,14 +130,23 @@ public override long Position // ================================ reading ================================ /// - public override async ValueTask ReadAsync( + /// + /// Not async once the header is in: from then on this is a pass-through, and an async method + /// would box its state machine on every read that does not complete at once. + /// + public override ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); - if (!_headerRead) - await ReadHeaderAsync(cancellationToken); + return _headerRead + ? _inner.ReadAsync(buffer, cancellationToken) + : ReadAfterHeaderAsync(buffer, cancellationToken); + } + private async ValueTask ReadAfterHeaderAsync(Memory buffer, CancellationToken cancellationToken) + { + await ReadHeaderAsync(cancellationToken); return await _inner.ReadAsync(buffer, cancellationToken); } diff --git a/QuickProxyNet/Internal/Vmess/VmessStream.cs b/QuickProxyNet/Internal/Vmess/VmessStream.cs index d5332e4..a339a95 100644 --- a/QuickProxyNet/Internal/Vmess/VmessStream.cs +++ b/QuickProxyNet/Internal/Vmess/VmessStream.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace QuickProxyNet; @@ -166,6 +167,7 @@ public override long Position // ================================ reading ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { @@ -240,6 +242,7 @@ public override int Read(byte[] buffer, int offset, int count) // Reads one sealed chunk (length prefix + ciphertext + tag) into _receiveSealed and // returns the sealed length. The chunk is not opened yet. + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] private async ValueTask ReceiveSealedChunkAsync(CancellationToken cancellationToken) { _receiveSealed ??= ArrayPool.Shared.Rent(InitialReceiveBufferSize); @@ -308,6 +311,7 @@ private void OpenChunk(int sealedLength, Memory plaintext) // ================================ writing ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { From 5df49329982934be5cd6a761c1669a9a4d445dff Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:50:54 +0500 Subject: [PATCH 17/35] refactor(transport): Ascii.EqualsIgnoreCase and span Trim for upgrade headers The hand-written case-insensitive compare and SP/HTAB trim are Ascii.EqualsIgnoreCase and MemoryExtensions.Trim(" \t"u8). Not Ascii.Trim, which would also strip \v, \f and \r. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- .../Transports/HttpUpgradeHandshake.cs | 32 +++---------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs index 93ca4c9..c36170c 100644 --- a/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs +++ b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs @@ -146,10 +146,12 @@ private static bool TryGetHeaderValue( if (colon < 0 || colon != name.Length) continue; - if (!EqualsIgnoreAsciiCase(line[..colon], name)) + if (!Ascii.EqualsIgnoreCase(line[..colon], name)) continue; - value = Trim(line[(colon + 1)..]); + // Whitespace around a field value is SP and HTAB only (RFC 9110 §5.6.3). Not + // Ascii.Trim, which would also strip \v, \f and \r. + value = line[(colon + 1)..].Trim(" \t"u8); return true; } @@ -157,32 +159,6 @@ private static bool TryGetHeaderValue( return false; } - private static bool EqualsIgnoreAsciiCase(ReadOnlySpan actual, ReadOnlySpan lowercase) - { - for (int i = 0; i < lowercase.Length; i++) - { - byte c = actual[i]; - if (c is >= (byte)'A' and <= (byte)'Z') - c += 32; - if (c != lowercase[i]) - return false; - } - return true; - } - - private static ReadOnlySpan Trim(ReadOnlySpan value) - { - int start = 0; - while (start < value.Length && (value[start] == (byte)' ' || value[start] == (byte)'\t')) - start++; - - int end = value.Length; - while (end > start && (value[end - 1] == (byte)' ' || value[end - 1] == (byte)'\t')) - end--; - - return value[start..end]; - } - /// /// Builds the upgrade request into a pooled buffer and, for a WebSocket handshake, the /// accept token the server must echo back. From cdc03dab21fc0b8804aba100c8f458759def229f Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:54:52 +0500 Subject: [PATCH 18/35] fix(https): let SslStream's own check say why a certificate is refused HttpsProxyClient installed a default RemoteCertificateValidationCallback that accepted exactly what SslStream accepts without one: no policy errors. The only thing it changed was the failure. TlsHandshake carries the handshake's message into TlsHandshakeFailed, and a callback's refusal reads "The remote certificate was rejected by the provided RemoteCertificateValidationCallback" where SslStream's own names the chain error. The callback is now null unless the caller set one. The ALPN list is one shared instance instead of a new list per connection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/HttpsProxyClientTest.cs | 24 +++++++++++++++++++++ QuickProxyNet/Clients/HttpsProxyClient.cs | 13 ++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/QuickProxyNet.Tests/HttpsProxyClientTest.cs b/QuickProxyNet.Tests/HttpsProxyClientTest.cs index d013ee5..d85c78e 100644 --- a/QuickProxyNet.Tests/HttpsProxyClientTest.cs +++ b/QuickProxyNet.Tests/HttpsProxyClientTest.cs @@ -46,6 +46,30 @@ public async Task Handshake_NamesTheProxy_NotTheTarget() Assert.False(errors.Value.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch), $"Policy errors: {errors}"); } + /// + /// Without a callback of the caller's, an untrusted certificate is refused by SslStream's own + /// check, whose message names the policy error. The library's former default callback made + /// every such failure read "rejected by the provided RemoteCertificateValidationCallback". + /// + [Fact] + public async Task UntrustedCertificate_WithoutACallback_IsRefused_NamingTheReason() + { + using X509Certificate2 certificate = CreateSelfSignedCertificate("localhost"); + using var proxy = new LoopbackConnectProxy(IPAddress.Loopback, certificate); + var client = new HttpsProxyClient("localhost", proxy.Port); + + using var socket = new TcpClient(); + await socket.ConnectAsync(IPAddress.Loopback, proxy.Port); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var ex = await Assert.ThrowsAsync( + async () => await client.ConnectAsync(socket.GetStream(), "example.com", 443, cts.Token)); + + Assert.Equal(ProxyErrorCode.TlsHandshakeFailed, ex.ErrorCode); + for (Exception? e = ex; e is not null; e = e.InnerException) + Assert.DoesNotContain("RemoteCertificateValidationCallback", e.Message); + } + private static X509Certificate2 CreateSelfSignedCertificate(string dnsName) { using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index b3e6518..460685a 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -32,6 +32,9 @@ public HttpsProxyClient(string host, int port, NetworkCredential credentials) : public override ProxyType Type => ProxyType.Https; + // SslStream only reads this list, so one instance serves every connection. + private static readonly List Http11Only = [SslApplicationProtocol.Http11]; + // The TLS session is with the proxy, not with the target the tunnel leads to, so the proxy's // name is the one SNI carries and the certificate is checked against. private SslClientAuthenticationOptions GetSslClientAuthenticationOptions() @@ -40,8 +43,11 @@ private SslClientAuthenticationOptions GetSslClientAuthenticationOptions() { CertificateRevocationCheckMode = CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, - ApplicationProtocols = new List { SslApplicationProtocol.Http11 }, - RemoteCertificateValidationCallback = ServerCertificateValidationCallback ?? DefaultValidation, + ApplicationProtocols = Http11Only, + // Null unless the caller set one. SslStream then applies the same rule the old default + // callback did — no policy errors — and its failure names the actual problem, where a + // callback's refusal only says a callback refused. + RemoteCertificateValidationCallback = ServerCertificateValidationCallback, CipherSuitesPolicy = SslCipherSuitesPolicy, ClientCertificates = ClientCertificates, EnabledSslProtocols = SslProtocols, @@ -49,9 +55,6 @@ private SslClientAuthenticationOptions GetSslClientAuthenticationOptions() }; } - private static bool DefaultValidation(object sender, X509Certificate? certificate, X509Chain? chain, - SslPolicyErrors sslPolicyErrors) => sslPolicyErrors == SslPolicyErrors.None; - public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { From f18e45e44b948a1fdb7fd45a61ae828bff117537 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:54:52 +0500 Subject: [PATCH 19/35] build: turn on the trim and AOT analyzers IsAotCompatible marks the package trimmable and makes a reflection-based dependency, a JsonSerializer call for instance, fail the build rather than a user's published app. The library builds with no IL warnings on all four targets: the vmess JSON is read with JsonDocument, which does not reflect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet/QuickProxyNet.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index 40f24d1..5456c1c 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -4,6 +4,9 @@ enable enable latest + + true v 4.0 From 9aec0e316416e414473d380dba096a96c7fc7c5b Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:54:53 +0500 Subject: [PATCH 20/35] refactor: throw helpers for argument checks, and a timeout doc that was wrong The host and port checks in ProxyClient use ArgumentException.ThrowIfNullOrEmpty and the ArgumentOutOfRangeException helpers. The types and parameter names are unchanged; the messages now include the rejected value, and the host message no longer says "between 0 and 256 characters" for a limit of 255. IProxyClient described the TimeSpan timeout as "in milliseconds". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet/Clients/ProxyClient.cs | 27 +++++++++++---------------- QuickProxyNet/IProxyClient.cs | 5 ++++- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 6807606..6287086 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -9,15 +9,13 @@ public abstract class ProxyClient : IProxyClient protected ProxyClient(string protocol, string host, int port) { - if (host == null) - throw new ArgumentNullException(nameof(host)); + ArgumentException.ThrowIfNullOrEmpty(host); + if (host.Length > 255) + throw new ArgumentException("A host name is at most 255 characters.", nameof(host)); - if (host.Length == 0 || host.Length > 255) - throw new ArgumentException("The length of the host name must be between 0 and 256 characters.", - nameof(host)); - - if (port < 0 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port)); + // Zero is allowed here and means the default port. + ArgumentOutOfRangeException.ThrowIfNegative(port); + ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); _scheme = protocol; // An IPv6 literal is kept unbracketed however it arrived: a Uri authority hands over @@ -228,15 +226,12 @@ public async ValueTask ConnectAsync(Stream source, EndPoint target, internal static void ValidateArguments(string host, int port) { - if (host == null) - throw new ArgumentNullException(nameof(host)); - - if (host.Length == 0 || host.Length > 255) - throw new ArgumentException("The length of the host name must be between 0 and 256 characters.", - nameof(host)); + ArgumentException.ThrowIfNullOrEmpty(host); + if (host.Length > 255) + throw new ArgumentException("A host name is at most 255 characters.", nameof(host)); - if (port <= 0 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); + ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); } // The EndPoint overloads spell the target the way the host-and-port ones take it. An diff --git a/QuickProxyNet/IProxyClient.cs b/QuickProxyNet/IProxyClient.cs index 21a6eea..4021b40 100644 --- a/QuickProxyNet/IProxyClient.cs +++ b/QuickProxyNet/IProxyClient.cs @@ -94,7 +94,10 @@ public interface IProxyClient /// /// The target host to connect to. /// The target port on the host. - /// The maximum time, in milliseconds, to wait for a connection to the host. + /// + /// The maximum time to wait for the connection to the proxy and the handshake through it, or + /// . Running out ends in . + /// /// A cancellation token that can be used to cancel the connection attempt. /// A representing the asynchronous operation and yielding the connected . ValueTask ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken cancellationToken = default); From 8815e6febe3758b182974d9304cfbde8a730d429 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:59:52 +0500 Subject: [PATCH 21/35] fix: decode share-link base64 the same on .NET 10 and .NET 11 In a base64 group ending "==" the last data character carries four bits no byte uses, and two before a single "=". Go's decoder, which Xray and most link producers run, ignores them, and so does .NET 10's Convert. .NET 11's rejects the group. The same vmess or ss link therefore parsed on one of the test project's targets and not on the other. ShareLinkBase64.TryNormalize is now the one place share-link base64 is prepared: the url-safe alphabet mapped to the standard one, whitespace dropped, padding completed, and those trailing bits cleared, which decodes to the bytes both older decoders produced. It replaces the two copies of that loop in VmessShareLink and ShadowsocksShareLink. The vmess relaxed decoder now clears its pooled char buffer, which held the base64 of a JSON carrying the user id. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/ShareLinkBase64Test.cs | 46 +++++++++++ QuickProxyNet.Tests/VmessClientTest.cs | 19 +++++ QuickProxyNet/Configs/ShadowsocksShareLink.cs | 22 +----- QuickProxyNet/Configs/VmessShareLink.cs | 46 ++--------- QuickProxyNet/Internal/ShareLinkBase64.cs | 76 +++++++++++++++++++ 5 files changed, 149 insertions(+), 60 deletions(-) create mode 100644 QuickProxyNet.Tests/ShareLinkBase64Test.cs create mode 100644 QuickProxyNet/Internal/ShareLinkBase64.cs diff --git a/QuickProxyNet.Tests/ShareLinkBase64Test.cs b/QuickProxyNet.Tests/ShareLinkBase64Test.cs new file mode 100644 index 0000000..583e3fb --- /dev/null +++ b/QuickProxyNet.Tests/ShareLinkBase64Test.cs @@ -0,0 +1,46 @@ +namespace QuickProxyNet.Tests; + +/// +/// What turns share-link base64 into must decode identically on +/// .NET 10 and .NET 11, including the case where they disagree: unused bits set in the last +/// character, which .NET 10 ignores, .NET 11 rejects, and Go's decoder accepts. +/// +public class ShareLinkBase64Test +{ + private static byte[]? Decode(string text) + { + char[] chars = new char[text.Length + 3]; + byte[] bytes = new byte[text.Length + 3]; + return ShareLinkBase64.TryNormalize(text, chars, out int length) + && Convert.TryFromBase64Chars(chars.AsSpan(0, length), bytes, out int written) + ? bytes[..written] + : null; + } + + [Theory] + [InlineData("YQ==", "61")] + [InlineData("YR==", "61")] // the 4 unused bits before "==" set + [InlineData("YR", "61")] // the same, unpadded + [InlineData("YWI=", "6162")] + [InlineData("YWJ=", "6162")] // the 2 unused bits before "=" set + [InlineData("YWJ", "6162")] + [InlineData("YWJj", "616263")] + [InlineData("P/8+", "3fff3e")] + [InlineData("P_8-", "3fff3e")] // url-safe alphabet + [InlineData("YW\r\nJj ", "616263")] // whitespace, as a wrapped or `echo | base64` blob has + public void Decodes_WhatShareLinksCarry(string text, string hex) + { + Assert.Equal(Convert.FromHexString(hex), Decode(text)); + } + + [Theory] + [InlineData("")] + [InlineData(" \t")] + [InlineData("Y")] // no base64 text is one character past a whole group + [InlineData("YWJjZ")] + [InlineData("Y*==")] + public void Refuses_WhatIsNotBase64(string text) + { + Assert.Null(Decode(text)); + } +} diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index ceddc5e..05a89da 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -361,6 +361,25 @@ public void TryParse_EmptyJsonPs_FallsBackToTheFragment() Assert.Equal("from fragment", o.Remark); } + // Go's base64 decoder, which Xray and most producers run, ignores the unused low bits of the + // last character. .NET 10 ignores them too; .NET 11 rejects the group, so this link used to + // parse on one of the test project's targets and not the other. + [Fact] + public void TryParse_UnusedTrailingBase64BitsSet_ParsesOnEveryTarget() + { + string json = MinimalJson(); + while (Encoding.UTF8.GetByteCount(json) % 3 != 1) + json += " "; + + char[] link = Link(json).ToCharArray(); + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + int last = link.Length - 3; // the data character before "==" + link[last] = alphabet[alphabet.IndexOf(link[last]) | 0x0F]; + + Assert.True(VmessShareLink.TryParse(new string(link), out var o)); + Assert.Equal(ProxyHost, o.Host); + } + // ===================== grammar 2: the standard URI form ===================== // // vmess://{uuid}@{host}:{port}?{query}#{remark} — 48 links in the corpus. The query diff --git a/QuickProxyNet/Configs/ShadowsocksShareLink.cs b/QuickProxyNet/Configs/ShadowsocksShareLink.cs index 9db953c..a534fc6 100644 --- a/QuickProxyNet/Configs/ShadowsocksShareLink.cs +++ b/QuickProxyNet/Configs/ShadowsocksShareLink.cs @@ -428,26 +428,8 @@ private static bool TryDecodeBase64Utf8(ReadOnlySpan payload, out DecodedT bool handedOver = false; try { - for (int i = 0; i < payload.Length; i++) - { - char c = payload[i]; - if (char.IsWhiteSpace(c)) - continue; - - chars[length++] = c switch - { - '-' => '+', - '_' => '/', - _ => c - }; - } - - int remainder = length % 4; - if (remainder == 1) - return false; // no base64 string has this length - - for (int i = remainder; remainder != 0 && i < 4; i++) - chars[length++] = '='; + if (!ShareLinkBase64.TryNormalize(payload, chars, out length)) + return false; if (!Convert.TryFromBase64Chars(chars.AsSpan(0, length), bytes, out decoded) || decoded == 0) return false; diff --git a/QuickProxyNet/Configs/VmessShareLink.cs b/QuickProxyNet/Configs/VmessShareLink.cs index 427fc55..f9a176a 100644 --- a/QuickProxyNet/Configs/VmessShareLink.cs +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -385,15 +385,17 @@ private static string Decode(ReadOnlySpan value) => value.IndexOf('%') < 0 ? value.ToString() : Uri.UnescapeDataString(value.ToString()); /// - /// Decodes a payload that uses the URL-safe alphabet and/or omits its padding. + /// Decodes a payload the fast path refused: the URL-safe alphabet, missing padding, or unused + /// trailing bits left set, which .NET 11 rejects where .NET 10 and Go accept them. /// private static bool TryDecodeRelaxed(ReadOnlySpan payload, Span destination, out int length) { // Padding may add up to 3 characters to the normalized form. char[] chars = ArrayPool.Shared.Rent(payload.Length + 3); + int charCount = 0; try { - if (!TryNormalizeBase64(payload, chars, out int charCount)) + if (!ShareLinkBase64.TryNormalize(payload, chars, out charCount)) { length = 0; return false; @@ -403,48 +405,12 @@ private static bool TryDecodeRelaxed(ReadOnlySpan payload, Span dest } finally { + // The JSON inside carries the user id. + Array.Clear(chars, 0, charCount); ArrayPool.Shared.Return(chars); } } - /// - /// Copies into , translating - /// the URL-safe alphabet to the standard one, dropping whitespace, and appending the - /// = padding requires. - /// - private static bool TryNormalizeBase64( - ReadOnlySpan payload, Span destination, out int length) - { - length = 0; - - for (int i = 0; i < payload.Length; i++) - { - char c = payload[i]; - if (char.IsWhiteSpace(c)) - continue; - - destination[length++] = c switch - { - '-' => '+', - '_' => '/', - _ => c - }; - } - - // Trailing padding may already be present; only top it up to a 4-character group. - int remainder = length % 4; - if (remainder == 1) - return false; // no base64 string can have this length - - if (remainder != 0) - { - for (int i = remainder; i < 4; i++) - destination[length++] = '='; - } - - return length > 0; - } - private static bool TryParseJson( ReadOnlyMemory utf8Json, string? fragmentRemark, diff --git a/QuickProxyNet/Internal/ShareLinkBase64.cs b/QuickProxyNet/Internal/ShareLinkBase64.cs new file mode 100644 index 0000000..8a92f8a --- /dev/null +++ b/QuickProxyNet/Internal/ShareLinkBase64.cs @@ -0,0 +1,76 @@ +namespace QuickProxyNet; + +/// +/// The base64 share links carry, turned into what +/// accepts on every target framework. +/// +/// +/// Links use either alphabet, with or without padding, sometimes wrapped with whitespace, and +/// sometimes with bits no decoded byte uses left set in the last character. Go's decoder, which +/// Xray and most producers run, accepts all of that. .NET accepts none of the first three, and the +/// last one depends on the version: .NET 10 ignores those bits, .NET 11 rejects the group. So the +/// same link decoded differently depending on the target it ran on, until the bits were cleared +/// here. +/// +internal static class ShareLinkBase64 +{ + private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + /// + /// Writes into in the standard + /// alphabet, without whitespace, padded to a whole group, with the unused trailing bits cleared. + /// + /// The base64 as found in the link. + /// At least payload.Length + 3 characters. + /// + /// The characters written, also when this returns false, so a caller holding a secret can + /// clear exactly those. + /// + /// False when no base64 text could have this many characters. + public static bool TryNormalize(ReadOnlySpan payload, Span destination, out int length) + { + length = 0; + + foreach (char c in payload) + { + if (char.IsWhiteSpace(c)) + continue; + + destination[length++] = c switch + { + '-' => '+', + '_' => '/', + _ => c + }; + } + + // Trailing padding may already be present; only top it up to a whole group. + int remainder = length % 4; + if (remainder == 1 || length == 0) + return false; + + for (int i = remainder; remainder != 0 && i < 4; i++) + destination[length++] = '='; + + ClearUnusedTrailingBits(destination[..length]); + return true; + } + + /// + /// In a group ending == the last data character carries 4 bits no byte uses; before a + /// single =, 2. Cleared, the group decodes to the same bytes it did with them set. + /// + private static void ClearUnusedTrailingBits(Span base64) + { + if (base64.Length < 4 || base64[^1] != '=') + return; + + bool twoPads = base64[^2] == '='; + int index = base64.Length - (twoPads ? 3 : 2); + int value = Alphabet.IndexOf(base64[index]); + if (value < 0) + return; // not base64 at all; the decoder will say so + + base64[index] = Alphabet[value & (twoPads ? ~0x0F : ~0x03)]; + } +} From 8e6166ab7e8090d2d632e5393a9020cac29b1d77 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 15:59:52 +0500 Subject: [PATCH 22/35] fix(vless): refuse an undecodable REALITY key or short id before connecting VlessClient decoded pbk and parsed sid inside ConnectAsync, so a bad value left that call as a FormatException, which is not one of the exceptions it may throw. Both are now checked where the configuration enters: VlessShareLink.TryParse refuses the link with the value named, and the VlessClient constructor throws ArgumentException for hand-built options. The decoded key is kept on the client instead of being decoded on every connect. A REALITY configuration with no key at all is still NotSupportedException at connect, as before. The key goes through ShareLinkBase64, so one whose last character has unused bits set decodes on .NET 11 as it does on .NET 10. RealityAuth gains TryDecodePublicKey and TryParseShortId; ParseShortId throws from the latter. Test changes: Client_Reality_MalformedPublicKey_ThrowsFormatBeforeWriting pinned the old behaviour and is replaced by tests that the link and the constructor refuse the value. Two parsing fixtures used pbk=PUBKEY, which is not a key and is now refused; they use a real one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/VlessTest.cs | 64 ++++++++++--- QuickProxyNet/Clients/VlessClient.cs | 50 +++++----- QuickProxyNet/Configs/VlessShareLink.cs | 13 +++ QuickProxyNet/Internal/Reality/RealityAuth.cs | 95 +++++++++++++++---- 4 files changed, 162 insertions(+), 60 deletions(-) diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index 096a2b7..1f0c6cc 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -193,9 +193,9 @@ public void Parse_Tls_WithSniAndAlpn() public void Parse_Reality_KeepsKeys() { var o = VlessShareLink.Parse( - $"vless://{Uuid}@example.com:443?security=reality&pbk=PUBKEY&sid=ab12&sni=www.microsoft.com&fp=chrome&flow=xtls-rprx-vision#r"); + $"vless://{Uuid}@example.com:443?security=reality&pbk=BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g&sid=ab12&sni=www.microsoft.com&fp=chrome&flow=xtls-rprx-vision#r"); Assert.Equal(VlessSecurity.Reality, o.Security); - Assert.Equal("PUBKEY", o.RealityPublicKey); + Assert.Equal("BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g", o.RealityPublicKey); Assert.Equal("ab12", o.RealityShortId); Assert.Equal("chrome", o.Fingerprint); Assert.Equal("xtls-rprx-vision", o.Flow); @@ -250,11 +250,11 @@ public void Parse_UnknownSecurity_Rejected_NoSilentPlaintextDowngrade() public void Parse_HtmlEscapedSeparators_DoNotSilentlyDowngradeRealityToPlaintext() { var o = VlessShareLink.Parse( - $"vless://{Uuid}@example.com:443?type=tcp&security=reality&pbk=PUBKEY" + + $"vless://{Uuid}@example.com:443?type=tcp&security=reality&pbk=BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g" + "&sid=ab12&flow=xtls-rprx-vision"); Assert.Equal(VlessSecurity.Reality, o.Security); - Assert.Equal("PUBKEY", o.RealityPublicKey); + Assert.Equal("BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g", o.RealityPublicKey); Assert.Equal("ab12", o.RealityShortId); Assert.Equal("xtls-rprx-vision", o.Flow); } @@ -437,25 +437,59 @@ public async Task Client_None_DoesNotReadResponseHeaderDuringConnect() } /// - /// A pbk that is not a key is a configuration error and must be reported as one — - /// naming the value, before anything is written — rather than as "REALITY not supported" - /// (which it is) or as an ArgumentException from inside the handshake. + /// A pbk that is not a key is a configuration error, reported with the value named when + /// the link is parsed or the client is built. It used to surface from ConnectAsync as a + /// FormatException, which that call may not throw. /// [Theory] - [InlineData("x")] // not base64url at all - [InlineData("AAAA")] // decodes to 3 bytes, not 32 - public async Task Client_Reality_MalformedPublicKey_ThrowsFormatBeforeWriting(string pbk) + [InlineData("x")] // not base64url at all + [InlineData("AAAA")] // decodes to 3 bytes, not 32 + public void Reality_MalformedPublicKey_IsRefusedBeforeAnyConnect(string pbk) { - var stream = new FakeProxyStream([0x00, 0x00]); - var client = new VlessClient( + var parse = Assert.Throws(() => VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=reality&pbk={pbk}")); + Assert.Contains($"'{pbk}'", parse.Message); - var ex = await Assert.ThrowsAsync( - () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + var options = new VlessOptions + { + Id = Uuid, Host = "example.com", Port = 443, Security = VlessSecurity.Reality, RealityPublicKey = pbk + }; + var construct = Assert.Throws(() => new VlessClient(options)); + Assert.Contains($"'{pbk}'", construct.Message); + } + + [Theory] + [InlineData("abc")] // odd length + [InlineData("zz")] // not hex + [InlineData("001122334455667788")] // nine bytes, one more than a short id holds + public void Reality_MalformedShortId_IsRefusedBeforeAnyConnect(string sid) + { + const string pbk = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; + + var parse = Assert.Throws(() => + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=reality&pbk={pbk}&sid={sid}")); + Assert.Contains($"'{sid}'", parse.Message); - Assert.Contains(pbk, ex.Message); + var options = new VlessOptions + { + Id = Uuid, Host = "example.com", Port = 443, Security = VlessSecurity.Reality, + RealityPublicKey = pbk, RealityShortId = sid + }; + Assert.Throws(() => new VlessClient(options)); } + [Fact] + public void Reality_PublicKeyWithUnusedTrailingBitsSet_DecodesToTheSameKey() + { + // A 43-character key's last character carries two bits no byte uses. Go ignores them and + // so does .NET 10; .NET 11 alone would refuse the key. + const string canonical = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; + string loose = canonical[..^1] + "h"; + + Assert.True(RealityAuth.TryDecodePublicKey(canonical, out byte[]? expected, out _)); + Assert.True(RealityAuth.TryDecodePublicKey(loose, out byte[]? actual, out _)); + Assert.Equal(expected, actual); + } /// /// REALITY failures are proxy errors like any other: the type carries a code a caller can /// branch on, and the two codes it uses mean different things to act on. diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index bed1225..02b1cf4 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -19,10 +19,14 @@ namespace QuickProxyNet; public sealed class VlessClient : ProxyClient { private readonly List? _alpn; + private readonly byte[]? _realityPublicKey; /// Creates a VLESS client from strongly-typed options. /// is null. - /// The options carry an invalid UUID. + /// + /// The options carry an invalid UUID, or, for REALITY, a public key or short id that cannot be + /// decoded. + /// public VlessClient(VlessOptions options) : base("vless", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) { @@ -37,6 +41,20 @@ public VlessClient(VlessOptions options) "a string of 1..30 characters (which would be mapped to a UUID).", nameof(options)); + // The same for REALITY's key and short id, and for the same reason: decoded inside + // ConnectAsync, a bad one left it as a FormatException, which that call may not throw. + // A missing key is still reported at connect, as the NotSupportedException it has always been. + if (options.Security == VlessSecurity.Reality) + { + if (!string.IsNullOrEmpty(options.RealityPublicKey) && + !RealityAuth.TryDecodePublicKey(options.RealityPublicKey, out _realityPublicKey, out string? keyError)) + throw new ArgumentException(keyError, nameof(options)); + + Span shortId = stackalloc byte[RealityAuth.ShortIdSize]; + if (!RealityAuth.TryParseShortId(shortId, options.RealityShortId, out string? shortIdError)) + throw new ArgumentException(shortIdError, nameof(options)); + } + Options = options; _alpn = BuildAlpn(options.Alpn); } @@ -142,37 +160,13 @@ private TransportKind EnsureSupported() private RealityTlsOptions BuildRealityOptions() => new() { ServerName = Options.Sni ?? Options.HostHeader ?? Options.Host, - PublicKey = DecodeBase64Url(Options.RealityPublicKey!), + // Decoded and length-checked by the constructor; EnsureSupported has already refused a + // REALITY configuration without a key. + PublicKey = _realityPublicKey!, ShortId = string.IsNullOrEmpty(Options.RealityShortId) ? null : Options.RealityShortId, Alpn = Options.Alpn is { Count: > 0 } ? Options.Alpn : ["h2", "http/1.1"] }; - /// Decodes the unpadded base64url that share links carry pbk in. - private static byte[] DecodeBase64Url(string value) - { - string padded = value.Replace('-', '+').Replace('_', '/'); - padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; - - byte[] key; - try - { - key = Convert.FromBase64String(padded); - } - catch (FormatException ex) - { - throw new FormatException( - $"The REALITY public key '{value}' is not valid base64url (expected the 'pbk' value from the share link).", ex); - } - - // Checked here, before any byte is written, so a truncated pbk fails as a configuration - // error with the value named — not as an ArgumentException from inside the handshake. - if (key.Length != X25519.KeySize) - throw new FormatException( - $"The REALITY public key '{value}' decodes to {key.Length} bytes; an X25519 key is {X25519.KeySize}."); - - return key; - } - private SslClientAuthenticationOptions BuildSslOptions() => new() { // Same precedence Xray applies: explicit SNI, else the transport Host header, else the diff --git a/QuickProxyNet/Configs/VlessShareLink.cs b/QuickProxyNet/Configs/VlessShareLink.cs index b5e5b4b..44605df 100644 --- a/QuickProxyNet/Configs/VlessShareLink.cs +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -159,6 +159,19 @@ private static bool TryParse( } } + // A REALITY key or short id that cannot be decoded describes a node nobody can reach, so + // the link is refused here with the value named. The alternative was a FormatException + // out of ConnectAsync, which that call may not throw. + if (security == VlessSecurity.Reality) + { + if (!string.IsNullOrEmpty(pbk) && !RealityAuth.TryDecodePublicKey(pbk, out _, out error)) + return false; + + Span shortId = stackalloc byte[RealityAuth.ShortIdSize]; + if (!RealityAuth.TryParseShortId(shortId, sid, out error)) + return false; + } + string? remark = uri.Fragment.Length > 1 ? Uri.UnescapeDataString(uri.Fragment.Substring(1)) : null; diff --git a/QuickProxyNet/Internal/Reality/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs index 0b7bb43..db2b793 100644 --- a/QuickProxyNet/Internal/Reality/RealityAuth.cs +++ b/QuickProxyNet/Internal/Reality/RealityAuth.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; namespace QuickProxyNet; @@ -196,39 +197,99 @@ public static bool VerifyCertificate( return CryptographicOperations.FixedTimeEquals(expected, certificateSignature); } + /// + /// Decodes a share link's pbk: base64url, usually unpadded, of the server's X25519 key. + /// + /// The pbk text. + /// The 32-byte key, when this returns true. + /// What is wrong with the value, naming it, when this returns false. + public static bool TryDecodePublicKey( + string value, [NotNullWhen(true)] out byte[]? key, [NotNullWhen(false)] out string? error) + { + key = null; + char[] chars = new char[value.Length + 3]; + byte[] decoded = new byte[(value.Length + 3) / 4 * 3]; + + if (!ShareLinkBase64.TryNormalize(value, chars, out int length) || + !Convert.TryFromBase64Chars(chars.AsSpan(0, length), decoded, out int written)) + { + error = $"The REALITY public key '{value}' is not valid base64url (expected the 'pbk' value from the share link)."; + return false; + } + + if (written != X25519.KeySize) + { + error = $"The REALITY public key '{value}' decodes to {written} bytes; an X25519 key is {X25519.KeySize}."; + return false; + } + + key = decoded[..X25519.KeySize]; + error = null; + return true; + } + /// /// Parses the share link's sid — an even-length hex string — into a zero-padded short id. /// - /// Receives 8 bytes. + /// Receives 8 bytes, all zero when this returns false. /// The hex text; may be empty, which is a valid configuration. - /// The text is not hex, or is longer than 8 bytes. - public static void ParseShortId(Span shortId, string? hex) + /// What is wrong with the text, naming it, when this returns false. + public static bool TryParseShortId(Span shortId, string? hex, [NotNullWhen(false)] out string? error) { if (shortId.Length != ShortIdSize) throw new ArgumentException($"The short id is {ShortIdSize} bytes.", nameof(shortId)); shortId.Clear(); + error = null; if (string.IsNullOrEmpty(hex)) - return; + return true; if ((hex.Length & 1) != 0) - throw new FormatException($"A REALITY short id is an even number of hex digits; '{hex}' is not."); + { + error = $"A REALITY short id is an even number of hex digits; '{hex}' is not."; + return false; + } if (hex.Length > ShortIdSize * 2) - throw new FormatException( - $"A REALITY short id is at most {ShortIdSize} bytes ({ShortIdSize * 2} hex digits); " + - $"'{hex}' is {hex.Length / 2}."); - - for (int i = 0; i < hex.Length; i += 2) - shortId[i / 2] = (byte)((ParseNibble(hex[i]) << 4) | ParseNibble(hex[i + 1])); + { + error = $"A REALITY short id is at most {ShortIdSize} bytes ({ShortIdSize * 2} hex digits); " + + $"'{hex}' is {hex.Length / 2}."; + return false; + } - static int ParseNibble(char c) => c switch + for (int i = 0; i < hex.Length; i++) { - >= '0' and <= '9' => c - '0', - >= 'a' and <= 'f' => c - 'a' + 10, - >= 'A' and <= 'F' => c - 'A' + 10, - _ => throw new FormatException($"'{c}' is not a hex digit.") - }; + int nibble = hex[i] switch + { + >= '0' and <= '9' => hex[i] - '0', + >= 'a' and <= 'f' => hex[i] - 'a' + 10, + >= 'A' and <= 'F' => hex[i] - 'A' + 10, + _ => -1 + }; + + if (nibble < 0) + { + shortId.Clear(); + error = $"'{hex[i]}' in the REALITY short id '{hex}' is not a hex digit."; + return false; + } + + shortId[i / 2] |= (byte)(i % 2 == 0 ? nibble << 4 : nibble); + } + + return true; + } + + /// + /// Parses the share link's sid — an even-length hex string — into a zero-padded short id. + /// + /// Receives 8 bytes. + /// The hex text; may be empty, which is a valid configuration. + /// The text is not hex, or is longer than 8 bytes. + public static void ParseShortId(Span shortId, string? hex) + { + if (!TryParseShortId(shortId, hex, out string? error)) + throw new FormatException(error); } } From 9186eebba8e4ab615bab647b5679d8ddfdd5aadb Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 16:09:42 +0500 Subject: [PATCH 23/35] feat!: remove ReadTimeout and WriteTimeout BREAKING CHANGE, for 5.0.0. IProxyClient.ReadTimeout and WriteTimeout are removed with their ProxyClient implementations, and CreateSocket no longer copies them to Socket.ReceiveTimeout and SendTimeout. They were documented as the timeout for sending and receiving data through the proxy, and never were. Socket.ReceiveTimeout and SendTimeout bound only synchronous calls: in a loopback probe an async read with ReceiveTimeout=300 was still pending after 1.5 s, while a synchronous read timed out at 308 ms. Every handshake in the library is asynchronous, so neither property ever applied inside ConnectAsync. The one thing they did do is lost. A caller's own synchronous Read or Write on the returned stream honoured them when that stream was the raw NetworkStream, which is the HTTP and SOCKS tunnels with no overread. Migration: set Stream.ReadTimeout / WriteTimeout on the returned stream when its CanTimeout is true, or pass a CancellationToken to async reads and writes. ConnectAsync(..., TimeSpan timeout, ...) already bounds the connect and the handshake. The RecordingClient test double drops the two members. CorpusCheck's live prober drops two assignments that never took effect, since all its I/O is async. The README loses them from the Factory API example and the options table and says how to bound reads instead, and AGENTS.md records the removal next to ProxyUri's. No test asserted on socket timeouts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- AGENTS.md | 4 ++++ QuickProxyNet.Tests/EndPointConnectTest.cs | 2 -- QuickProxyNet/Clients/ProxyClient.cs | 8 +++----- QuickProxyNet/IProxyClient.cs | 10 ---------- README.md | 8 +++++--- tools/CorpusCheck/LiveProbe.cs | 2 -- 6 files changed, 12 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b2d249..94773af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,10 @@ All public library types live in the `QuickProxyNet` namespace. for the other VPN-style families, and could not even hold a password containing `@`. `Proxy.Create(Uri)` stays as an adapter for `WebProxy.Address` and `IWebProxy.GetProxy`; `ProxyClient.ToString()` is `scheme://host:port` for logs. +- There is no `IProxyClient.ReadTimeout` / `WriteTimeout` (removed in 5.0.0). They were + copied to `Socket.ReceiveTimeout` / `SendTimeout`, which bind only synchronous calls, so + they never applied to a handshake. Do not bring them back: the `TimeSpan` overloads of + `ConnectAsync` bound the handshake, and a caller bounds reads on the returned stream. - `IProxyClient` is the client contract; connection methods return `ValueTask`. `SourceLink` carries the text the client was built from. A target is `host, port` or an `EndPoint` (`DnsEndPoint` / `IPEndPoint`); the diff --git a/QuickProxyNet.Tests/EndPointConnectTest.cs b/QuickProxyNet.Tests/EndPointConnectTest.cs index 8fc7d5f..f7e8b8e 100644 --- a/QuickProxyNet.Tests/EndPointConnectTest.cs +++ b/QuickProxyNet.Tests/EndPointConnectTest.cs @@ -111,8 +111,6 @@ private sealed class RecordingClient : IProxyClient public IPEndPoint? LocalEndPoint { get; set; } public LingerOption? LingerState { get; set; } public bool NoDelay { get; set; } - public int WriteTimeout { get; set; } - public int ReadTimeout { get; set; } public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) => Record(host, port, "host, port"); diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 6287086..2926275 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -58,9 +58,6 @@ public override string ToString() => ProxyHost.Contains(':') public LingerOption? LingerState { get; set; } = new LingerOption(true, 0); public bool NoDelay { get; set; } = true; - public int WriteTimeout { get; set; } - public int ReadTimeout { get; set; } - private Socket CreateSocket() { // Socket(SocketType, ProtocolType) is dual-mode wherever the OS has IPv6, so the proxy is @@ -72,9 +69,10 @@ private Socket CreateSocket() : new Socket(local.AddressFamily, SocketType.Stream, ProtocolType.Tcp); try { + // No SendTimeout or ReceiveTimeout: they bound only synchronous socket calls, and every + // read and write of the handshake is asynchronous. The timeout and the token given to + // ConnectAsync are what bound it. socket.NoDelay = NoDelay; - socket.SendTimeout = WriteTimeout; - socket.ReceiveTimeout = ReadTimeout; if (LingerState is not null) socket.LingerState = LingerState; if (local is not null) diff --git a/QuickProxyNet/IProxyClient.cs b/QuickProxyNet/IProxyClient.cs index 4021b40..ca9a069 100644 --- a/QuickProxyNet/IProxyClient.cs +++ b/QuickProxyNet/IProxyClient.cs @@ -60,16 +60,6 @@ public interface IProxyClient /// bool NoDelay { get; set; } - /// - /// Gets or sets the write timeout in milliseconds for sending data through the proxy. - /// - int WriteTimeout { get; set; } - - /// - /// Gets or sets the read timeout in milliseconds for receiving data through the proxy. - /// - int ReadTimeout { get; set; } - /// /// Asynchronously connects to a target host and port through the proxy. /// diff --git a/README.md b/README.md index a9f4273..97d27dc 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ await using var stream = await Proxy.ConnectAsync( ```csharp var client = Proxy.Create("socks5://proxy:1080"); client.NoDelay = true; -client.ReadTimeout = 5000; await using var stream = await client.ConnectAsync("example.com", 443); ``` @@ -244,10 +243,13 @@ When using the factory/client API, each client supports: |---|---|---| | `NoDelay` | `true` | Disable Nagle algorithm | | `LingerState` | `Linger(true, 0)` | Socket linger on close | -| `ReadTimeout` | `0` (infinite) | Read timeout in ms | -| `WriteTimeout` | `0` (infinite) | Write timeout in ms | | `LocalEndPoint` | `null` | Bind to specific local IP | +There is no read or write timeout on the client. The `TimeSpan` overloads of `ConnectAsync` bound +the connection and the handshake. For reads and writes on the returned stream, pass a +`CancellationToken` to the async calls, or set the stream's own `ReadTimeout` / `WriteTimeout` +when its `CanTimeout` is `true`. + ## License MIT diff --git a/tools/CorpusCheck/LiveProbe.cs b/tools/CorpusCheck/LiveProbe.cs index faceed2..ae1f0fd 100644 --- a/tools/CorpusCheck/LiveProbe.cs +++ b/tools/CorpusCheck/LiveProbe.cs @@ -381,8 +381,6 @@ public async Task ProbeAsync(LiveNode node, CancellationToken cancel try { var client = node.CreateClient(); - client.ReadTimeout = (int)timeout.TotalMilliseconds; - client.WriteTimeout = (int)timeout.TotalMilliseconds; // ConnectAsync's own timer aborts the socket, but DNS resolution happens before // the socket exists, so keep an outer hard bound as well. From 234968ff683af11264bdcad963f9a5237e90ea33 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 16:23:59 +0500 Subject: [PATCH 24/35] fix(reality): be as strict as Go's client around the server's Finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places where the managed REALITY client accepted more than Go's crypto/tls client does. Neither was exploitable: the bytes only reached a caller once the peer had also passed the REALITY certificate HMAC check, so they came from the server itself. But no real server needs either leniency, and REALITY's server side is Go's crypto/tls. Application data before the server's Finished. A record of application data under the server's handshake keys was kept in a list and handed to the caller as the first bytes of the tunnel. RFC 8446 §2 says application data MUST NOT be sent before the Finished, and Go's client refuses it in readRecordOrCCS (src/crypto/tls/conn.go): case recordTypeApplicationData: if !handshakeComplete || expectChangeCipherSpec { return c.in.setErrorLocked(c.sendAlert(alertUnexpectedMessage)) } It is now a RealityHandshakeException, and the plumbing that existed only for it is gone: the Leftover list and its 64 KiB cap, the cap of 64 empty application-data records during the handshake (Go refuses the first, empty or not), and RealityTlsStream's third constructor parameter with the copy it fed. The handshake loop stays bounded without them; ChangeCipherSpec count, flight length and message size keep their own limits. Handshake bytes after the server's Finished. Once the Finished is read, reads switch to the application keys, and handshake bytes left over from the Finished's record were dropped without a word. RFC 8446 §5.1: handshake messages MUST NOT span key changes, and an implementation that detects one MUST terminate with unexpected_message. The client already checked this after the ServerHello. It now checks after the Finished too, where Go does: certificate and Finished verified, its own Finished not yet sent. Go's check sits in conn.go and is called from readServerFinished in handshake_client_tls13.go: func (c *Conn) setReadTrafficSecret(...) error { // Ensure that there are no buffered handshake messages before changing the // read keys, since that can cause messages to be parsed that were encrypted // using old keys which are no longer appropriate. if c.handLen() != 0 { ... return errors.New("tls: handshake buffer not empty before setting read traffic secret") } Tests: HostilePeerTest gains a peer that holds real keys: an X25519 exchange, the RFC 8446 key schedule, a certificate bound to the REALITY auth key, and a Finished over the real transcript. It is the only way to reach a check made after the ServerHello. Its unbent flight, with application data under the application keys in the same burst, completes and delivers that data. That control is what shows the two refusals are caused by the bent part: application data under the handshake keys before the Finished, and a NewSessionTicket sharing the Finished's record. Against the previous client both refusal tests fail, because it completed the handshake. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- AGENTS.md | 13 + .../RealityTlsSocketBenchmark.cs | 4 +- .../RealityTlsStreamBenchmark.cs | 6 +- QuickProxyNet.Tests/HostilePeerTest.cs | 320 +++++++++++++++++- QuickProxyNet.Tests/TlsRecordStreamTest.cs | 6 +- .../Internal/Reality/RealityTlsClient.cs | 65 ++-- .../Internal/Reality/RealityTlsStream.cs | 10 +- 7 files changed, 373 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 94773af..422d5ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,10 +152,23 @@ independent: transcribed from `XTLS/REALITY`'s `tls.go`. - `HostilePeerTest` — a scripted malformed or hostile peer, in memory. This is the only suite that can reach the failure modes a cooperating server never produces. + Its `KeyedServer` derives real keys and a certificate bound to the REALITY auth key, + which is what reaches the checks after the ServerHello. Its unbent flight is a test + of its own, so a refusal there cannot be a mistake in the peer. - `Integration/Managed*` — real handshakes and real tunnels against Xray-core, with a REALITY server whose `dest` points at a decoy TLS inbound in the same process, so nothing leaves the machine. +**Strict where Go's client is strict.** The server side is Go's crypto/tls, so leniency +Go's client does not have buys nothing. Application data before the server's Finished is +refused, not buffered for the stream (RFC 8446 §2; Go's `readRecordOrCCS` sends +`unexpected_message` while the handshake is incomplete, even for an empty record). Handshake +bytes still buffered when the read keys change — after the ServerHello and after the +Finished — are refused (RFC 8446 §5.1; Go's `setReadTrafficSecret`). A server's first +application data comes under its application keys, possibly in the same transport read as +its flight. That is safe because the record layer decrypts a record only when it is asked +for one, by which point the application keys are in place. + **Public shape, decided:** REALITY is reached through `VlessClient` — `security=reality` in `VlessOptions`, or simply the share link via `Proxy.Create(string)`. Nothing under `Internal/Reality/` is public except `RealityHandshakeException`, which is a diff --git a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs index d3f5c06..eaa1812 100644 --- a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs @@ -175,7 +175,7 @@ public void Setup() { Write = new TlsRecordProtection(suite, sinkSecret) }; - _sink = new RealityTlsStream(drainTransport, sinkRecords, []); + _sink = new RealityTlsStream(drainTransport, sinkRecords); var echoTransport = new NetworkStream(_echo.Client, ownsSocket: false); var clientRecords = new TlsRecordStream(echoTransport) @@ -183,7 +183,7 @@ public void Setup() Write = new TlsRecordProtection(suite, pairSecret), Read = new TlsRecordProtection(suite, pairSecret) }; - _client = new RealityTlsStream(echoTransport, clientRecords, []); + _client = new RealityTlsStream(echoTransport, clientRecords); } [GlobalCleanup] diff --git a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs index edf91e2..d53ba7c 100644 --- a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs @@ -76,7 +76,7 @@ public void Setup() { Write = new TlsRecordProtection(suite, sinkSecret) }; - _sink = new RealityTlsStream(Stream.Null, sinkRecords, []); + _sink = new RealityTlsStream(Stream.Null, sinkRecords); // Room for 1 MiB of plaintext plus per-record headers and tags, so the stream never grows // during a measured operation. @@ -91,8 +91,8 @@ public void Setup() Read = new TlsRecordProtection(suite, pairSecret) }; - _writer = new RealityTlsStream(_wire, writerRecords, []); - _reader = new RealityTlsStream(_wire, readerRecords, []); + _writer = new RealityTlsStream(_wire, writerRecords); + _reader = new RealityTlsStream(_wire, readerRecords); } [GlobalCleanup] diff --git a/QuickProxyNet.Tests/HostilePeerTest.cs b/QuickProxyNet.Tests/HostilePeerTest.cs index 6110ac9..20fc571 100644 --- a/QuickProxyNet.Tests/HostilePeerTest.cs +++ b/QuickProxyNet.Tests/HostilePeerTest.cs @@ -1,3 +1,5 @@ +using System.Formats.Asn1; +using System.Security.Cryptography; namespace QuickProxyNet.Tests; @@ -63,7 +65,8 @@ private static byte[] ServerHello( byte compression = 0, bool supportedVersions = true, bool keyShare = true, - ReadOnlySpan random = default) + ReadOnlySpan random = default, + byte[]? keyShareValue = null) { var body = new List { 0x03, 0x03 }; @@ -85,7 +88,7 @@ private static byte[] ServerHello( if (keyShare) { extensions.AddRange(new byte[] { 0, 51, 0, 36, 0x00, 0x1D, 0x00, 0x20 }); - extensions.AddRange(new byte[32]); + extensions.AddRange(keyShareValue ?? new byte[32]); } body.Add((byte)(extensions.Count >> 8)); @@ -102,13 +105,14 @@ private static byte[] ServerHello( return message; } - private static async Task ExpectRefusalAsync(Func respond) + private static async Task ExpectRefusalAsync( + Func respond, RealityTlsOptions? options = null) { await using var peer = new ScriptedPeer(respond); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var ex = await Assert.ThrowsAsync( - async () => await RealityTlsClient.HandshakeAsync(peer, Options(), timeout.Token)); + async () => await RealityTlsClient.HandshakeAsync(peer, options ?? Options(), timeout.Token)); // Nothing a hostile peer does here is "we were not recognised": it is the peer breaking // the protocol, and the code must say so — AuthFailed is reserved for the decoy relay. @@ -316,6 +320,314 @@ public async Task EmptyHandshakeRecord_IsRefused() Assert.Contains("zero-length", ex.Message); } + // ---- A peer that holds real keys ---- + + /// How bends its flight, if at all. + private enum Flight + { + /// A correct flight, then application data under the application keys. + WellFormed, + + /// Application data under the handshake keys, between CertificateVerify and Finished. + ApplicationDataBeforeFinished, + + /// A NewSessionTicket in the same handshake-key record as the Finished. + HandshakeBytesAfterFinished + } + + /// The application data a keyed server sends. + private static ReadOnlySpan ServerGreeting => "sent by the server"u8; + + /// + /// The control for the keyed tests: the same peer with nothing bent is accepted, and the + /// application data it sends after its Finished, in the same burst, reaches the caller. + /// + /// + /// Without this, a refusal in a keyed test could be a mistake in the peer rather than the check + /// under test. It is also the legitimate form of what + /// sends. + /// + [Fact] + public async Task KeyedServer_WellFormedFlight_CompletesAndCarriesData() + { + (RealityTlsOptions options, Func respond) = KeyedServer(Flight.WellFormed); + await using var peer = new ScriptedPeer(respond); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await using Stream tunnel = await RealityTlsClient.HandshakeAsync(peer, options, timeout.Token); + + byte[] received = new byte[64]; + int count = await tunnel.ReadAsync(received, timeout.Token); + Assert.Equal(ServerGreeting.ToArray(), received[..count]); + } + + /// + /// Application data protected by the server's handshake keys, sent before its Finished. The + /// keys are genuine, so this is the server itself breaking TLS 1.3, not an injection. + /// + /// + /// RFC 8446 §2: application data MUST NOT be sent before the Finished. Go's client answers it + /// with unexpected_message from readRecordOrCCS. This client used to keep it and hand it + /// to the caller as the first bytes of the tunnel. + /// + [Fact] + public async Task ApplicationDataUnderHandshakeKeys_IsRefused() + { + (RealityTlsOptions options, Func respond) = KeyedServer(Flight.ApplicationDataBeforeFinished); + + RealityHandshakeException ex = await ExpectRefusalAsync(respond, options); + + Assert.Contains("application data before its Finished", ex.Message); + } + + /// + /// A handshake message after the server's Finished in the same record, and so under the + /// handshake keys, although reads switch to the application keys right after the Finished. + /// + /// + /// RFC 8446 §5.1: handshake messages MUST NOT span key changes, and a receiver that sees one + /// MUST abort with unexpected_message. Go's client checks it in setReadTrafficSecret. + /// This client checked only after the ServerHello, and dropped these bytes without a word. + /// + [Fact] + public async Task HandshakeBytesAfterFinishedInItsRecord_AreRefused() + { + (RealityTlsOptions options, Func respond) = KeyedServer(Flight.HandshakeBytesAfterFinished); + + RealityHandshakeException ex = await ExpectRefusalAsync(respond, options); + + Assert.Contains("record that carried its Finished", ex.Message); + } + + /// + /// A REALITY server for one connection: its own REALITY key pair, and a reply computed from the + /// ClientHello the client actually sent. + /// + /// + /// Everything the client verifies is genuine: an X25519 exchange, the RFC 8446 key schedule, a + /// certificate whose signature field is the HMAC of the REALITY auth key, and a Finished over + /// the real transcript. So when a flight built here is refused, the bent part is the only thing + /// left to refuse. The CertificateVerify signature is filler, because the client does not check + /// it: the certificate HMAC subsumes it. + /// + private static (RealityTlsOptions Options, Func Respond) KeyedServer(Flight flight) + { + byte[] realityPrivateKey = new byte[X25519.KeySize]; + byte[] realityPublicKey = new byte[X25519.KeySize]; + X25519.GenerateKeyPair(realityPrivateKey, realityPublicKey); + + RealityTlsOptions options = new() + { + ServerName = "qpn.test", + PublicKey = realityPublicKey, + ShortId = "ab12" + }; + + return (options, clientHello => KeyedFlight(clientHello, realityPrivateKey, flight)); + } + + private static byte[] KeyedFlight(byte[] clientHello, byte[] realityPrivateKey, Flight flight) + { + TlsCipherSuite suite = TlsCipherSuite.FromId(0x1301)!; + HashAlgorithmName hash = suite.Hash; + byte[] zeros = new byte[suite.HashLength]; + byte[] emptyHash = SHA256.HashData(ReadOnlySpan.Empty); + + byte[] clientKeyShare = ClientKeyShare(clientHello); + byte[] ephemeralPrivateKey = new byte[X25519.KeySize]; + byte[] ephemeralPublicKey = new byte[X25519.KeySize]; + X25519.GenerateKeyPair(ephemeralPrivateKey, ephemeralPublicKey); + + byte[] serverHello = ServerHello( + clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize), + keyShareValue: ephemeralPublicKey); + + using IncrementalHash transcript = IncrementalHash.CreateHash(hash); + transcript.AppendData(clientHello); + transcript.AppendData(serverHello); + + // RFC 8446 §7.1, from the server's side of the same exchange. + byte[] shared = new byte[X25519.KeySize]; + X25519.Agree(shared, ephemeralPrivateKey, clientKeyShare); + + byte[] early = new byte[suite.HashLength]; + byte[] derived = new byte[suite.HashLength]; + byte[] handshakeSecret = new byte[suite.HashLength]; + byte[] serverHandshakeTraffic = new byte[suite.HashLength]; + byte[] masterSecret = new byte[suite.HashLength]; + byte[] serverApplicationTraffic = new byte[suite.HashLength]; + + TlsKeySchedule.Extract(hash, zeros, zeros, early); + TlsKeySchedule.DeriveSecret(hash, early, "derived"u8, emptyHash, derived); + TlsKeySchedule.Extract(hash, derived, shared, handshakeSecret); + TlsKeySchedule.DeriveSecret( + hash, handshakeSecret, "s hs traffic"u8, transcript.GetCurrentHash(), serverHandshakeTraffic); + TlsKeySchedule.DeriveSecret(hash, handshakeSecret, "derived"u8, emptyHash, derived); + TlsKeySchedule.Extract(hash, derived, zeros, masterSecret); + + // REALITY: the auth key the client derives, from the other half of its key_share exchange, + // and a leaf certificate whose signature field is the HMAC the client checks. + byte[] authKey = new byte[RealityAuth.AuthKeySize]; + RealityAuth.DeriveAuthKey(authKey, realityPrivateKey, clientKeyShare, clientHello.AsSpan(6, 32)); + byte[] leafPublicKey = RandomNumberGenerator.GetBytes(32); + byte[] certificate = Ed25519Certificate(leafPublicKey, HMACSHA512.HashData(authKey, leafPublicKey)); + + byte[] encryptedExtensions = HandshakeMessage(8, [0, 0]); + byte[] certificateMessage = HandshakeMessage( + 11, [0, .. UInt24(certificate.Length + 5), .. UInt24(certificate.Length), .. certificate, 0, 0]); + byte[] certificateVerify = HandshakeMessage(15, [0x08, 0x07, 0, 64, .. new byte[64]]); + + transcript.AppendData(encryptedExtensions); + transcript.AppendData(certificateMessage); + transcript.AppendData(certificateVerify); + + byte[] verifyData = new byte[suite.HashLength]; + TlsKeySchedule.FinishedVerifyData(hash, serverHandshakeTraffic, transcript.GetCurrentHash(), verifyData); + byte[] finished = HandshakeMessage(20, verifyData); + + transcript.AppendData(finished); + TlsKeySchedule.DeriveSecret( + hash, masterSecret, "s ap traffic"u8, transcript.GetCurrentHash(), serverApplicationTraffic); + + using var handshakeKeys = new TlsRecordProtection(suite, serverHandshakeTraffic); + using var applicationKeys = new TlsRecordProtection(suite, serverApplicationTraffic); + + byte[] beforeFinished = [.. encryptedExtensions, .. certificateMessage, .. certificateVerify]; + + var wire = new List(); + wire.AddRange(Record(TlsContentTypeForTests.Handshake, serverHello)); + wire.AddRange(Record(TlsContentTypeForTests.ChangeCipherSpec, [1])); + + switch (flight) + { + case Flight.WellFormed: + wire.AddRange(Sealed(handshakeKeys, TlsContentTypeForTests.Handshake, [.. beforeFinished, .. finished])); + wire.AddRange(Sealed(applicationKeys, TlsContentTypeForTests.ApplicationData, ServerGreeting)); + break; + + case Flight.ApplicationDataBeforeFinished: + wire.AddRange(Sealed(handshakeKeys, TlsContentTypeForTests.Handshake, beforeFinished)); + wire.AddRange(Sealed(handshakeKeys, TlsContentTypeForTests.ApplicationData, ServerGreeting)); + wire.AddRange(Sealed(handshakeKeys, TlsContentTypeForTests.Handshake, finished)); + break; + + case Flight.HandshakeBytesAfterFinished: + // A NewSessionTicket belongs under the application keys. Sharing the Finished's + // record puts it under the handshake keys instead, across the key change. + byte[] ticket = HandshakeMessage(4, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0]); + wire.AddRange(Sealed( + handshakeKeys, TlsContentTypeForTests.Handshake, [.. beforeFinished, .. finished, .. ticket])); + break; + } + + return wire.ToArray(); + } + + /// Finds the client's X25519 key_share in its raw ClientHello, as a server has to. + private static byte[] ClientKeyShare(byte[] clientHello) + { + int offset = RealityAuth.SessionIdOffset + RealityAuth.SessionIdSize; + offset += 2 + ((clientHello[offset] << 8) | clientHello[offset + 1]); // cipher suites + offset += 1 + clientHello[offset]; // compression methods + int end = offset + 2 + ((clientHello[offset] << 8) | clientHello[offset + 1]); + offset += 2; + + while (offset < end) + { + int type = (clientHello[offset] << 8) | clientHello[offset + 1]; + int length = (clientHello[offset + 2] << 8) | clientHello[offset + 3]; + offset += 4; + + // key_share: the list length, then each entry's group and key length ahead of its key. + if (type == 51 && clientHello[offset + 2] == 0x00 && clientHello[offset + 3] == 0x1D) + return clientHello.AsSpan(offset + 6, X25519.KeySize).ToArray(); + + offset += length; + } + + throw new InvalidOperationException("The ClientHello offers no X25519 key_share."); + } + + private static byte[] HandshakeMessage(byte type, ReadOnlySpan body) + { + byte[] message = new byte[4 + body.Length]; + message[0] = type; + UInt24(body.Length).CopyTo(message, 1); + body.CopyTo(message.AsSpan(4)); + return message; + } + + private static byte[] UInt24(int value) => [(byte)(value >> 16), (byte)(value >> 8), (byte)value]; + + /// Seals one record under , with its real type inside. + private static byte[] Sealed(TlsRecordProtection keys, TlsContentTypeForTests type, ReadOnlySpan content) + { + byte[] inner = [.. content, (byte)type]; + int length = inner.Length + TlsCipherSuite.TagLength; + + byte[] record = new byte[5 + length]; + record[0] = (byte)TlsContentTypeForTests.ApplicationData; + record[1] = 3; + record[2] = 3; + record[3] = (byte)(length >> 8); + record[4] = (byte)length; + + keys.Protect( + inner, + record.AsSpan(5, inner.Length), + record.AsSpan(5 + inner.Length, TlsCipherSuite.TagLength), + record.AsSpan(0, 5)); + + return record; + } + + /// + /// A minimal DER certificate carrying an Ed25519 key and the given signature field, which is + /// all the client reads out of a REALITY certificate. + /// + private static byte[] Ed25519Certificate(byte[] publicKey, byte[] signature) + { + var writer = new AsnWriter(AsnEncodingRules.DER); + + using (writer.PushSequence()) + { + using (writer.PushSequence()) // tbsCertificate + { + using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0, isConstructed: true))) + writer.WriteInteger(2); // version: v3 + + writer.WriteInteger(1); // serialNumber + WriteEd25519Algorithm(writer); + writer.WriteEncodedValue([0x30, 0x00]); // issuer: an empty name + + using (writer.PushSequence()) // validity + { + writer.WriteUtcTime(DateTimeOffset.UtcNow.AddDays(-1)); + writer.WriteUtcTime(DateTimeOffset.UtcNow.AddDays(1)); + } + + writer.WriteEncodedValue([0x30, 0x00]); // subject: an empty name + + using (writer.PushSequence()) // subjectPublicKeyInfo + { + WriteEd25519Algorithm(writer); + writer.WriteBitString(publicKey); + } + } + + WriteEd25519Algorithm(writer); + writer.WriteBitString(signature); + } + + return writer.Encode(); + } + + private static void WriteEd25519Algorithm(AsnWriter writer) + { + using (writer.PushSequence()) + writer.WriteObjectIdentifier("1.3.101.112"); + } + /// /// A transport that captures what the client writes and replays a scripted answer. /// diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index d9a7780..bbbe0da 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -386,7 +386,7 @@ public async Task AfterHandshake_EmptyRecordFlood_EndsTheReadInAnError() var transport = new MemoryStream(wire.ToArray()); var records = new TlsRecordStream(transport) { Read = new TlsRecordProtection(suite, secret) }; - await using var tls = new RealityTlsStream(transport, records, []); + await using var tls = new RealityTlsStream(transport, records); var ex = await Assert.ThrowsAsync(async () => await tls.ReadAsync(new byte[64])); @@ -405,7 +405,7 @@ public void AfterHandshake_SyncSpanWriteAndRead_RoundTripThroughTheRecordLayer() var wire = new MemoryStream(); var writerRecords = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }; - using (var writer = new RealityTlsStream(wire, writerRecords, [])) + using (var writer = new RealityTlsStream(wire, writerRecords)) { writer.Write("hel"u8); writer.WriteByte((byte)'l'); @@ -414,7 +414,7 @@ public void AfterHandshake_SyncSpanWriteAndRead_RoundTripThroughTheRecordLayer() var transport = new MemoryStream(wire.ToArray()); var readerRecords = new TlsRecordStream(transport) { Read = new TlsRecordProtection(suite, secret) }; - using var reader = new RealityTlsStream(transport, readerRecords, []); + using var reader = new RealityTlsStream(transport, readerRecords); Assert.Equal((int)'h', reader.ReadByte()); diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index e682ba5..c798180 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -248,6 +248,19 @@ public static async ValueTask HandshakeAsync( // ---- The REALITY decision ---- AssertRealityServer(leafCertificate, authKey, options.ServerName); + // The Finished is the last message under the server's handshake keys; reads switch + // to its application keys below. RFC 8446 §5.1: a handshake message must not span + // that change, and one that does ends the connection — the same rule as after the + // ServerHello. Without the check, whatever followed the Finished in its record would + // be dropped without a word. Go's client makes it at the same point, once the + // certificate and the Finished are verified and before its own Finished is sent + // (setReadTrafficSecret, from readServerFinished). + if (messages.HasBufferedBytes) + throw new RealityHandshakeException( + "The server sent more handshake data in the record that carried its Finished. A " + + "handshake message must not span the change to application keys, so the connection " + + "is refused."); + byte[] transcriptAfterServerFinished = transcript.GetCurrentHash(); // ---- Client Finished ---- @@ -276,7 +289,7 @@ await records.WriteAsync(TlsContentType.ChangeCipherSpec, ChangeCipherSpecPayloa records.Write = new TlsRecordProtection(parsed.Suite, secrets.ClientApplicationTraffic); records.Read = new TlsRecordProtection(parsed.Suite, secrets.ServerApplicationTraffic); - return new RealityTlsStream(transport, records, messages.Leftover); + return new RealityTlsStream(transport, records); } finally { @@ -308,7 +321,6 @@ await records.WriteAsync(TlsContentType.ChangeCipherSpec, ChangeCipherSpecPayloa if (hello.PrivateKey is not null) CryptographicOperations.ZeroMemory(hello.PrivateKey); - // Safe here: the stream returned above has already copied whatever Leftover held. messages?.Return(); } } @@ -573,17 +585,14 @@ private static byte[] ExtractLeafCertificate(ReadOnlyMemory body) /// /// A handshake message may span records and several may share one, so the record boundary /// carries no meaning here. Anything that is not a handshake record is either dropped - /// (ChangeCipherSpec) or fatal (Alert); application data arriving mid-handshake is kept for - /// the stream, since the server may coalesce it with its last flight. + /// (ChangeCipherSpec) or fatal: an Alert, or application data, which TLS 1.3 allows + /// only once the server's Finished has brought in the keys that carry it. /// private sealed class HandshakeReader(TlsRecordStream records) { /// Largest handshake message we will reassemble, well past any real one. private const int MaxHandshakeMessage = 1 << 18; - /// Cap on early application data, so a flood cannot exhaust memory. - private const int MaxLeftover = 1 << 16; - /// /// Cap on ChangeCipherSpec records, which carry no meaning and are dropped. /// @@ -593,28 +602,18 @@ private sealed class HandshakeReader(TlsRecordStream records) /// private const int MaxChangeCipherSpec = 8; - /// - /// Cap on empty application-data records during the handshake. Each is legal on its own - /// and carries nothing; a stream of them is a peer keeping us busy. - /// - private const int MaxEmptyRecords = 64; - private byte[] _buffer = ArrayPool.Shared.Rent(TlsRecordStream.MaxCiphertext); private int _length; private int _consumed; private int _changeCipherSpecSeen; - private int _emptyRecordsSeen; - - /// Application data that arrived before the handshake finished. - public List Leftover { get; } = []; - - /// Whether any handshake bytes are still buffered but unconsumed. - public bool HasBufferedBytes => _length - _consumed > 0; /// - /// Returns the reassembly buffer to the pool. is a separate list - /// and stays valid afterwards. + /// Whether any handshake bytes are still buffered but unconsumed. Checked at each change + /// of read keys, because a handshake message must not span one. /// + public bool HasBufferedBytes => _length - _consumed > 0; + + /// Returns the reassembly buffer to the pool. public void Return() { if (_buffer.Length == 0) @@ -660,18 +659,16 @@ public async ValueTask NextAsync(CancellationToken cancellatio "than passed to the caller."); case TlsContentType.ApplicationData: - if (record.Payload.IsEmpty && ++_emptyRecordsSeen > MaxEmptyRecords) - throw new RealityHandshakeException( - $"The peer sent more than {MaxEmptyRecords} empty application-data records " + - "during the handshake."); - - if (Leftover.Count + record.Payload.Length > MaxLeftover) - throw new RealityHandshakeException( - $"The peer sent more than {MaxLeftover} bytes of application data before " + - "finishing its handshake."); - - Leftover.AddRange(record.Payload.ToArray()); - continue; + // Protected by the server's handshake keys, so it did come from the peer + // we are negotiating with, but TLS 1.3 has no place for it yet: application + // data follows the sender's Finished, under the keys that Finished + // introduces (RFC 8446 §2). Go's client answers any application_data before + // its handshake is complete with unexpected_message, empty records included, + // and a REALITY server, being Go's crypto/tls underneath, does not send one. + // Refusing it outright also leaves nothing to buffer and nothing to bound. + throw new RealityHandshakeException( + "The server sent application data before its Finished. TLS 1.3 carries application " + + "data only under the keys that the Finished introduces, so it is refused."); case TlsContentType.Handshake: // RFC 8446 §5.1: zero-length handshake records are forbidden. Accepting diff --git a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs index 36ef05b..db5e432 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -41,14 +41,14 @@ internal sealed class RealityTlsStream : Stream private int _recordsWithoutData; - internal RealityTlsStream(Stream transport, TlsRecordStream records, List leftover) + /// + /// Starts with nothing pending. The handshake refuses application data that arrives before + /// the server's Finished, as Go's client does, so there is never any to carry over. + /// + internal RealityTlsStream(Stream transport, TlsRecordStream records) { _transport = transport; _records = records; - - // The one case that must be copied: the leftover comes from the handshake reader's list, - // which does not survive. - _pending = leftover.Count > 0 ? leftover.ToArray() : ReadOnlyMemory.Empty; } public override bool CanRead => !_disposed; From 969b0d649df2c90e86d4c2315feeaf87124918af Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 16:46:20 +0500 Subject: [PATCH 25/35] fix(socks): size the request buffer for the largest SOCKS4a request SocksHelper rents one buffer for every message it writes, sized by BufferSize = 513, with a comment naming SOCKS5 username/password authentication as the largest message. It is not. A SOCKS4a request is VN, CD, DSTPORT and DSTIP, a user id of up to 255 bytes and its NUL, and a host of up to 255 bytes and its NUL: 520 bytes. It worked only because ArrayPool.Shared.Rent(513) returns a 1024-byte array. A buffer of exactly 513 would have refused a host of 250 to 255 bytes after a 255-byte user id as too long, and thrown IndexOutOfRangeException for a 249-byte one when writing its NUL. The constant is now 520. Its comment lists the largest size of every message the helper builds, and of the replies it reads into the same buffer. Nothing changes at run time, because the pool hands out the same array. Test: BufferSize_HoldsTheLargestMessageTheHelperWrites sends the longest SOCKS4a request and the longest SOCKS5 authentication through a stream that records its largest write. It checks the SOCKS4a bytes and requires BufferSize to equal the larger of the two writes. BufferSize is internal so the test can read it. Against the old constant, made internal but still 513, the test fails on both TFMs with expected 513, actual 520. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/Socks5HelperTest.cs | 67 ++++++++++++++++++++++++- QuickProxyNet/Internal/SocksHelper.cs | 15 ++++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/QuickProxyNet.Tests/Socks5HelperTest.cs b/QuickProxyNet.Tests/Socks5HelperTest.cs index 2865dff..4487adc 100644 --- a/QuickProxyNet.Tests/Socks5HelperTest.cs +++ b/QuickProxyNet.Tests/Socks5HelperTest.cs @@ -143,7 +143,7 @@ public async Task Socks5_ConnectFailed_Throws() Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); } - // ArrayPool rounds the 513-byte request buffer up to 1024, so a string of 256 to about 1000 + // ArrayPool rounds the 520-byte request buffer up to 1024, so a string of 256 to about 1000 // UTF-8 bytes fits the buffer but not the one-byte length field. It used to escape as a raw // OverflowException, outside ConnectAsync's exception contract. [Theory] @@ -188,6 +188,71 @@ public async Task Socks4_UserIdOver255Bytes_IsSocksStringTooLong() Assert.Equal(ProxyErrorCode.SocksStringTooLong, ex.ErrorCode); } + /// + /// The request buffer must hold the largest message the helper writes. It was sized for the + /// SOCKS5 username and password message, 513 bytes, while a SOCKS4a request carrying a + /// 255-byte user id and a 255-byte host is 520. Nothing failed only because ArrayPool hands + /// out 1024 bytes for either size, which is also why the size is checked here directly. + /// + [Fact] + public async Task BufferSize_HoldsTheLargestMessageTheHelperWrites() + { + string longest = new('x', 255); + byte[] longestBytes = Encoding.ASCII.GetBytes(longest); + + var socks4a = new WriteRecordingStream([0, 90, 0, 0, 0, 0, 0, 0]); + await SocksHelper.EstablishSocks4TunnelAsync(socks4a, true, longest, 443, + new NetworkCredential(longest, ""), CancellationToken.None); + + byte[] request = [4, 1, 443 >> 8, 443 & 0xFF, 0, 0, 0, 255, .. longestBytes, 0, .. longestBytes, 0]; + Assert.Equal(request, socks4a.Written); + + var socks5 = new WriteRecordingStream([5, 2, 1, 0, 5, 0, 0, 1, 0, 0, 0, 0, 0, 0]); + await SocksHelper.EstablishSocks5TunnelAsync(socks5, longest, 443, + new NetworkCredential(longest, longest), CancellationToken.None); + + Assert.Equal(520, socks4a.LargestWrite); + Assert.Equal(513, socks5.LargestWrite); + Assert.Equal(SocksHelper.BufferSize, Math.Max(socks4a.LargestWrite, socks5.LargestWrite)); + } + + /// Replays a scripted reply, and records what was written and the largest single write. + private sealed class WriteRecordingStream(byte[] reply) : Stream + { + private readonly MemoryStream _reply = new(reply); + private readonly MemoryStream _written = new(); + + public byte[] Written => _written.ToArray(); + + public int LargestWrite { get; private set; } + + 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 void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => _reply.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + _reply.ReadAsync(buffer, ct); + + public override void Write(byte[] buffer, int offset, int count) + { + LargestWrite = Math.Max(LargestWrite, count); + _written.Write(buffer, offset, count); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + { + LargestWrite = Math.Max(LargestWrite, buffer.Length); + return _written.WriteAsync(buffer, ct); + } + } + [Fact] public async Task Socks5_WrongVersion_Throws() { diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index 8e444e9..eb070c5 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -9,8 +9,17 @@ namespace QuickProxyNet; internal static class SocksHelper { - // Largest possible message size is 513 bytes (Socks5 username & password auth) - private const int BufferSize = 513; + // One buffer holds every message this helper writes, so it is sized for the largest of them: + // SOCKS4a request VN(1) CD(1) DSTPORT(2) DSTIP(4) USERID(255) NUL(1) HOST(255) NUL(1) 520 + // SOCKS5 auth VER(1) ULEN(1) UNAME(255) PLEN(1) PASSWD(255) 513 + // SOCKS4 request VN(1) CD(1) DSTPORT(2) DSTIP(4) USERID(255) NUL(1) 264 + // SOCKS5 request VER(1) CMD(1) RSV(1) ATYP(1) LEN(1) DST.ADDR(255) DST.PORT(2) 262 + // SOCKS5 greeting VER(1) NMETHODS(1) METHODS(2) 4 + // Replies are read into the same buffer and are smaller: at most 257 bytes in one read (the + // rest of a SOCKS5 reply naming a domain), 8 for SOCKS4. It was 513, which a SOCKS4a request + // with a long user id and host does not fit; ArrayPool rounding the rental up to 1024 is the + // only reason that worked. + internal const int BufferSize = 520; private const int ProtocolVersion4 = 4; private const int ProtocolVersion5 = 5; private const int SubnegotiationVersion = 1; // Socks5 username & password auth @@ -288,7 +297,7 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName) { // The length goes out as a single byte, so the write is capped at 255 whatever room the - // rented buffer has. ArrayPool rounds 513 up to 1024, and a string that fit the buffer but + // rented buffer has. ArrayPool rounds 520 up to 1024, and a string that fit the buffer but // not the length byte used to escape ConnectAsync as an OverflowException from the cast. if (!Encoding.UTF8.TryGetBytes(chars, buffer[..Math.Min(buffer.Length, 255)], out int written)) throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, From 1e3304649ea615b95dfcf6f434d8aecf5350bbbe Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 16:55:08 +0500 Subject: [PATCH 26/35] fix: refuse control characters in a target host, and a NUL in a SOCKS4 user id HTTP CONNECT writes the target host into the request line and the Host header as it is, and ValidateArguments checked only null, empty and length. Reproduced against a loopback proxy: Proxy.Create("http://127.0.0.1:P").ConnectAsync("example.com HTTP/1.1\r\nX-Injected: yes\r\n\r\n GET /admin HTTP/1.1\r\nHost: internal.local\r\nX-Pad: x", 443) returned a stream, and the proxy received an injected header and a second request. SOCKS4 and SOCKS4a strings are NUL-terminated. A host "good.example\0GET /admin HTTP/1.1\r\n\r\n" over socks4a put everything after the NUL on the wire as tunnel data. A Socks4aClient with the user id "alice\0evil.example" asking for good.example made a SOCKS4a server read user alice and host evil.example. A target host containing a space or an ASCII control character (0x00-0x1F, 0x7F) is the caller's mistake, so ProxyClient.ValidateArguments throws ArgumentException for it. The message names the code point and index and does not repeat the host. Non-ASCII names are still allowed. The check lives there, not in HttpHelper and SocksHelper, because it is about the argument, not one wire format. No host name contains these characters, and a NUL cuts a name short wherever it reaches a resolver: on this machine Dns.GetHostAddresses("localhost\0evil.example") returned localhost's addresses. ValidateArguments did not run on every path. ConnectAsync(host, port) and the EndPoint overloads reached it, but ConnectAsync(Stream, host, port), which callers use directly, skipped it on all nine clients. Each override now runs it before any byte is written or any TLS handshake starts. That brings the rest of the check to those overloads too: a null or empty host, a host over 255 characters, and a port outside 1-65535 are now ArgumentException there, as they already were elsewhere. Before, a null host reached the protocol code (a NullReferenceException in HttpHelper), and a port of 65616 would have gone out as 80 in a SOCKS5 request. A SOCKS4 user id containing a NUL is refused where the credential enters: the Socks4Client and Socks4aClient constructors that take a NetworkCredential. Proxy.Create also reaches them for socks4:// and socks4a:// links with a user. NetworkCredential is mutable, so SocksHelper checks again before writing the request. The message never contains the user id. The new tests fail against the old library (stashed, with the new tests kept): 20 of 243 in the affected classes on each TFM. - HttpHelperTest: the reproduction through every overload against LoopbackConnectProxy, and NUL, TAB, LF, CR, 0x1F, space and DEL in a host through the stream overload. - ProxyFactoryTest: a host with CR LF, refused by all nine link families through the DnsEndPoint and stream overloads, with nothing written. - Socks5HelperTest: a NUL in a SOCKS4a host; a NUL in a user id through both constructors and both link schemes, with no message naming it; and a user id changed after construction. Client_InternationalisedTargetHost_IsStillSentAsUtf8 guards against refusing too much, and passes on both the old library and the new. ShadowsocksShareLinkTest.Client_ConnectAsync_HostNameTooLong_FailsBeforeWriting used 300 ASCII characters, which the stream overload now refuses with ArgumentException before the address encoder sees them. It now uses 200 Cyrillic letters (400 bytes), so it still tests the encoder's own limit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/HttpHelperTest.cs | 66 +++++++++++++++++++ QuickProxyNet.Tests/ProxyFactoryTest.cs | 28 ++++++++ .../ShadowsocksShareLinkTest.cs | 4 +- QuickProxyNet.Tests/Socks5HelperTest.cs | 65 ++++++++++++++++++ QuickProxyNet/Clients/HttpProxyClient.cs | 1 + QuickProxyNet/Clients/HttpsProxyClient.cs | 3 + QuickProxyNet/Clients/ProxyClient.cs | 21 ++++++ QuickProxyNet/Clients/ShadowsocksClient.cs | 1 + QuickProxyNet/Clients/Socks4Client.cs | 3 + QuickProxyNet/Clients/Socks4aClient.cs | 3 + QuickProxyNet/Clients/Socks5Client.cs | 1 + QuickProxyNet/Clients/TrojanClient.cs | 4 +- QuickProxyNet/Clients/VlessClient.cs | 1 + QuickProxyNet/Clients/VmessClient.cs | 1 + QuickProxyNet/Internal/SocksHelper.cs | 22 +++++++ 15 files changed, 222 insertions(+), 2 deletions(-) diff --git a/QuickProxyNet.Tests/HttpHelperTest.cs b/QuickProxyNet.Tests/HttpHelperTest.cs index eee9720..099e3be 100644 --- a/QuickProxyNet.Tests/HttpHelperTest.cs +++ b/QuickProxyNet.Tests/HttpHelperTest.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text; using QuickProxyNet.Tests.Helpers; @@ -5,6 +6,71 @@ namespace QuickProxyNet.Tests; public class HttpHelperTest { + private static byte[] Established => Encoding.ASCII.GetBytes("HTTP/1.1 200 Connection established\r\n\r\n"); + + /// + /// The target host is written into the request line and the Host header as it is. This host, + /// given to the one-call API, made the proxy read an injected header and then a second request + /// of the caller's writing, and ConnectAsync returned a stream. Every overload now refuses it + /// before anything is sent. + /// + [Fact] + public async Task Client_HeaderInjectionThroughTheTargetHost_IsRefusedByEveryOverload() + { + const string host = + "example.com HTTP/1.1\r\nX-Injected: yes\r\n\r\nGET /admin HTTP/1.1\r\nHost: internal.local\r\nX-Pad: x"; + + using var proxy = new LoopbackConnectProxy(IPAddress.Loopback); + IProxyClient client = Proxy.Create($"http://127.0.0.1:{proxy.Port}"); + + await Assert.ThrowsAsync(() => client.ConnectAsync(host, 443).AsTask()); + await Assert.ThrowsAsync(() => + client.ConnectAsync(host, 443, TimeSpan.FromSeconds(10)).AsTask()); + await Assert.ThrowsAsync(() => client.ConnectAsync(new DnsEndPoint(host, 443)).AsTask()); + + var stream = new FakeProxyStream(Established); + await Assert.ThrowsAsync(() => client.ConnectAsync(stream, host, 443).AsTask()); + await Assert.ThrowsAsync(() => + client.ConnectAsync(stream, new DnsEndPoint(host, 443)).AsTask()); + Assert.Empty(stream.WrittenBytes); + } + + /// + /// Each of these ends or splits a line of the request, or ends a NUL-terminated field, and none + /// is ever part of a host name. + /// + [Theory] + [InlineData(0x00)] + [InlineData(0x09)] + [InlineData(0x0A)] + [InlineData(0x0D)] + [InlineData(0x1F)] + [InlineData(0x20)] + [InlineData(0x7F)] + public async Task Client_TargetHostWithSpaceOrControlCharacter_IsRefusedBeforeWriting(int character) + { + var stream = new FakeProxyStream(Established); + var client = new HttpProxyClient("proxy.example", 8080); + + var ex = await Assert.ThrowsAsync(() => + client.ConnectAsync(stream, $"example{(char)character}com", 443).AsTask()); + + Assert.Equal("host", ex.ParamName); + Assert.Contains($"U+{character:X4} at index 7", ex.Message); + Assert.Empty(stream.WrittenBytes); + } + + [Fact] + public async Task Client_InternationalisedTargetHost_IsStillSentAsUtf8() + { + var stream = new FakeProxyStream(Established); + + await new HttpProxyClient("proxy.example", 8080).ConnectAsync(stream, "пример.рф", 443); + + Assert.StartsWith("CONNECT пример.рф:443 HTTP/1.1\r\nHost: пример.рф:443\r\n", + Encoding.UTF8.GetString(stream.WrittenBytes)); + } + [Fact] public async Task EstablishTunnel_200_ReturnsStream() { diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index f7e8e40..db909a8 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -235,6 +235,34 @@ await Proxy.ConnectAsync( Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); } + /// + /// A space or an ASCII control character in the target host is the caller's mistake, refused by + /// every family before a byte is written. The EndPoint overloads share the check, and the stream + /// overload runs it itself: callers reach that one directly, not only through + /// ConnectAsync(host, port). + /// + [Theory] + [InlineData("http://127.0.0.1:{port}")] + [InlineData("https://127.0.0.1:{port}")] + [InlineData("socks4://127.0.0.1:{port}")] + [InlineData("socks4a://127.0.0.1:{port}")] + [InlineData("socks5://127.0.0.1:{port}")] + [InlineData("vless://" + Uuid + "@127.0.0.1:{port}?security=none")] + [InlineData("trojan://password@127.0.0.1:{port}")] + [InlineData("vmess://" + Uuid + "@127.0.0.1:{port}?type=tcp&security=none")] + [InlineData("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@127.0.0.1:{port}")] + public async Task ConnectAsync_TargetHostWithAControlCharacter_IsRefusedByEveryFamily(string link) + { + const string host = "example.com\r\nX-Injected: yes"; + IProxyClient client = Create(link.Replace("{port}", UnusedPort().ToString())); + + await Assert.ThrowsAsync(() => client.ConnectAsync(new DnsEndPoint(host, 443)).AsTask()); + + var stream = new Helpers.FakeProxyStream([]); + await Assert.ThrowsAsync(() => client.ConnectAsync(stream, host, 443).AsTask()); + Assert.Empty(stream.WrittenBytes); + } + [Fact] public void Create_MalformedKnownScheme_ThrowsFormat() { diff --git a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs index cbb22a6..9a4a8ec 100644 --- a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs +++ b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs @@ -501,8 +501,10 @@ public async Task Client_ConnectAsync_HostNameTooLong_FailsBeforeWriting() var transport = new FakeProxyStream([]); var client = ShadowsocksClient.FromShareLink($"ss://{Aes128TestUrlSafe}@example.com:8388"); + // 200 characters pass the argument check, which 300 ASCII ones would not, and encode to + // 400 bytes: this is the address encoder's own limit. var ex = await Assert.ThrowsAsync(async () => - await client.ConnectAsync(transport, new string('a', 300), 443)); + await client.ConnectAsync(transport, new string('ж', 200), 443)); Assert.Equal(ProxyErrorCode.StringTooLong, ex.ErrorCode); Assert.Empty(transport.WrittenBytes); diff --git a/QuickProxyNet.Tests/Socks5HelperTest.cs b/QuickProxyNet.Tests/Socks5HelperTest.cs index 4487adc..7552943 100644 --- a/QuickProxyNet.Tests/Socks5HelperTest.cs +++ b/QuickProxyNet.Tests/Socks5HelperTest.cs @@ -188,6 +188,71 @@ public async Task Socks4_UserIdOver255Bytes_IsSocksStringTooLong() Assert.Equal(ProxyErrorCode.SocksStringTooLong, ex.ErrorCode); } + /// + /// SOCKS4a ends its host at the first NUL, so a host carrying one put everything after the NUL + /// on the wire as tunnel data, as though the caller had written it there. + /// + [Fact] + public async Task Socks4a_NulInTargetHost_IsRefusedBeforeWriting() + { + var stream = new FakeProxyStream([0, 90, 0, 0, 0, 0, 0, 0]); + var client = new Socks4aClient("proxy.example", 1080); + + var ex = await Assert.ThrowsAsync(() => + client.ConnectAsync(stream, "good.example\0GET /admin HTTP/1.1\r\n\r\n", 443).AsTask()); + + Assert.Equal("host", ex.ParamName); + Assert.Empty(stream.WrittenBytes); + } + + /// + /// The user id is NUL-terminated too. alice\0evil.example as the user id of a SOCKS4a + /// request for good.example reached the server as user alice and host evil.example, a target + /// the caller never named. It is refused where the credential enters, from a constructor or a + /// link, and no message repeats it. + /// + [Fact] + public void Socks4_NulInUserId_IsRefusedWhereTheCredentialEnters() + { + const string userId = "alice\0evil.example"; + + Exception[] refusals = + [ + Assert.Throws(() => + new Socks4aClient("proxy.example", 1080, new NetworkCredential(userId, ""))), + Assert.Throws(() => + new Socks4Client("proxy.example", 1080, new NetworkCredential(userId, ""))), + Assert.Throws(() => Proxy.Create("socks4a://alice%00evil.example@127.0.0.1:1080")), + Assert.Throws(() => Proxy.Create("socks4://alice%00evil.example@127.0.0.1:1080")), + ]; + + foreach (Exception refusal in refusals) + { + Assert.Contains("NUL", refusal.Message); + for (Exception? e = refusal; e is not null; e = e.InnerException) + { + Assert.DoesNotContain("alice", e.Message); + Assert.DoesNotContain("evil", e.Message); + } + } + } + + [Fact] + public async Task Socks4a_UserIdGivenANulAfterConstruction_IsRefusedBeforeWriting() + { + // NetworkCredential is mutable, so the constructor's check alone cannot keep a NUL off the wire. + var credentials = new NetworkCredential("alice", ""); + var client = new Socks4aClient("proxy.example", 1080, credentials); + credentials.UserName = "alice\0evil.example"; + var stream = new FakeProxyStream([0, 90, 0, 0, 0, 0, 0, 0]); + + var ex = await Assert.ThrowsAsync(() => + client.ConnectAsync(stream, "good.example", 443).AsTask()); + + Assert.DoesNotContain("alice", ex.Message); + Assert.Empty(stream.WrittenBytes); + } + /// /// The request buffer must hold the largest message the helper writes. It was sized for the /// SOCKS5 username and password message, 513 bytes, while a SOCKS4a request carrying a diff --git a/QuickProxyNet/Clients/HttpProxyClient.cs b/QuickProxyNet/Clients/HttpProxyClient.cs index e329162..6246da5 100644 --- a/QuickProxyNet/Clients/HttpProxyClient.cs +++ b/QuickProxyNet/Clients/HttpProxyClient.cs @@ -21,6 +21,7 @@ public HttpProxyClient(string host, int port, NetworkCredential credentials) : b public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { + ValidateArguments(host, port); return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); } } \ No newline at end of file diff --git a/QuickProxyNet/Clients/HttpsProxyClient.cs b/QuickProxyNet/Clients/HttpsProxyClient.cs index 460685a..57a646d 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -58,6 +58,9 @@ private SslClientAuthenticationOptions GetSslClientAuthenticationOptions() public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { + // Before the TLS handshake: a target that is refused anyway should cost no round trip. + ValidateArguments(host, port); + var ssl = new SslStream(stream, false); try { diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 2926275..e616014 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -222,12 +222,33 @@ public async ValueTask ConnectAsync(Stream source, EndPoint target, return await ConnectAsync(source, host, port, cancellationToken); } + /// + /// Checks a target the caller named. Every ConnectAsync overload runs this before it + /// sends anything, the stream overloads included, because callers reach those directly. + /// internal static void ValidateArguments(string host, int port) { ArgumentException.ThrowIfNullOrEmpty(host); if (host.Length > 255) throw new ArgumentException("A host name is at most 255 characters.", nameof(host)); + // The host goes on the wire as the bytes it is: into an HTTP request line and Host header, + // and into SOCKS4a's NUL-terminated host field. A CR or LF there ended the CONNECT request + // and smuggled headers and a second request to the proxy; a NUL ended the SOCKS4a host and + // turned the rest into tunnel data; a space splits the request line. No host name contains + // any of them, so they are refused for every protocol. Non-ASCII is allowed: an + // internationalised name is a host name, and UTF-8 never encodes one as a byte below 0x80. + // The message gives the position rather than the host, which would carry the same + // characters into a log. + for (int i = 0; i < host.Length; i++) + { + if (host[i] <= ' ' || host[i] == (char)0x7F) // 0x7F is DEL + throw new ArgumentException( + $"A target host cannot contain a space or an ASCII control character; this one has " + + $"U+{(int)host[i]:X4} at index {i}.", + nameof(host)); + } + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); } diff --git a/QuickProxyNet/Clients/ShadowsocksClient.cs b/QuickProxyNet/Clients/ShadowsocksClient.cs index 0ac2a0c..206d303 100644 --- a/QuickProxyNet/Clients/ShadowsocksClient.cs +++ b/QuickProxyNet/Clients/ShadowsocksClient.cs @@ -101,6 +101,7 @@ public override async ValueTask ConnectAsync(Stream stream, string host, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); + ValidateArguments(host, port); ShadowsocksStream tunnel = CreateTunnel(stream); try diff --git a/QuickProxyNet/Clients/Socks4Client.cs b/QuickProxyNet/Clients/Socks4Client.cs index edd1847..3d8c836 100644 --- a/QuickProxyNet/Clients/Socks4Client.cs +++ b/QuickProxyNet/Clients/Socks4Client.cs @@ -13,8 +13,10 @@ public Socks4Client(string host, int port) : base("socks4", host, port) { } + /// has a NUL in its user name. public Socks4Client(string host, int port, NetworkCredential credentials) : base("socks4", host, port, credentials) { + SocksHelper.ValidateUserId(credentials, nameof(credentials)); } public override ProxyType Type => ProxyType.Socks4; @@ -23,6 +25,7 @@ public Socks4Client(string host, int port, NetworkCredential credentials) : base public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { + ValidateArguments(host, port); return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); } } \ No newline at end of file diff --git a/QuickProxyNet/Clients/Socks4aClient.cs b/QuickProxyNet/Clients/Socks4aClient.cs index 3511c46..3dd1dc3 100644 --- a/QuickProxyNet/Clients/Socks4aClient.cs +++ b/QuickProxyNet/Clients/Socks4aClient.cs @@ -13,9 +13,11 @@ public Socks4aClient(string host, int port) : base("socks4a", host, port) { } + /// has a NUL in its user name. public Socks4aClient(string host, int port, NetworkCredential credentials) : base("socks4a", host, port, credentials) { + SocksHelper.ValidateUserId(credentials, nameof(credentials)); } @@ -25,6 +27,7 @@ public Socks4aClient(string host, int port, NetworkCredential credentials) : bas public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { + ValidateArguments(host, port); return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); } } \ No newline at end of file diff --git a/QuickProxyNet/Clients/Socks5Client.cs b/QuickProxyNet/Clients/Socks5Client.cs index ba31430..3fa4627 100644 --- a/QuickProxyNet/Clients/Socks5Client.cs +++ b/QuickProxyNet/Clients/Socks5Client.cs @@ -23,6 +23,7 @@ public Socks5Client(string host, int port, NetworkCredential credentials) : base public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { + ValidateArguments(host, port); return await ProxyConnector.ConnectToProxyAsync(stream, Type, host, port, ProxyCredentials, cancellationToken); } } \ No newline at end of file diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs index 8997c5d..6fa600d 100644 --- a/QuickProxyNet/Clients/TrojanClient.cs +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -62,7 +62,9 @@ public TrojanClient(TrojanOptions options) public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { - // Reject unsupported transports before writing any bytes or starting the handshake. + // Reject a bad target and unsupported transports before writing any bytes or starting the + // handshake. + ValidateArguments(host, port); TransportKind transport = EnsureSupported(); // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too, and every layer diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 02b1cf4..5ae7742 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -91,6 +91,7 @@ public VlessClient(VlessOptions options) public override async ValueTask ConnectAsync(Stream stream, string host, int port, CancellationToken cancellationToken = default) { + ValidateArguments(host, port); TransportKind transport = EnsureSupported(); // Each layer takes ownership of the one below it, so tracking the outermost stream is diff --git a/QuickProxyNet/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs index a961b1e..160ca02 100644 --- a/QuickProxyNet/Clients/VmessClient.cs +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -122,6 +122,7 @@ public override async ValueTask ConnectAsync(Stream stream, string host, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); + ValidateArguments(host, port); // Reject unsupported transports and ciphers before writing any bytes or starting TLS. VmessSecurity security = EnsureSupported(out TransportKind transportKind); diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index eb070c5..7697ae7 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -189,6 +189,10 @@ await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), internal static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool isVersion4a, string host, int port, NetworkCredential? credentials, CancellationToken cancellationToken) { + // The client constructors have checked this already, but NetworkCredential is mutable, and a + // user name changed after construction must not reach the wire either. + ValidateUserId(credentials, nameof(credentials)); + var buffer = ArrayPool.Shared.Rent(BufferSize); try @@ -294,6 +298,24 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo } } + /// + /// Refuses a SOCKS4 user id the wire format cannot carry: one with a NUL in it. + /// + /// + /// SOCKS4 ends the user id at its first NUL, and the proxy reads whatever follows as the next + /// field. For SOCKS4a that is the host, so alice\0evil.example as the user id of a request + /// for good.example reached the proxy as user alice asking for + /// evil.example. The message never includes the user id, which is a credential. + /// + internal static void ValidateUserId(NetworkCredential? credentials, string paramName) + { + if (credentials is not null && credentials.UserName.Contains('\0')) + throw new ArgumentException( + "A SOCKS4 user id cannot contain a NUL character: the protocol ends the user id at the " + + "first one, and the proxy would read what follows as the next field.", + paramName); + } + private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName) { // The length goes out as a single byte, so the write is capped at 255 whatever room the From 663220095d4db05b2fb90d3eb592881e606f695b Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 16:59:17 +0500 Subject: [PATCH 27/35] fix(reality): bound the server name and ALPN list where the configuration enters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REALITY ClientHello is written as one TLS record, and nothing bounded what went into it. Reproduced: new VlessClient(new VlessOptions { Security = Reality, RealityPublicKey = , Sni = new string('s', 70000) }) made ConnectAsync throw InvalidOperationException ("A two-byte vector cannot hold 70000 bytes.") from TlsWriter.EndVector. A link with a 17 000-character sni= made it throw ArgumentOutOfRangeException ("A TLS record carries at most 16384 bytes") from the record layer's WriteAsync. Both happened after the TCP connect, and neither is an exception ConnectAsync may throw. Separately, alpn=h%C3%A9 was accepted, but TlsClientHello encoded ALPN with Encoding.ASCII, so the wire carried "h?". TlsClientHello.TryValidate checks the name and the list against three documented bounds: - a server name of at most 253 characters in the A-label form SNI sends (a DNS name without its trailing dot); - at most 16 ALPN protocols; - each protocol 1 to 255 bytes of UTF-8 (RFC 7301's one-byte length, which SslApplicationProtocol also enforces). At all three maxima the hello comes to about 4.5 KB, which leaves the rest of the 16 384-byte record as room for a browser fingerprint; Chrome's hello is about 1.7 KB. The same check refuses a name ToALabel cannot convert, which used to surface from inside the handshake as an ArgumentException. The check runs where the configuration enters, like the pbk and sid checks. VlessShareLink refuses a REALITY link with the reason (FormatException from Parse), and the VlessClient constructor throws ArgumentException for options built by hand. The name is Sni ?? HostHeader ?? Host, and VlessOptions.RealityServerName now holds that precedence in one place. The parser, the constructor and BuildRealityOptions all read it, so sni=, host= and the server address are all bounded. Only security=reality is checked; the TLS path hands the name and ALPN to SslStream. ALPN is now encoded as UTF-8. That is what SslApplicationProtocol puts on the wire for the same list under security=tls, and what Go, and so Xray, sends for a string. Refusing non-ASCII instead would reject, for REALITY only, a link that the TLS path and Xray both accept. Tests are in VlessTest. Four of them fail against the old library (stashed) on each TFM: - an over-long name through sni=, host=, Proxy.Create and all three option fields, with 253 characters and an internationalised name still accepted; - a name IdnMapping cannot convert; - 17 protocols, a 256-byte one and a 256-byte non-ASCII one refused by the link, and 17 protocols and an empty one refused by the constructor, with 16 protocols of 255 bytes accepted; - ALPN "hé" encoded as the bytes of SslApplicationProtocol("hé") (the old encoder wrote 02 68 3F). Reality_HelloAtTheLargestAllowedNameAndAlpn_FitsOneRecord builds a hello at every maximum at once, checks that one more of each is refused, and writes the hello through the record layer as one record. It uses the new constants, so it does not compile against the old library and was left out of that run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/VlessTest.cs | 115 ++++++++++++++++++ QuickProxyNet/Clients/VlessClient.cs | 11 +- QuickProxyNet/Configs/VlessOptions.cs | 10 ++ QuickProxyNet/Configs/VlessShareLink.cs | 11 ++ .../Internal/Reality/TlsClientHello.cs | 88 +++++++++++++- 5 files changed, 232 insertions(+), 3 deletions(-) diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index 1f0c6cc..c8928d9 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -1,3 +1,4 @@ +using System.Net.Security; using QuickProxyNet.Tests.Helpers; namespace QuickProxyNet.Tests; @@ -490,6 +491,120 @@ public void Reality_PublicKeyWithUnusedTrailingBitsSet_DecodesToTheSameKey() Assert.True(RealityAuth.TryDecodePublicKey(loose, out byte[]? actual, out _)); Assert.Equal(expected, actual); } + + private const string RealityKey = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; + + private static VlessOptions RealityOptions( + string host = "example.com", string? sni = null, string? hostHeader = null, IReadOnlyList? alpn = null) => + new() + { + Id = Uuid, Host = host, Port = 443, Security = VlessSecurity.Reality, RealityPublicKey = RealityKey, + Sni = sni, HostHeader = hostHeader, Alpn = alpn + }; + + /// + /// The server name goes into a ClientHello written as one TLS record. A 70 000-character sni on + /// built options used to leave ConnectAsync as an InvalidOperationException from the hello writer, + /// and a 17 000-character one in a link as an ArgumentOutOfRangeException from the record layer, + /// both after the TCP connect. The name is bounded where it enters, whichever field it comes from: + /// sni, else host, else the server address. + /// + [Fact] + public void Reality_ServerNameLongerThanADnsName_IsRefusedBeforeAnyConnect() + { + string link = $"vless://{Uuid}@example.com:443?security=reality&pbk={RealityKey}"; + + var fromSni = Assert.Throws(() => VlessShareLink.Parse(link + "&sni=" + new string('s', 17_000))); + Assert.Contains("17000 characters", fromSni.Message); + Assert.Throws(() => VlessShareLink.Parse(link + "&host=" + new string('h', 254))); + Assert.Throws(() => Proxy.Create(link + "&sni=" + new string('s', 254))); + + Assert.Throws(() => new VlessClient(RealityOptions(sni: new string('s', 70_000)))); + Assert.Throws(() => new VlessClient(RealityOptions(hostHeader: new string('h', 254)))); + Assert.Throws(() => new VlessClient(RealityOptions(host: new string('a', 254)))); + + // The limit itself is a name, and so is an internationalised one. + VlessShareLink.Parse(link + "&sni=" + new string('s', 253)); + _ = new VlessClient(RealityOptions(sni: new string('s', 253))); + _ = new VlessClient(RealityOptions(sni: "пример.рф")); + } + + /// + /// A name SNI cannot encode used to be found only inside the handshake, as an ArgumentException + /// out of ConnectAsync. It is a link that describes no reachable node, and is refused as one. + /// + [Fact] + public void Reality_ServerNameThatIsNotAHostName_IsRefusedBeforeAnyConnect() + { + string sni = new('ж', 60); // a label whose A-label form is longer than DNS allows + + var parse = Assert.Throws(() => + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=reality&pbk={RealityKey}&sni={sni}")); + Assert.Contains("encoded for SNI", parse.Message); + + Assert.Throws(() => new VlessClient(RealityOptions(sni: sni))); + } + + [Fact] + public void Reality_AlpnBeyondWhatAHelloCarries_IsRefusedBeforeAnyConnect() + { + string link = $"vless://{Uuid}@example.com:443?security=reality&pbk={RealityKey}"; + string[] seventeen = Enumerable.Range(0, 17).Select(i => $"p{i}").ToArray(); + string[] atTheLimit = Enumerable.Repeat(new string('a', 255), 16).ToArray(); + + var tooMany = Assert.Throws(() => VlessShareLink.Parse(link + "&alpn=" + string.Join(',', seventeen))); + Assert.Contains("at most 16", tooMany.Message); + Assert.Throws(() => VlessShareLink.Parse(link + "&alpn=h2," + new string('a', 256))); + Assert.Throws(() => + VlessShareLink.Parse(link + "&alpn=" + Uri.EscapeDataString(new string('é', 128)))); // 256 bytes + + Assert.Throws(() => new VlessClient(RealityOptions(alpn: seventeen))); + Assert.Throws(() => new VlessClient(RealityOptions(alpn: ["h2", ""]))); + + VlessShareLink.Parse(link + "&alpn=" + string.Join(',', atTheLimit)); + _ = new VlessClient(RealityOptions(alpn: atTheLimit)); + } + + /// + /// ALPN was encoded as ASCII, so alpn=h%C3%A9, which the constructor accepts, went out as + /// "h?". It is UTF-8 now, the bytes SslStream sends for the same protocol under security=tls. + /// + [Fact] + public void Reality_Hello_EncodesAlpnAsUtf8() + { + TlsClientHello.Result hello = TlsClientHello.Build("example.com", ["hé"]); + + // ALPN is the last extension written: type 16, extension length 6, list length 4, then the + // one protocol with its length byte. + byte[] utf8 = new SslApplicationProtocol("hé").Protocol.ToArray(); + Assert.Equal([0x00, 0x10, 0x00, 0x06, 0x00, 0x04, 3, .. utf8], hello.Handshake[^10..]); + } + + /// + /// The bounds are what keep a hello inside the single record it is written into. This builds one + /// at every maximum at once and writes it the way RealityTlsClient does. + /// + [Fact] + public async Task Reality_HelloAtTheLargestAllowedNameAndAlpn_FitsOneRecord() + { + string serverName = new('s', TlsClientHello.MaxServerNameLength); + string[] alpn = Enumerable + .Repeat(new string('a', TlsClientHello.MaxAlpnProtocolLength), TlsClientHello.MaxAlpnProtocols) + .ToArray(); + + Assert.True(TlsClientHello.TryValidate(serverName, alpn, out string? error), error); + Assert.False(TlsClientHello.TryValidate(serverName + "s", alpn, out _)); + Assert.False(TlsClientHello.TryValidate(serverName, [.. alpn, "h2"], out _)); + Assert.False(TlsClientHello.TryValidate(serverName, [.. alpn[1..], alpn[0] + "a"], out _)); + + TlsClientHello.Result hello = TlsClientHello.Build(serverName, alpn); + Assert.True(hello.Handshake.Length <= TlsRecordStream.MaxPlaintext, $"The hello is {hello.Handshake.Length} bytes."); + + using var transport = new MemoryStream(); + using var records = new TlsRecordStream(transport); + await records.WriteAsync(TlsContentType.Handshake, hello.Handshake, CancellationToken.None); + Assert.Equal(5 + hello.Handshake.Length, transport.Length); + } /// /// REALITY failures are proxy errors like any other: the type carries a code a caller can /// branch on, and the two codes it uses mean different things to act on. diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 5ae7742..1536bd9 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -25,7 +25,7 @@ public sealed class VlessClient : ProxyClient /// is null. /// /// The options carry an invalid UUID, or, for REALITY, a public key or short id that cannot be - /// decoded. + /// decoded, or a server name or ALPN list that a ClientHello cannot carry. /// public VlessClient(VlessOptions options) : base("vless", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) @@ -53,6 +53,12 @@ public VlessClient(VlessOptions options) Span shortId = stackalloc byte[RealityAuth.ShortIdSize]; if (!RealityAuth.TryParseShortId(shortId, options.RealityShortId, out string? shortIdError)) throw new ArgumentException(shortIdError, nameof(options)); + + // The server name and ALPN list too. Past what one TLS record holds, the hello failed to + // write after the TCP connect, as an InvalidOperationException or an + // ArgumentOutOfRangeException out of ConnectAsync. + if (!TlsClientHello.TryValidate(options.RealityServerName, options.Alpn, out string? helloError)) + throw new ArgumentException(helloError, nameof(options)); } Options = options; @@ -160,7 +166,8 @@ private TransportKind EnsureSupported() /// private RealityTlsOptions BuildRealityOptions() => new() { - ServerName = Options.Sni ?? Options.HostHeader ?? Options.Host, + // Bounded by the constructor, like the ALPN list below. + ServerName = Options.RealityServerName, // Decoded and length-checked by the constructor; EnsureSupported has already refused a // REALITY configuration without a key. PublicKey = _realityPublicKey!, diff --git a/QuickProxyNet/Configs/VlessOptions.cs b/QuickProxyNet/Configs/VlessOptions.cs index e018b43..2e27311 100644 --- a/QuickProxyNet/Configs/VlessOptions.cs +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -86,4 +86,14 @@ public sealed class VlessOptions /// The resolved transport layer this configuration selects. internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); + + /// + /// The name a REALITY ClientHello carries: , else , + /// else . + /// + /// + /// One property, so the share-link parser and the client constructor check the same name the + /// handshake sends, whichever field it comes from. + /// + internal string RealityServerName => Sni ?? HostHeader ?? Host; } diff --git a/QuickProxyNet/Configs/VlessShareLink.cs b/QuickProxyNet/Configs/VlessShareLink.cs index 44605df..0040c34 100644 --- a/QuickProxyNet/Configs/VlessShareLink.cs +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -193,6 +193,17 @@ private static bool TryParse( RealityShortId = sid, Remark = remark }; + + // The server name and ALPN list go into a ClientHello written as one TLS record. Past what it + // holds, the write failed after the TCP connect with an exception ConnectAsync may not throw, + // so a link that could never be sent is refused here. + if (security == VlessSecurity.Reality && + !TlsClientHello.TryValidate(options.RealityServerName, options.Alpn, out error)) + { + options = null; + return false; + } + error = null; return true; } diff --git a/QuickProxyNet/Internal/Reality/TlsClientHello.cs b/QuickProxyNet/Internal/Reality/TlsClientHello.cs index c011cf0..b096d88 100644 --- a/QuickProxyNet/Internal/Reality/TlsClientHello.cs +++ b/QuickProxyNet/Internal/Reality/TlsClientHello.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -47,6 +48,88 @@ internal static class TlsClientHello /// The matching public key. internal readonly record struct Result(byte[] Handshake, byte[] PrivateKey, byte[] PublicKey); + /// The longest server name a hello carries, counted in the ASCII form SNI sends. + /// + /// + /// A DNS name is at most 253 characters without its trailing dot. The three bounds here exist + /// because the hello is written as one TLS record of at most 16 384 bytes, and a configuration + /// past them used to fail only once the TCP connection was open, as an exception + /// ConnectAsync may not throw. + /// + /// + /// At all three maxima the hello is about 4.5 KB: the fixed fields and extensions, 253 bytes of + /// name, and 16 protocols of 1 + 255 bytes. The rest of the record is room for the browser + /// fingerprint this hello does not have yet; Chrome's hello is about 1.7 KB, most of it the + /// X25519MLKEM768 key share. VlessTest builds a hello at the maxima and writes it as a + /// record, so a fingerprint that outgrows the room fails there. + /// + /// + internal const int MaxServerNameLength = 253; + + /// The most ALPN protocols a hello offers. See . + internal const int MaxAlpnProtocols = 16; + + /// + /// The longest ALPN protocol name in bytes of UTF-8: RFC 7301 gives it a one-byte length, and + /// refuses anything longer. + /// + internal const int MaxAlpnProtocolLength = 255; + + /// + /// Checks a server name and an ALPN list against what a hello can carry, so that a configuration + /// is refused where it enters, not when the hello is written. + /// + /// The name SNI will carry, before its conversion to A-labels. + /// The ALPN protocols, or null for the default list. + /// What is wrong, when this returns false. + public static bool TryValidate( + string serverName, IReadOnlyList? alpn, [NotNullWhen(false)] out string? error) + { + string aLabel; + try + { + aLabel = ToALabel(serverName); + } + catch (ArgumentException) + { + // A name too long to read is described, not repeated. + error = serverName.Length <= MaxServerNameLength + ? $"The REALITY server name '{serverName}' is not a host name that can be encoded for SNI." + : $"The REALITY server name is {serverName.Length} characters; a DNS name is at most {MaxServerNameLength}."; + return false; + } + + if (aLabel.Length > MaxServerNameLength) + { + error = $"The REALITY server name is {aLabel.Length} characters in the ASCII form SNI carries; " + + $"a DNS name is at most {MaxServerNameLength}."; + return false; + } + + if (alpn is not null) + { + if (alpn.Count > MaxAlpnProtocols) + { + error = $"A REALITY hello offers at most {MaxAlpnProtocols} ALPN protocols; this configuration has {alpn.Count}."; + return false; + } + + for (int i = 0; i < alpn.Count; i++) + { + int length = string.IsNullOrEmpty(alpn[i]) ? 0 : Encoding.UTF8.GetByteCount(alpn[i]); + if (length is 0 or > MaxAlpnProtocolLength) + { + error = $"An ALPN protocol is 1 to {MaxAlpnProtocolLength} bytes of UTF-8; protocol {i + 1} " + + $"of {alpn.Count} is {length}."; + return false; + } + } + } + + error = null; + return true; + } + /// /// Builds a ClientHello offering a fresh X25519 key_share. /// @@ -242,8 +325,11 @@ private static void WriteAlpn(TlsWriter writer, IReadOnlyList alpn) int list = writer.BeginVector16(); foreach (string protocol in alpn) { + // UTF-8, which is what SslApplicationProtocol puts on the wire for the same list under + // security=tls, and what Go, and so Xray, sends for a string. ASCII turned "hé" into + // "h?" without a word, the substitution ToALabel exists to prevent for the name. int entry = writer.BeginVector8(); - writer.Write(Encoding.ASCII.GetBytes(protocol)); + writer.Write(Encoding.UTF8.GetBytes(protocol)); writer.EndVector(entry, 1); } From fbb0cf84bfca239ff90ff38197f1315a0006623b Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 17:31:32 +0500 Subject: [PATCH 28/35] fix: refuse control characters in a ws/httpupgrade request and in a proxy host The ws and httpupgrade transports write the configured path into the request line, and the Host header value (host=, else sni, else the server address) into the Host header, as they are. A share link percent-decodes both, so path=%2Fws%0D%0AX-Injected:%20yes, or the same in host= or sni=, made VLESS, Trojan and VMess send their node's server, or the CDN in front of it, a header of the link's writing; a path could as well have ended the header block and added a second request. Options built by hand with a CR or LF in Path, HostHeader or Sni did the same. ProxyTransport.TryValidateRequest refuses an ASCII control character (0x00-0x1F, 0x7F) in the path and in the Host header value, for ws and httpupgrade. It runs where the configuration enters: the VLESS and Trojan parsers and both VMess grammars report it as a FormatException, and the three client constructors throw ArgumentException. The message names the field, the code point and the index, never the value, because a path can carry a secret. The raw-TCP transport sends neither value, so a tcp link with junk in an unused path is left working. VlessOptions, TrojanOptions and VmessOptions gain TransportHostHeader, so the check and the request read the same value. A space in the path is not refused; it goes out as %20. A request target cannot hold one (RFC 9112 section 3.2), and a server splitting the request line at it reads the rest as the HTTP version. But a server can be configured with such a path, Xray's client builds this request with Go's net/url, which writes a space in a path as %20, and 68 of the 10 822 vless links in the cached real-world corpus carry one in their decoded ws path. They used to go out as "GET /a b HTTP/1.1". The proxy host: ProxyClient's constructor only length-checked it, and a NUL cuts a name short at the resolver, as 1e33046 measured. It now refuses what ValidateArguments refuses in a target, a space or an ASCII control character, with ArgumentException. Every client, options built by hand and Proxy.Create(ProxyType, ...) come through it. Through links, Uri already refused such a host, raw or percent-encoded, for http, https, socks4, socks4a, socks5, vless, trojan and the vmess URI form. The ss:// authority is scanned by hand and a vmess JSON "add" holds any string, and both built a client with the host as it was; those two parsers now refuse it as a FormatException. An empty Sni on options built by hand was sent as the server name: RealityServerName was Sni ?? HostHeader ?? Host, and the VLESS, Trojan and VMess TLS paths passed the same expression to SslStream, so the hello carried none of the names the options gave. TlsHandshake.ResolveServerName treats an empty sni or host header as absent, as ResolveHostHeader already did, and the options' ServerName (renamed from RealityServerName) now gives REALITY, every TLS TargetHost and the name in a TLS error. A share link never produces an empty value. Tests: 29 new cases on each TFM. Against the old library (stashed, new tests kept) 28 fail on each TFM, each on its own assertion, and Parse_ControlCharacterInFieldsTheTransportNeverSends_IsLeftAlone, which guards against refusing too much, passes. - TransportTest: CR LF, LF, NUL and DEL in path=, host= and sni= refused by the vless, trojan and vmess URI links; CR LF in the path, host and sni of a vmess JSON link, refused by the check rather than as unreadable JSON; NUL, TAB, LF, CR, 0x1F and DEL in Path, HostHeader and Sni refused by all three constructors on both transports. A space in the path goes out as %20 through a link over ws and through built options over httpupgrade; the old request line was "GET /a b?ed=2048 HTTP/1.1". - ProxyFactoryTest: NUL, TAB, LF, CR, 0x1F, space and DEL in the proxy host refused by the HTTP client, Proxy.Create(ProxyType) with and without credentials and the VLESS, Trojan, VMess and Shadowsocks constructors; a NUL or CR LF in the host of all eight link grammars refused as malformed, the vmess JSON and both ss grammars by the new check. - VlessTest, TrojanTest, VmessClientTest: an empty sni falls back to the host header in the REALITY and TLS hellos, and an empty sni and host header to the server address for REALITY. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MMe8Wh9ppLrvvRKg5KaPLs --- QuickProxyNet.Tests/ProxyFactoryTest.cs | 85 ++++++++++++ QuickProxyNet.Tests/TransportTest.cs | 124 ++++++++++++++++++ QuickProxyNet.Tests/TrojanTest.cs | 19 +++ QuickProxyNet.Tests/VlessTest.cs | 35 +++++ QuickProxyNet.Tests/VmessClientTest.cs | 19 +++ QuickProxyNet/Clients/ProxyClient.cs | 42 +++++- QuickProxyNet/Clients/TrojanClient.cs | 20 ++- QuickProxyNet/Clients/VlessClient.cs | 25 ++-- QuickProxyNet/Clients/VmessClient.cs | 19 ++- QuickProxyNet/Configs/ShadowsocksShareLink.cs | 10 ++ QuickProxyNet/Configs/TrojanOptions.cs | 17 ++- QuickProxyNet/Configs/TrojanShareLink.cs | 9 ++ QuickProxyNet/Configs/VlessOptions.cs | 17 ++- QuickProxyNet/Configs/VlessShareLink.cs | 10 +- QuickProxyNet/Configs/VmessOptions.cs | 17 ++- QuickProxyNet/Configs/VmessShareLink.cs | 28 ++++ QuickProxyNet/Internal/TlsHandshake.cs | 21 +++ .../Internal/Transports/ProxyTransport.cs | 79 ++++++++++- 18 files changed, 561 insertions(+), 35 deletions(-) diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index db909a8..7eaa5a7 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -263,6 +263,91 @@ public async Task ConnectAsync_TargetHostWithAControlCharacter_IsRefusedByEveryF Assert.Empty(stream.WrittenBytes); } + /// + /// A proxy host is refused, by the constructor every client shares, for the characters a target + /// host is refused for. Proxy.Create(ProxyType, ...) and options built by hand both reach it. A + /// NUL cut the name short at the resolver: Dns.GetHostAddresses("localhost\0evil.example") + /// returns localhost's addresses. + /// + [Theory] + [InlineData(0x00)] + [InlineData(0x09)] + [InlineData(0x0A)] + [InlineData(0x0D)] + [InlineData(0x1F)] + [InlineData(0x20)] + [InlineData(0x7F)] + public void Constructor_ProxyHostWithSpaceOrControlCharacter_IsRefused(int character) + { + string host = $"proxy{(char)character}evil.example"; + + Func[] constructors = + [ + () => new HttpProxyClient(host, 8080), + () => Proxy.Create(ProxyType.Socks5, host, 1080, null), + () => Proxy.Create(ProxyType.Https, host, 443, new NetworkCredential("user", "password")), + () => new VlessClient(new VlessOptions { Id = Uuid, Host = host, Port = 443 }), + () => new TrojanClient(new TrojanOptions { Password = "password", Host = host, Port = 443 }), + () => new VmessClient(new VmessOptions { Id = Uuid, Host = host, Port = 443 }), + () => new ShadowsocksClient(new ShadowsocksOptions + { + Method = "aes-256-gcm", Password = "password", Host = host, Port = 8388 + }), + ]; + + foreach (Func construct in constructors) + { + var ex = Assert.Throws(construct); + Assert.Equal("host", ex.ParamName); + Assert.Contains($"U+{character:X4} at index 5", ex.Message); + Assert.DoesNotContain("evil", ex.Message); + } + } + + /// + /// No link grammar may hand a client such a host either, and each refuses it as a malformed link. + /// Uri already refused one for the classic schemes, vless, trojan and the vmess URI form. The ss:// + /// authority is scanned by hand and a vmess JSON string holds anything, and both of those built a + /// client with the host as it was. + /// + [Fact] + public void Create_LinkWhoseProxyHostHasAControlCharacter_IsRefusedAsMalformed() + { + const string host = "proxy\0evil.example"; + string json = $$"""{"add":"proxy\u0000evil.example","port":"443","id":"{{Uuid}}","aid":"0","net":"tcp"}"""; + + string[] refusedByUri = + [ + $"socks5://{host}:1080", + $"http://{host}:8080", + $"vless://{Uuid}@{host}:443?security=none", + $"trojan://password@{host}:443", + $"vmess://{Uuid}@{host}:443?type=tcp", + ]; + + // These two built a client. The refusal must be the host check, not some other reason the + // link could not be read, so the message has to name the character. + string[] refusedByTheParser = + [ + "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)), + $"ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ@{host}:8388", + "ss://" + Convert.ToBase64String(Encoding.UTF8.GetBytes("aes-256-gcm:password@proxy\r\nevil.example:8388")), + ]; + + foreach (string link in refusedByUri) + { + var ex = Assert.Throws(() => Create(link)); + Assert.DoesNotContain("evil", ex.Message); + } + + foreach (string link in refusedByTheParser) + { + var ex = Assert.Throws(() => Create(link)); + Assert.Contains("control character", ex.Message); + Assert.DoesNotContain("evil", ex.Message); + } + } + [Fact] public void Create_MalformedKnownScheme_ThrowsFormat() { diff --git a/QuickProxyNet.Tests/TransportTest.cs b/QuickProxyNet.Tests/TransportTest.cs index 29467b7..c648c9d 100644 --- a/QuickProxyNet.Tests/TransportTest.cs +++ b/QuickProxyNet.Tests/TransportTest.cs @@ -1,3 +1,4 @@ +using System.Text; using QuickProxyNet.Tests.Helpers; namespace QuickProxyNet.Tests; @@ -325,6 +326,129 @@ public async Task HttpUpgrade_AcceptsResponseWithoutAcceptHeader() Assert.Equal(1, await tunnel.ReadAsync(buffer)); } + // === what the configuration puts into the request === + + /// + /// The path and the Host header went into the upgrade request as they were, and a link decodes + /// %0D%0A to CR LF, so each of these links sent its node's server, or the CDN in front of it, a + /// header of the link's writing. Every family refuses them where the link is parsed, on both + /// transports and in every field the Host header comes from. + /// + [Theory] + [InlineData("vless://" + Uuid + "@example.com:443?type=ws&path=%2Fws%0D%0AX-Injected:%20yes")] + [InlineData("vless://" + Uuid + "@example.com:443?type=httpupgrade&host=cdn.example.com%0D%0AX-Injected:%20yes")] + [InlineData("vless://" + Uuid + "@example.com:443?type=ws&sni=cdn.example.com%0D%0AX-Injected:%20yes")] + [InlineData("trojan://password@example.com:443?type=ws&path=%2Fws%0AX-Injected:%20yes")] + [InlineData("trojan://password@example.com:443?type=httpupgrade&host=cdn.example.com%00X-Injected")] + [InlineData("vmess://" + Uuid + "@example.com:443?type=ws&path=%2Fws%0D%0AX-Injected:%20yes")] + [InlineData("vmess://" + Uuid + "@example.com:443?type=httpupgrade&host=cdn.example.com%7FX-Injected")] + public void Parse_ControlCharacterInPathOrHostHeader_IsRefused(string link) + { + var ex = Assert.Throws(() => Proxy.Create(link)); + + Assert.Contains("control character", ex.Message); + Assert.DoesNotContain("Injected", ex.Message); + } + + /// The JSON grammar carries the characters themselves rather than escapes of them. + [Theory] + [InlineData("\"path\":\"/ws\\r\\nX-Injected: yes\"")] + [InlineData("\"host\":\"cdn.example.com\\r\\nX-Injected: yes\"")] + [InlineData("\"sni\":\"cdn.example.com\\nX-Injected: yes\"")] + public void Parse_VmessJsonWithControlCharacterInPathOrHostHeader_IsRefused(string field) + { + string json = $$"""{"add":"example.com","port":"443","id":"{{Uuid}}","aid":"0","net":"ws",{{field}}}"""; + string link = "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + Assert.False(VmessShareLink.TryParse(link, out _)); + var ex = Assert.Throws(() => Proxy.Create(link)); + + // Refused by the check, not for JSON that could not be read. + Assert.Contains("control character", ex.Message); + Assert.DoesNotContain("Injected", ex.Message); + } + + /// + /// Options built by hand are refused by the constructor: each character that ends or breaks a + /// request line or a header line, in the path and in each field the Host header comes from. + /// + [Theory] + [InlineData(0x00)] + [InlineData(0x09)] + [InlineData(0x0A)] + [InlineData(0x0D)] + [InlineData(0x1F)] + [InlineData(0x7F)] + public void Client_ControlCharacterInPathOrHostHeader_IsRefusedByTheConstructor(int character) + { + string bad = $"cdn{(char)character}example.com"; + + foreach (string transport in new[] { "ws", "httpupgrade" }) + { + Func[] constructors = + [ + () => new VlessClient(new VlessOptions { Id = Uuid, Host = "example.com", Port = 443, Transport = transport, Path = "/" + bad }), + () => new VlessClient(new VlessOptions { Id = Uuid, Host = "example.com", Port = 443, Transport = transport, HostHeader = bad }), + () => new VlessClient(new VlessOptions { Id = Uuid, Host = "example.com", Port = 443, Transport = transport, Sni = bad }), + () => new TrojanClient(new TrojanOptions { Password = "password", Host = "example.com", Port = 443, Transport = transport, Path = "/" + bad }), + () => new TrojanClient(new TrojanOptions { Password = "password", Host = "example.com", Port = 443, Transport = transport, HostHeader = bad }), + () => new VmessClient(new VmessOptions { Id = Uuid, Host = "example.com", Port = 443, Transport = transport, Path = "/" + bad }), + () => new VmessClient(new VmessOptions { Id = Uuid, Host = "example.com", Port = 443, Transport = transport, Sni = bad }), + ]; + + foreach (Func construct in constructors) + { + var ex = Assert.Throws(construct); + Assert.Equal("options", ex.ParamName); + Assert.Contains($"U+{character:X4} at index", ex.Message); + } + } + } + + /// + /// Only ws and httpupgrade write the path and the Host header. A raw-TCP link with junk in them + /// sends neither, so it still parses and still builds a client. + /// + [Fact] + public void Parse_ControlCharacterInFieldsTheTransportNeverSends_IsLeftAlone() + { + VlessOptions options = VlessShareLink.Parse($"vless://{Uuid}@example.com:443?type=tcp&path=%2Fws%0D%0A&host=a%00b"); + + Assert.Equal("/ws\r\n", options.Path); + _ = new VlessClient(options); + } + + /// + /// A request target cannot hold a space: the request line ends at it. A link's path is + /// percent-decoded, so its %20 arrived as a space and went out raw. It goes out as %20 again, + /// which is what Go's net/url writes for Xray's client and what the server decodes back. + /// + [Fact] + public async Task Handshake_SpaceInPath_IsSentPercentEncoded() + { + var server = new FakeWebSocketServer(); + server.SendToClient(VlessResponse(0x41)); + + var tunnel = await WsClient("&path=%2Fa%20b%3Fed%3D2048") + .ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + Assert.StartsWith("GET /a%20b?ed=2048 HTTP/1.1\r\n", server.Request); + + // Built by hand too, over httpupgrade, and without the leading slash. + var upgrade = new FakeWebSocketServer(framed: false); + upgrade.SendToClient(VlessResponse(0x41)); + var client = new VlessClient(new VlessOptions + { + Id = Uuid, Host = "example.com", Port = 443, Transport = "httpupgrade", Path = "a b c" + }); + + var upgraded = await client.ConnectAsync(upgrade, "example.org", 443, CancellationToken.None); + await DriveFirstRead(upgraded); + + Assert.StartsWith("GET /a%20b%20c HTTP/1.1\r\n", upgrade.Request); + } + // === parsing === [Theory] diff --git a/QuickProxyNet.Tests/TrojanTest.cs b/QuickProxyNet.Tests/TrojanTest.cs index ec6894d..3dbed11 100644 --- a/QuickProxyNet.Tests/TrojanTest.cs +++ b/QuickProxyNet.Tests/TrojanTest.cs @@ -180,6 +180,25 @@ await Assert.ThrowsAsync( () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); } + /// + /// An empty sni counts as absent for the TLS name, as it already did for the ws Host header. It + /// used to be sent as the name itself, so the hello carried neither the host header nor the + /// server address. + /// + [Fact] + public async Task Client_EmptySni_FallsBackToTheHostHeaderForTheTlsName() + { + var transport = new FakeProxyStream([]); + var client = new TrojanClient(new TrojanOptions + { + Password = Password, Host = "server.example.net", Port = 443, Sni = "", HostHeader = "cdn.example.net" + }); + + await Assert.ThrowsAsync(() => client.ConnectAsync(transport, "example.org", 443).AsTask()); + + Assert.True(transport.WrittenBytes.AsSpan().IndexOf("cdn.example.net"u8) >= 0); + } + // === Factory === [Fact] diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index c8928d9..04c0d46 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -545,6 +545,41 @@ public void Reality_ServerNameThatIsNotAHostName_IsRefusedBeforeAnyConnect() Assert.Throws(() => new VlessClient(RealityOptions(sni: sni))); } + /// + /// An empty sni on options built by hand counts as absent, as it already did when the ws Host + /// header is picked. It used to be sent as the name itself, so the hello carried none of the + /// names the options gave, over REALITY and over TLS alike. + /// + [Fact] + public async Task EmptySniOrHostHeader_CountsAsAbsent_ForTheServerName() + { + byte[] hello = await RealityHello(RealityOptions(host: "server.example.net", sni: "", hostHeader: "cdn.example.net")); + Assert.True(Carries(hello, "cdn.example.net"u8), "REALITY: an empty sni falls back to the host header"); + + hello = await RealityHello(RealityOptions(host: "server.example.net", sni: "", hostHeader: "")); + Assert.True(Carries(hello, "server.example.net"u8), "REALITY: empty sni and host header fall back to the server"); + + var tls = new VlessOptions + { + Id = Uuid, Host = "server.example.net", Port = 443, Security = VlessSecurity.Tls, + Sni = "", HostHeader = "cdn.example.net" + }; + var transport = new FakeProxyStream([]); + await Assert.ThrowsAsync(() => new VlessClient(tls).ConnectAsync(transport, "example.org", 443).AsTask()); + Assert.True(Carries(transport.WrittenBytes, "cdn.example.net"u8), "TLS: an empty sni falls back to the host header"); + + static async Task RealityHello(VlessOptions options) + { + // Nothing answers, so the handshake ends at the first read, after the hello is written. + var transport = new FakeProxyStream([]); + await Assert.ThrowsAsync(() => + new VlessClient(options).ConnectAsync(transport, "example.org", 443).AsTask()); + return transport.WrittenBytes; + } + } + + private static bool Carries(byte[] written, ReadOnlySpan name) => written.AsSpan().IndexOf(name) >= 0; + [Fact] public void Reality_AlpnBeyondWhatAHelloCarries_IsRefusedBeforeAnyConnect() { diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index 05a89da..0fb4321 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -710,6 +710,25 @@ public void Client_ExposesTypeAndOptions() Assert.Equal($"vmess://{ProxyHost}:{ProxyPort}", client.ToString()); } + /// + /// An empty sni counts as absent for the TLS name, as it already did for the ws Host header. It + /// used to be sent as the name itself, so the hello carried neither the host header nor the + /// server address. A link never gives an empty one; options built by hand can. + /// + [Fact] + public async Task Client_EmptySni_FallsBackToTheHostHeaderForTheTlsName() + { + var transport = new FakeProxyStream([]); + var client = new VmessClient(new VmessOptions + { + Id = Uuid, Host = "server.example.net", Port = 443, UseTls = true, Sni = "", HostHeader = "cdn.example.net" + }); + + await Assert.ThrowsAsync(() => client.ConnectAsync(transport, "example.org", 443).AsTask()); + + Assert.True(transport.WrittenBytes.AsSpan().IndexOf("cdn.example.net"u8) >= 0); + } + [Fact] public void Client_IPv6Host_ConstructsWithoutThrowing() { diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index e616014..bf398bf 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -13,6 +13,19 @@ protected ProxyClient(string protocol, string host, int port) if (host.Length > 255) throw new ArgumentException("A host name is at most 255 characters.", nameof(host)); + // The characters ValidateArguments refuses in a target, for the same reason: no host name + // contains them. A NUL cut the name short at the resolver, so "localhost\0evil.example" + // connected to localhost, and the proxy host also becomes a TLS name and, over ws and + // httpupgrade, a Host header. Every client and Proxy.Create(ProxyType, ...) come through + // here. The share-link parsers whose grammar can hand such a host over refuse it first, as a + // malformed link. + int bad = IndexOfSpaceOrControl(host); + if (bad >= 0) + throw new ArgumentException( + $"A proxy host cannot contain a space or an ASCII control character; this one has " + + $"U+{(int)host[bad]:X4} at index {bad}.", + nameof(host)); + // Zero is allowed here and means the default port. ArgumentOutOfRangeException.ThrowIfNegative(port); ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); @@ -240,17 +253,34 @@ internal static void ValidateArguments(string host, int port) // internationalised name is a host name, and UTF-8 never encodes one as a byte below 0x80. // The message gives the position rather than the host, which would carry the same // characters into a log. + int bad = IndexOfSpaceOrControl(host); + if (bad >= 0) + throw new ArgumentException( + $"A target host cannot contain a space or an ASCII control character; this one has " + + $"U+{(int)host[bad]:X4} at index {bad}.", + nameof(host)); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); + ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); + } + + /// + /// The index of the first space or ASCII control character (0x00-0x1F, 0x7F) in + /// , or -1 when it has none. + /// + /// + /// One rule for every place a host enters: a target, a proxy host, and the share-link grammars + /// that hand a host over without having parsed it. + /// + internal static int IndexOfSpaceOrControl(ReadOnlySpan host) + { for (int i = 0; i < host.Length; i++) { if (host[i] <= ' ' || host[i] == (char)0x7F) // 0x7F is DEL - throw new ArgumentException( - $"A target host cannot contain a space or an ASCII control character; this one has " + - $"U+{(int)host[i]:X4} at index {i}.", - nameof(host)); + return i; } - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); - ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); + return -1; } // The EndPoint overloads spell the target the way the host-and-port ones take it. An diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs index 6fa600d..62ae72f 100644 --- a/QuickProxyNet/Clients/TrojanClient.cs +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -26,7 +26,10 @@ public sealed class TrojanClient : ProxyClient /// Creates a Trojan client from strongly-typed options. /// is null. - /// The password is empty. + /// + /// The password is empty; or, for ws and httpupgrade, the path or the Host header + /// has an ASCII control character; or the proxy host has a space or an ASCII control character. + /// public TrojanClient(TrojanOptions options) : base("trojan", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) { @@ -35,6 +38,11 @@ public TrojanClient(TrojanOptions options) if (string.IsNullOrEmpty(options.Password)) throw new ArgumentException("Trojan password must not be empty.", nameof(options)); + // A CR LF in the path or the Host header used to go into the HTTP upgrade request as it was. + if (!ProxyTransport.TryValidateRequest( + options.TransportKind, options.Path, options.TransportHostHeader, out string? requestError)) + throw new ArgumentException(requestError, nameof(options)); + Options = options; _alpn = BuildAlpn(options.Alpn); } @@ -73,14 +81,14 @@ public override async ValueTask ConnectAsync(Stream stream, string host, try { await TlsHandshake.AuthenticateAsync( - (SslStream)layered, BuildSslOptions(), Options.Sni ?? Options.Host, cancellationToken) + (SslStream)layered, BuildSslOptions(), Options.ServerName, cancellationToken) .ConfigureAwait(false); layered = await ProxyTransport.ApplyAsync( transport, layered, Options.Path, - ProxyTransport.ResolveHostHeader(Options.HostHeader, Options.Sni, Options.Host), + Options.TransportHostHeader, cancellationToken).ConfigureAwait(false); await TrojanHelper.EstablishTrojanTunnelAsync(layered, Options, host, port, cancellationToken) @@ -107,9 +115,9 @@ private TransportKind EnsureSupported() private SslClientAuthenticationOptions BuildSslOptions() => new() { - // Same precedence Xray applies: explicit SNI, else the transport Host header, else the - // server address. A ws+tls node commonly sets only 'host'. - TargetHost = Options.Sni ?? Options.HostHeader ?? Options.Host, + // Explicit SNI, else the transport Host header, else the server address, an empty one + // counting as absent (see TlsHandshake.ResolveServerName). + TargetHost = Options.ServerName, EnabledSslProtocols = SslProtocols, RemoteCertificateValidationCallback = Options.AllowInsecure ? static (_, _, _, _) => true diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 1536bd9..df4d7fc 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -24,8 +24,10 @@ public sealed class VlessClient : ProxyClient /// Creates a VLESS client from strongly-typed options. /// is null. /// - /// The options carry an invalid UUID, or, for REALITY, a public key or short id that cannot be - /// decoded, or a server name or ALPN list that a ClientHello cannot carry. + /// The options carry an invalid UUID; or, for REALITY, a public key or short id that cannot be + /// decoded, or a server name or ALPN list that a ClientHello cannot carry; or, for ws and + /// httpupgrade, an ASCII control character in the path or the Host header; or a proxy host + /// with a space or an ASCII control character. /// public VlessClient(VlessOptions options) : base("vless", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) @@ -57,10 +59,15 @@ public VlessClient(VlessOptions options) // The server name and ALPN list too. Past what one TLS record holds, the hello failed to // write after the TCP connect, as an InvalidOperationException or an // ArgumentOutOfRangeException out of ConnectAsync. - if (!TlsClientHello.TryValidate(options.RealityServerName, options.Alpn, out string? helloError)) + if (!TlsClientHello.TryValidate(options.ServerName, options.Alpn, out string? helloError)) throw new ArgumentException(helloError, nameof(options)); } + // A CR LF in the path or the Host header used to go into the HTTP upgrade request as it was. + if (!ProxyTransport.TryValidateRequest( + options.TransportKind, options.Path, options.TransportHostHeader, out string? requestError)) + throw new ArgumentException(requestError, nameof(options)); + Options = options; _alpn = BuildAlpn(options.Alpn); } @@ -111,7 +118,7 @@ public override async ValueTask ConnectAsync(Stream stream, string host, var ssl = new SslStream(layered, leaveInnerStreamOpen: false); layered = ssl; await TlsHandshake.AuthenticateAsync( - ssl, BuildSslOptions(), Options.Sni ?? Options.Host, cancellationToken).ConfigureAwait(false); + ssl, BuildSslOptions(), Options.ServerName, cancellationToken).ConfigureAwait(false); } else if (Options.Security == VlessSecurity.Reality) { @@ -124,7 +131,7 @@ await TlsHandshake.AuthenticateAsync( transport, layered, Options.Path, - ProxyTransport.ResolveHostHeader(Options.HostHeader, Options.Sni, Options.Host), + Options.TransportHostHeader, cancellationToken).ConfigureAwait(false); return await VlessHelper.EstablishVlessTunnelAsync(layered, Options, host, port, cancellationToken) @@ -167,7 +174,7 @@ private TransportKind EnsureSupported() private RealityTlsOptions BuildRealityOptions() => new() { // Bounded by the constructor, like the ALPN list below. - ServerName = Options.RealityServerName, + ServerName = Options.ServerName, // Decoded and length-checked by the constructor; EnsureSupported has already refused a // REALITY configuration without a key. PublicKey = _realityPublicKey!, @@ -177,9 +184,9 @@ private TransportKind EnsureSupported() private SslClientAuthenticationOptions BuildSslOptions() => new() { - // Same precedence Xray applies: explicit SNI, else the transport Host header, else the - // server address. A ws+tls node commonly sets only 'host'. - TargetHost = Options.Sni ?? Options.HostHeader ?? Options.Host, + // The name REALITY sends too: explicit SNI, else the transport Host header, else the server + // address, an empty one counting as absent (see TlsHandshake.ResolveServerName). + TargetHost = Options.ServerName, EnabledSslProtocols = SslProtocols, RemoteCertificateValidationCallback = ServerCertificateValidationCallback, ApplicationProtocols = _alpn diff --git a/QuickProxyNet/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs index 160ca02..91b36ba 100644 --- a/QuickProxyNet/Clients/VmessClient.cs +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -58,7 +58,9 @@ public sealed class VmessClient : ProxyClient /// Creates a VMess client from strongly-typed options. /// is null. /// - /// The options carry an invalid UUID or a non-zero . + /// The options carry an invalid UUID or a non-zero ; or, for + /// ws and httpupgrade, an ASCII control character in the path or the Host header; + /// or a proxy host with a space or an ASCII control character. /// public VmessClient(VmessOptions options) : base("vmess", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) @@ -81,6 +83,11 @@ public VmessClient(VmessOptions options) "implemented, and a non-zero value selects the legacy MD5 authentication format.", nameof(options)); + // A CR LF in the path or the Host header used to go into the HTTP upgrade request as it was. + if (!ProxyTransport.TryValidateRequest( + options.TransportKind, options.Path, options.TransportHostHeader, out string? requestError)) + throw new ArgumentException(requestError, nameof(options)); + Options = options; _alpn = BuildAlpn(options.Alpn); } @@ -138,14 +145,14 @@ public override async ValueTask ConnectAsync(Stream stream, string host, var ssl = new SslStream(layered, leaveInnerStreamOpen: false); layered = ssl; await TlsHandshake.AuthenticateAsync( - ssl, BuildSslOptions(), Options.Sni ?? Options.Host, cancellationToken).ConfigureAwait(false); + ssl, BuildSslOptions(), Options.ServerName, cancellationToken).ConfigureAwait(false); } layered = await ProxyTransport.ApplyAsync( transportKind, layered, Options.Path, - ProxyTransport.ResolveHostHeader(Options.HostHeader, Options.Sni, Options.Host), + Options.TransportHostHeader, cancellationToken).ConfigureAwait(false); } catch @@ -271,9 +278,9 @@ private VmessSecurity EnsureSupported(out TransportKind transportKind) private SslClientAuthenticationOptions BuildSslOptions() => new() { - // Same precedence Xray applies: explicit SNI, else the transport Host header, else the - // server address. A ws+tls node commonly sets only 'host'. - TargetHost = Options.Sni ?? Options.HostHeader ?? Options.Host, + // Explicit SNI, else the transport Host header, else the server address, an empty one + // counting as absent (see TlsHandshake.ResolveServerName). + TargetHost = Options.ServerName, EnabledSslProtocols = SslProtocols, RemoteCertificateValidationCallback = Options.AllowInsecure ? static (_, _, _, _) => true diff --git a/QuickProxyNet/Configs/ShadowsocksShareLink.cs b/QuickProxyNet/Configs/ShadowsocksShareLink.cs index a534fc6..3a2520f 100644 --- a/QuickProxyNet/Configs/ShadowsocksShareLink.cs +++ b/QuickProxyNet/Configs/ShadowsocksShareLink.cs @@ -395,6 +395,16 @@ private static bool TryParseHostPort( return false; } + // Scanned by hand, so nothing else has refused these, and the legacy form's base64 can hold + // any of them. A NUL cut the name short at the resolver. + int bad = ProxyClient.IndexOfSpaceOrControl(host); + if (bad >= 0) + { + error = "Shadowsocks share link server host cannot contain a space or an ASCII control character; " + + $"this one has U+{(int)host[bad]:X4} at index {bad}."; + return false; + } + if (!portText.IsEmpty) { if (!int.TryParse(portText, System.Globalization.NumberStyles.None, diff --git a/QuickProxyNet/Configs/TrojanOptions.cs b/QuickProxyNet/Configs/TrojanOptions.cs index 82e419c..5537dd3 100644 --- a/QuickProxyNet/Configs/TrojanOptions.cs +++ b/QuickProxyNet/Configs/TrojanOptions.cs @@ -39,7 +39,10 @@ public sealed class TrojanOptions /// public string? HostHeader { get; init; } - /// TLS server name (SNI). Falls back to when null. + /// + /// TLS server name (SNI). When null or empty, is sent, then + /// . + /// public string? Sni { get; init; } /// ALPN protocol identifiers for the TLS handshake, if specified. @@ -56,4 +59,16 @@ public sealed class TrojanOptions /// The resolved transport layer this configuration selects. internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); + + /// + /// The name the TLS hello carries: , else , else + /// , an empty string counting as absent. + /// + internal string ServerName => TlsHandshake.ResolveServerName(Sni, HostHeader, Host); + + /// + /// The Host header a ws or httpupgrade request carries: + /// , else , else . + /// + internal string TransportHostHeader => ProxyTransport.ResolveHostHeader(HostHeader, Sni, Host); } diff --git a/QuickProxyNet/Configs/TrojanShareLink.cs b/QuickProxyNet/Configs/TrojanShareLink.cs index c991391..1c904af 100644 --- a/QuickProxyNet/Configs/TrojanShareLink.cs +++ b/QuickProxyNet/Configs/TrojanShareLink.cs @@ -147,6 +147,15 @@ private static bool TryParse( AllowInsecure = allowInsecure, Remark = remark }; + + // The path and the Host header go into the HTTP upgrade request as they are, and %0D%0A in + // path=, host= or sni= decodes to a CR LF that ended a line of it. + if (!ProxyTransport.TryValidateRequest(options.TransportKind, options.Path, options.TransportHostHeader, out error)) + { + options = null; + return false; + } + error = null; return true; } diff --git a/QuickProxyNet/Configs/VlessOptions.cs b/QuickProxyNet/Configs/VlessOptions.cs index 2e27311..0e95c8d 100644 --- a/QuickProxyNet/Configs/VlessOptions.cs +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -63,7 +63,10 @@ public sealed class VlessOptions /// public string? HostHeader { get; init; } - /// TLS/REALITY server name (SNI). Falls back to when null. + /// + /// TLS/REALITY server name (SNI). When null or empty, is sent, then + /// . + /// public string? Sni { get; init; } /// ALPN protocol identifiers for the TLS handshake, if specified. @@ -88,12 +91,18 @@ public sealed class VlessOptions internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); /// - /// The name a REALITY ClientHello carries: , else , - /// else . + /// The name a TLS or REALITY hello carries: , else , + /// else , an empty string counting as absent. /// /// /// One property, so the share-link parser and the client constructor check the same name the /// handshake sends, whichever field it comes from. /// - internal string RealityServerName => Sni ?? HostHeader ?? Host; + internal string ServerName => TlsHandshake.ResolveServerName(Sni, HostHeader, Host); + + /// + /// The Host header a ws or httpupgrade request carries: + /// , else , else . + /// + internal string TransportHostHeader => ProxyTransport.ResolveHostHeader(HostHeader, Sni, Host); } diff --git a/QuickProxyNet/Configs/VlessShareLink.cs b/QuickProxyNet/Configs/VlessShareLink.cs index 0040c34..b1612e3 100644 --- a/QuickProxyNet/Configs/VlessShareLink.cs +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -194,11 +194,19 @@ private static bool TryParse( Remark = remark }; + // The path and the Host header go into the HTTP upgrade request as they are, and %0D%0A in + // path=, host= or sni= decodes to a CR LF that ended a line of it. + if (!ProxyTransport.TryValidateRequest(options.TransportKind, options.Path, options.TransportHostHeader, out error)) + { + options = null; + return false; + } + // The server name and ALPN list go into a ClientHello written as one TLS record. Past what it // holds, the write failed after the TCP connect with an exception ConnectAsync may not throw, // so a link that could never be sent is refused here. if (security == VlessSecurity.Reality && - !TlsClientHello.TryValidate(options.RealityServerName, options.Alpn, out error)) + !TlsClientHello.TryValidate(options.ServerName, options.Alpn, out error)) { options = null; return false; diff --git a/QuickProxyNet/Configs/VmessOptions.cs b/QuickProxyNet/Configs/VmessOptions.cs index 07322b1..33cb08c 100644 --- a/QuickProxyNet/Configs/VmessOptions.cs +++ b/QuickProxyNet/Configs/VmessOptions.cs @@ -90,7 +90,10 @@ public sealed class VmessOptions /// public bool UseTls { get; init; } - /// TLS server name (SNI). Falls back to when null. + /// + /// TLS server name (SNI). When null or empty, is sent, then + /// . + /// public string? Sni { get; init; } /// ALPN protocol identifiers for the TLS handshake, if specified. @@ -108,6 +111,18 @@ public sealed class VmessOptions /// The resolved transport layer this configuration selects. internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); + /// + /// The name the TLS hello carries: , else , else + /// , an empty string counting as absent. + /// + internal string ServerName => TlsHandshake.ResolveServerName(Sni, HostHeader, Host); + + /// + /// The Host header a ws or httpupgrade request carries: + /// , else , else . + /// + internal string TransportHostHeader => ProxyTransport.ResolveHostHeader(HostHeader, Sni, Host); + /// /// Maps onto the concrete body cipher written into the request /// header's security nibble, resolving . diff --git a/QuickProxyNet/Configs/VmessShareLink.cs b/QuickProxyNet/Configs/VmessShareLink.cs index f9a176a..c7782d9 100644 --- a/QuickProxyNet/Configs/VmessShareLink.cs +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -340,6 +340,15 @@ private static bool TryParseStandardUri( ? Uri.UnescapeDataString(uri.Fragment[1..]) : null }; + + // The path and the Host header go into the HTTP upgrade request as they are, and %0D%0A in + // path=, host= or sni= decodes to a CR LF that ended a line of it. + if (!ProxyTransport.TryValidateRequest(options.TransportKind, options.Path, options.TransportHostHeader, out error)) + { + options = null; + return false; + } + error = null; return true; } @@ -508,6 +517,16 @@ private static bool TryParseJson( return false; } + // Uri refuses such a host in every other grammar, but a JSON string holds anything, and + // a NUL in it cut the name short at the resolver. + int badHost = ProxyClient.IndexOfSpaceOrControl(host); + if (badHost >= 0) + { + error = "VMess share link server address cannot contain a space or an ASCII control character; " + + $"this one has U+{(int)host[badHost]:X4} at index {badHost}."; + return false; + } + if (GetInt32(portField, out int port) != FieldState.Ok || port <= 0 || port > 65535) { error = "VMess share link is missing a valid server port."; @@ -598,6 +617,15 @@ private static bool TryParseJson( AllowInsecure = GetBoolean(allowInsecureField) || GetBoolean(skipCertVerifyField), Remark = remark }; + + // The path and the Host header go into the HTTP upgrade request as they are, and a JSON + // string can hold the CR LF that ended a line of it. + if (!ProxyTransport.TryValidateRequest(options.TransportKind, options.Path, options.TransportHostHeader, out error)) + { + options = null; + return false; + } + error = null; return true; } diff --git a/QuickProxyNet/Internal/TlsHandshake.cs b/QuickProxyNet/Internal/TlsHandshake.cs index 2629fb7..402384f 100644 --- a/QuickProxyNet/Internal/TlsHandshake.cs +++ b/QuickProxyNet/Internal/TlsHandshake.cs @@ -24,6 +24,27 @@ namespace QuickProxyNet; /// internal static class TlsHandshake { + /// + /// The name a TLS or REALITY handshake sends in SNI: the explicit SNI, else the transport Host + /// header, else the server address. That is the precedence Xray applies, and a ws+tls node + /// commonly sets only host. + /// + /// + /// An empty string counts as absent, as it already did when the Host header is picked. Options + /// built by hand with Sni = "" used to send the empty string as the name: the hello + /// carried none of the names the options did give, and a REALITY server, which is Go's + /// crypto/tls underneath, matches a hello without one against none of its configured names. A + /// share link never produces an empty value. + /// + public static string ResolveServerName(string? sni, string? hostHeader, string serverHost) + { + if (!string.IsNullOrEmpty(sni)) + return sni; + if (!string.IsNullOrEmpty(hostHeader)) + return hostHeader; + return serverHost; + } + /// /// Performs , /// converting into diff --git a/QuickProxyNet/Internal/Transports/ProxyTransport.cs b/QuickProxyNet/Internal/Transports/ProxyTransport.cs index 8911b9c..6456d50 100644 --- a/QuickProxyNet/Internal/Transports/ProxyTransport.cs +++ b/QuickProxyNet/Internal/Transports/ProxyTransport.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace QuickProxyNet; /// @@ -93,16 +95,91 @@ public static async ValueTask ApplyAsync( /// Normalizes the configured path to a request target. /// /// + /// /// The path is otherwise sent verbatim, query and all. Xray's early-data feature encodes /// itself as ?ed=2048 on the path, and the server matches the path it was /// configured with — stripping or re-encoding the query turns a working node into a 404. + /// + /// + /// A space is the one exception, and goes out as %20. A request target cannot hold one + /// (RFC 9112 §3.2): the request line ends at it, and a server reads what follows as the HTTP + /// version. A share link's path is percent-decoded, so its %20 arrives here as a space, + /// and 68 of the 10 822 vless links in the real-world corpus carry one in their ws path. + /// Refusing them would refuse a path a server can be configured with. Xray's client builds + /// this request with Go's net/url, which writes a space in a path as %20, and the + /// server decodes it back before comparing. Control characters never get here: + /// refuses them where the configuration enters. + /// /// public static string NormalizePath(string? path) { if (string.IsNullOrEmpty(path)) return "/"; - return path[0] == '/' ? path : "/" + path; + if (path[0] != '/') + path = "/" + path; + + return path.Contains(' ') ? path.Replace(" ", "%20") : path; + } + + /// + /// Checks the two configured values a ws or httpupgrade transport writes into its + /// HTTP upgrade request: the path, in the request line, and the Host header. + /// + /// The transport. Only ws and httpupgrade write either value. + /// The configured path, before . + /// The Host header value, as picks it. + /// What is wrong, when this returns false. + /// + /// Both go into the request as the characters they are, and a share link decodes %0D%0A + /// to CR LF, so path=, host= or sni= could end a line of the request and + /// add headers, or a second request, to what the client sends the node's server or the CDN in + /// front of it. An ASCII control character belongs in neither a request target nor a host + /// name, and Go's net/url and net/http, which Xray builds the same request with, refuse one too. + /// The raw-TCP transport writes neither value, so a tcp link with junk in an unused field is left + /// working. The error names the field and the position, never the value: a path can carry a + /// secret. + /// + public static bool TryValidateRequest( + TransportKind kind, string? path, string hostHeader, [NotNullWhen(false)] out string? error) + { + if (kind is not (TransportKind.WebSocket or TransportKind.HttpUpgrade)) + { + error = null; + return true; + } + + string name = kind == TransportKind.WebSocket ? "ws" : "httpupgrade"; + + int bad = IndexOfControl(path); + if (bad >= 0) + { + error = $"The {name} transport's path cannot contain an ASCII control character; this one has " + + $"U+{(int)path![bad]:X4} at index {bad}."; + return false; + } + + bad = IndexOfControl(hostHeader); + if (bad >= 0) + { + error = $"The {name} transport's Host header (host, else sni, else the server address) cannot " + + $"contain an ASCII control character; this one has U+{(int)hostHeader[bad]:X4} at index {bad}."; + return false; + } + + error = null; + return true; + } + + private static int IndexOfControl(ReadOnlySpan value) + { + for (int i = 0; i < value.Length; i++) + { + if (value[i] < ' ' || value[i] == (char)0x7F) // 0x7F is DEL + return i; + } + + return -1; } /// From ad3d356692bd91c1f03c5a0d3a0de7431b1a3bd3 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 17:43:26 +0500 Subject: [PATCH 29/35] chore: assert the internal invariants the stream and record code relies on The suite runs the Debug build, so these are live in every test run. Each checks something only a bug in this library can break, and each guards a place where breaking it would not fail on the spot: a pooled buffer rounded up past the size a formula asked for, a zero-length read that reads as end of stream, or an HMAC that takes a key of any length. Nothing a peer, a share link or a caller controls is asserted; those stay throws, because an assert is gone from the Release build. - TlsRecordStream: room in the inbound buffer before a transport read (a 0-byte read would come back as end of stream mid-record, which RealityTlsStream takes for an orderly close); and StageRecord's payload within MaxPlaintext with room for the record (_outboundPlain is rented at 16 385 bytes and comes back at 32 KiB, so an oversized record would be sealed without a word). - TlsRecordProtection's constructor and TlsKeySchedule.ExpandLabel: a secret exactly one hash long. Every caller in the library, the tests and the benchmarks passes one. - ShadowsocksStream: SealRun takes something from a non-empty payload (otherwise WriteAsync loops forever), and FreeSpace leaves the room its own comment promises. - Request sizes the pool's rounding would hide: the SOCKS4 request against BufferSize, the VLESS request against MaxRequestSize (at the write, as length, since BuildRequest itself takes any flow), the Trojan request, the HTTP CONNECT request and the HTTP upgrade request against their computed sizes, and ProxyAddress's destination against MaxLength. Every caller of WriteTypeAndAddress, including ShadowsocksCryptoTest's direct call to BuildAddressHeader, passes at least MaxLength. - VisionStream: room for the read after Compact(1) (a 0-byte read in Undecided mode would switch the stream to Raw and hand the UUID and padding to the caller), a non-zero step when content is taken or padding skipped (otherwise a 0 that reads as end of stream, or a loop that never reads), a whole header before ReadFrameHeader, and the first frame's padding within MaxFrame. - VmessStream: OpenChunk's plaintext length (a mismatch makes the AEAD throw ArgumentException, which the CryptographicException translation lets out of ReadAsync), and SealChunk's plaintext within MaxSendPlaintextSize. - TlsWriter, in Debug only: a stack of the vectors begun and not yet ended. EndVector must end the innermost one, with the prefix size it began with, and ToArray must find none open. The check first suggested, that the prefix being patched still reads zero, misses a size one short (it lands on a zero byte of the same placeholder) and every empty vector. - RealityTlsClient: HandshakeSecrets.At within its six secrets, now a named constant that Rent uses too; and no read or write protection installed yet when the first keys go in. - RealityTlsStream.TakePending: a record in hand and a non-empty buffer (a 0 would read as end of stream). - Sha256Core: whole blocks, an eight-word state and a full schedule for Absorb and Finish (the schedule is expanded through Unsafe.Add, unchecked); an eight-word IV; a digest of whole words that fits its destination. - VmessKdf.Derive: one path element or three. Left out: an assert that the application-epoch protections replace disposed ones. The handshake disposes both on the two lines before, and TlsRecordProtection has no disposed state an assert could read. None were added inside per-byte or per-limb loops (X25519, the SHA rounds, CRC, FNV), where test vectors already pin the behaviour. Verified with the asserts live: dotnet test (Debug) passed 922, skipped 37, of 959 on net10.0 and net11.0, with no DebugAssertException in the log. The library builds with 0 warnings on all four TFMs in Release and in Debug (the TlsWriter check compiles only in Debug), the solution with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly. Co-Authored-By: Claude Opus 5 (1M context) --- QuickProxyNet/Internal/Crypto/Sha256Core.cs | 11 ++++++ QuickProxyNet/Internal/HttpHelper.cs | 3 ++ QuickProxyNet/Internal/ProxyAddress.cs | 5 +++ .../Internal/Reality/RealityTlsClient.cs | 20 ++++++++-- .../Internal/Reality/RealityTlsStream.cs | 4 ++ .../Internal/Reality/TlsKeySchedule.cs | 5 +++ .../Internal/Reality/TlsRecordLayer.cs | 15 ++++++++ QuickProxyNet/Internal/Reality/TlsWriter.cs | 37 +++++++++++++++++-- .../Internal/Shadowsocks/ShadowsocksStream.cs | 5 +++ QuickProxyNet/Internal/SocksHelper.cs | 3 ++ .../Transports/HttpUpgradeHandshake.cs | 3 ++ QuickProxyNet/Internal/TrojanHelper.cs | 4 ++ QuickProxyNet/Internal/VisionStream.cs | 18 +++++++++ QuickProxyNet/Internal/VlessHelper.cs | 4 ++ QuickProxyNet/Internal/Vmess/VmessKdf.cs | 5 +++ QuickProxyNet/Internal/Vmess/VmessStream.cs | 8 ++++ 16 files changed, 144 insertions(+), 6 deletions(-) diff --git a/QuickProxyNet/Internal/Crypto/Sha256Core.cs b/QuickProxyNet/Internal/Crypto/Sha256Core.cs index 7e9cd2d..ff7722e 100644 --- a/QuickProxyNet/Internal/Crypto/Sha256Core.cs +++ b/QuickProxyNet/Internal/Crypto/Sha256Core.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; @@ -83,6 +84,7 @@ internal static class Sha256Core internal static void ComputeHash( ReadOnlySpan iv, ReadOnlySpan data, Span destination, int outputBytes, bool vectorize) { + Debug.Assert(iv.Length == StateWords); Span state = stackalloc uint[StateWords]; iv.CopyTo(state); @@ -98,6 +100,10 @@ internal static void ComputeHash( /// internal static void Absorb(Span state, ReadOnlySpan blocks, Span schedule, bool vectorize) { + // A remainder would be dropped, and the caller would have hashed a different message. The + // schedule is expanded through Unsafe.Add, so a short one is written past its end unchecked. + Debug.Assert(blocks.Length % BlockSize == 0 && state.Length == StateWords && schedule.Length >= ScheduleWords); + while (blocks.Length >= BlockSize) { ProcessBlock(blocks, state, schedule, vectorize); @@ -114,6 +120,8 @@ internal static void Absorb(Span state, ReadOnlySpan blocks, Span state, ReadOnlySpan tail, ulong totalBytes, Span schedule, bool vectorize) { + Debug.Assert(state.Length == StateWords && schedule.Length >= ScheduleWords); // see Absorb + while (tail.Length >= BlockSize) { ProcessBlock(tail, state, schedule, vectorize); @@ -144,6 +152,9 @@ internal static void Finish( /// internal static void WriteDigest(ReadOnlySpan state, Span destination, int outputBytes) { + // A partial word would be dropped. Sha224 and Sha256 check the destination before calling. + Debug.Assert(outputBytes % 4 == 0 && outputBytes <= DigestSize && destination.Length >= outputBytes); + for (int i = 0; i < outputBytes / 4; i++) BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(i * 4), state[i]); } diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index 2e21af1..f733785 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Text; +using System.Diagnostics; using System.Net; using System.Text; @@ -82,6 +83,8 @@ private static (byte[] buffer, int length) BuildConnectionCommand( // End of headers S_crlf.CopyTo(buf.AsSpan(pos)); pos += 2; + // size is a worst case, and the pool's rounding would hide a formula that fell short of it. + Debug.Assert(pos <= size); return (buf, pos); } diff --git a/QuickProxyNet/Internal/ProxyAddress.cs b/QuickProxyNet/Internal/ProxyAddress.cs index ae82d44..38bda8e 100644 --- a/QuickProxyNet/Internal/ProxyAddress.cs +++ b/QuickProxyNet/Internal/ProxyAddress.cs @@ -25,6 +25,11 @@ internal static class ProxyAddress public static int WriteTypeAndAddress( string host, Span dest, byte ipv4Type, byte domainType, byte ipv6Type) { + // Every caller passes room for the longest address: VlessHelper, TrojanHelper and + // ShadowsocksClient rent it, and VmessRequest checks its destination first. An IPv4 host needs + // five bytes, so a buffer sized for the host in hand would work until a long name came along. + Debug.Assert(dest.Length >= MaxLength); + if (IPAddress.TryParse(host, out var ip)) { if (ip.AddressFamily == AddressFamily.InterNetwork) diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index c798180..355fef5 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Formats.Asn1; using System.Security.Cryptography; @@ -84,8 +85,11 @@ internal sealed class RealityTlsClient /// private readonly struct HandshakeSecrets(byte[] buffer, int hashLength) { + /// The secrets after the shared one, each a hash long. + private const int SecretCount = 6; + public static HandshakeSecrets Rent(int hashLength) => - new(ArrayPool.Shared.Rent(X25519.KeySize + (6 * hashLength)), hashLength); + new(ArrayPool.Shared.Rent(X25519.KeySize + (SecretCount * hashLength)), hashLength); /// The raw X25519 shared secret, before the key schedule touches it. public Span Shared => buffer.AsSpan(0, X25519.KeySize); @@ -97,8 +101,13 @@ public static HandshakeSecrets Rent(int hashLength) => public Span ClientApplicationTraffic => At(4); public Span ServerApplicationTraffic => At(5); - private Span At(int index) => - buffer.AsSpan(X25519.KeySize + (index * hashLength), hashLength); + private Span At(int index) + { + // The pool hands out more than was asked for, so a seventh secret would not fail the slice. + // It would land in the slack, outside what Rent sized for. + Debug.Assert((uint)index < SecretCount); + return buffer.AsSpan(X25519.KeySize + (index * hashLength), hashLength); + } /// Clears every secret and returns the buffer to the pool. public void Return() => ArrayPool.Shared.Return(buffer, clearArray: true); @@ -191,6 +200,9 @@ public static async ValueTask HandshakeAsync( secrets.HandshakeSecret, secrets.ClientHandshakeTraffic, secrets.ServerHandshakeTraffic, secrets.MasterSecret); + // The first read keys, so there is nothing to replace; a protection replaced here would + // be left undisposed. + Debug.Assert(records.Read is null); records.Read = new TlsRecordProtection(parsed.Suite, secrets.ServerHandshakeTraffic); // ---- Server flight ---- @@ -270,6 +282,8 @@ public static async ValueTask HandshakeAsync( await records.WriteAsync(TlsContentType.ChangeCipherSpec, ChangeCipherSpecPayload, cancellationToken) .ConfigureAwait(false); + // The first write keys: the ClientHello and the ChangeCipherSpec went in the clear. + Debug.Assert(records.Write is null); records.Write = new TlsRecordProtection(parsed.Suite, secrets.ClientHandshakeTraffic); byte[] finished = BuildFinished( diff --git a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs index db5e432..26ca6fe 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics; using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -105,6 +106,9 @@ private async ValueTask ReadFromRecordsAsync(Memory buffer, Cancellat /// Copies out of the record in hand and advances past what was taken. private int TakePending(Span buffer) { + // Every caller has a record in hand and room for some of it. A 0 from here would read as end + // of stream. + Debug.Assert(buffer.Length > 0 && !_pending.IsEmpty); int count = Math.Min(buffer.Length, _pending.Length); _pending.Span[..count].CopyTo(buffer); _pending = _pending[count..]; diff --git a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs index 46a2399..a8d20f3 100644 --- a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs +++ b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Diagnostics; using System.Security.Cryptography; using System.Text; @@ -47,6 +48,10 @@ public static void ExpandLabel( $"TLS 1.3 never expands past one hash length ({hashLength} bytes); {output.Length} were asked for.", nameof(output)); + // Every secret the schedule expands is one hash long. HMAC takes a key of any length, so a + // wrongly sliced one would derive wrong keys silently, and the symptom would look like the peer's. + Debug.Assert(secret.Length == hashLength); + // HkdfLabel = uint16 length || opaque label<7..255> || opaque context<0..255>, followed // here by the one-byte block counter HKDF-Expand appends. int labelLength = LabelPrefix.Length + label.Length; diff --git a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs index caf92c8..c5aa45e 100644 --- a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs +++ b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Security.Cryptography; @@ -90,6 +91,10 @@ internal sealed class TlsRecordProtection : IDisposable public TlsRecordProtection(TlsCipherSuite suite, ReadOnlySpan trafficSecret) { + // HMAC takes a key of any length, so a secret sliced to the wrong length would derive the wrong + // keys without complaint, and the first record would fail authentication as the peer's fault. + Debug.Assert(trafficSecret.Length == suite.HashLength); + _iv = new byte[TlsCipherSuite.NonceLength]; // On the stack rather than the heap: this is a record-protection key, and the largest a @@ -292,6 +297,10 @@ private async ValueTask ReadFromTransportAsync(CancellationToken cancell _start = 0; } + // Reached only with less than one whole record buffered: at most a header and a body one + // byte short of MaxCiphertext, against a 64 KiB buffer. + Debug.Assert(_end < _inbound.Length, "a 0-byte transport read would read as end of stream"); + int read = await transport .ReadAsync(_inbound.AsMemory(_end, _inbound.Length - _end), cancellationToken) .ConfigureAwait(false); @@ -437,6 +446,12 @@ private async ValueTask SendStagedAsync(int staged, CancellationToken cancellati /// Frames one record into ; returns the bytes written. private int StageRecord(TlsContentType type, ReadOnlySpan payload, Span destination) { + // WriteAsync refuses a larger payload and WriteApplicationDataAsync cuts records to fit. Nothing + // below would notice otherwise: the pool hands out 32 KiB for _outboundPlain's 16 385 bytes, so + // an oversized record would be sealed into the slack and sent for the peer to refuse. + Debug.Assert(payload.Length <= MaxPlaintext && + destination.Length >= payload.Length + (Write is null ? HeaderLength : RecordOverhead)); + if (Write is null) { destination[0] = (byte)type; diff --git a/QuickProxyNet/Internal/Reality/TlsWriter.cs b/QuickProxyNet/Internal/Reality/TlsWriter.cs index fd8c886..79342fd 100644 --- a/QuickProxyNet/Internal/Reality/TlsWriter.cs +++ b/QuickProxyNet/Internal/Reality/TlsWriter.cs @@ -14,6 +14,11 @@ internal sealed class TlsWriter(int capacity = 512) private byte[] _buffer = new byte[capacity]; private int _position; +#if DEBUG + /// The vectors begun and not yet ended, innermost last, with their prefix sizes. + private readonly Stack<(int Marker, int PrefixSize)> _openVectors = new(); +#endif + /// Number of bytes written so far. public int Length => _position; @@ -52,14 +57,14 @@ public void WriteZeros(int count) public int BeginVector8() { WriteByte(0); - return _position; + return Opened(1); } /// Reserves a two-byte length prefix; pass the result to . public int BeginVector16() { WriteUInt16(0); - return _position; + return Opened(2); } /// Reserves a three-byte length prefix; pass the result to . @@ -67,6 +72,15 @@ public int BeginVector24() { WriteByte(0); WriteUInt16(0); + return Opened(3); + } + + /// The marker for the vector whose length prefix was just reserved. + private int Opened(int prefixSize) + { +#if DEBUG + _openVectors.Push((_position, prefixSize)); +#endif return _position; } @@ -75,6 +89,16 @@ public int BeginVector24() /// 1, 2 or 3 — must match the BeginVector* that was used. public void EndVector(int marker, int prefixSize) { +#if DEBUG + // Vectors nest, so the one ending must be the innermost still open, with the prefix size it + // began with. A second EndVector for one marker, a mismatched size or an out-of-order end would + // patch a length over the wrong bytes, and the peer would only see a malformed hello. Checking + // that the prefix still reads zero catches few of these: a size one short lands on a zero byte + // of the same placeholder, and an empty vector's length is zero either way. + bool anyOpen = _openVectors.TryPop(out (int Marker, int PrefixSize) innermost); + System.Diagnostics.Debug.Assert(anyOpen && innermost == (marker, prefixSize)); +#endif + int length = _position - marker; int start = marker - prefixSize; @@ -107,7 +131,14 @@ public void EndVector(int marker, int prefixSize) } /// Copies the written bytes into a new array. - public byte[] ToArray() => _buffer.AsSpan(0, _position).ToArray(); + public byte[] ToArray() + { +#if DEBUG + // A vector never ended still carries a zero length. + System.Diagnostics.Debug.Assert(_openVectors.Count == 0); +#endif + return _buffer.AsSpan(0, _position).ToArray(); + } private void Ensure(int additional) { diff --git a/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs index 10075df..084a26b 100644 --- a/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs +++ b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Security.Cryptography; @@ -377,6 +378,7 @@ private Memory FreeSpace(int missing) _start = 0; } + Debug.Assert(buffer.Length - _end >= missing); return buffer.AsMemory(_end); } @@ -581,6 +583,9 @@ private int SealRun(ReadOnlySpan payload, out int consumed) } while (consumed < payload.Length); + // The buffer holds the salt and one full chunk, so the first chunk always fits. A run that took + // nothing from a non-empty payload would have the write loops go round on it forever. + Debug.Assert(consumed > 0 || payload.IsEmpty); return offset; } diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index 7697ae7..a9e3434 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -265,6 +265,9 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo totalLength += hostLength + 1; } + // BufferSize is the largest SOCKS4a request, and ArrayPool's rounding would hide a request + // that outgrew it. + Debug.Assert(totalLength <= BufferSize); await stream.WriteAsync(buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); // +----+----+----+----+----+----+----+----+ diff --git a/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs index c36170c..52c72ec 100644 --- a/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs +++ b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Text; +using System.Diagnostics; using System.Security.Cryptography; using System.Text; @@ -216,6 +217,8 @@ private static (byte[] buffer, int length, byte[]? expectedAccept) BuildRequest( Write(buffer, ref pos, "\r\n"u8); + // size is a worst case, and the pool's rounding would hide a formula that fell short of it. + Debug.Assert(pos <= size); return (buffer, pos, expectedAccept); static void Write(byte[] buffer, ref int pos, ReadOnlySpan value) diff --git a/QuickProxyNet/Internal/TrojanHelper.cs b/QuickProxyNet/Internal/TrojanHelper.cs index f496df2..48f6146 100644 --- a/QuickProxyNet/Internal/TrojanHelper.cs +++ b/QuickProxyNet/Internal/TrojanHelper.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Security.Cryptography; using System.Text; @@ -73,6 +74,9 @@ internal static int BuildRequest(Span buffer, string password, string host BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(offset), (ushort)port); offset += 2; + // MaxRequestSize is what EstablishTrojanTunnelAsync rents; the pool's rounding would hide a + // request that outgrew it. + Debug.Assert(offset + 2 <= MaxRequestSize); buffer[offset] = CR; buffer[offset + 1] = LF; return offset + 2; diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs index a2df149..cb4087d 100644 --- a/QuickProxyNet/Internal/VisionStream.cs +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Security.Cryptography; @@ -187,9 +188,13 @@ public override async ValueTask ReadAsync( if (Buffered == 0 && await FillSomeAsync(cancellationToken).ConfigureAwait(false) == 0) throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame, mid-response."); + // Past the checks above, content or padding remains, something is buffered and the caller's + // buffer is not empty. A step of zero would return 0 as though the stream had ended, or go + // round this loop without reading. if (_remainingContent > 0) { int taken = Math.Min(Math.Min(_remainingContent, Buffered), buffer.Length); + Debug.Assert(taken > 0); _buffer.AsSpan(_start, taken).CopyTo(buffer.Span); _start += taken; _remainingContent -= taken; @@ -197,6 +202,7 @@ public override async ValueTask ReadAsync( } int skipped = Math.Min(_remainingPadding, Buffered); + Debug.Assert(skipped > 0); _start += skipped; _remainingPadding -= skipped; } @@ -258,6 +264,7 @@ public override int Read(Span buffer) if (_remainingContent > 0) { int taken = Math.Min(Math.Min(_remainingContent, Buffered), buffer.Length); + Debug.Assert(taken > 0); _buffer.AsSpan(_start, taken).CopyTo(buffer); _start += taken; _remainingContent -= taken; @@ -265,6 +272,7 @@ public override int Read(Span buffer) } int skipped = Math.Min(_remainingPadding, Buffered); + Debug.Assert(skipped > 0); _start += skipped; _remainingPadding -= skipped; } @@ -303,6 +311,9 @@ private static bool EndsFraming(byte command) => private void ReadFrameHeader() { + // Both callers refuse fewer bytes first. The buffer is larger than what it holds, so a short + // header would be read out of stale bytes rather than fail. + Debug.Assert(Buffered >= HeaderSize); ReadOnlySpan header = _buffer.AsSpan(_start, HeaderSize); _command = header[0]; _remainingContent = BinaryPrimitives.ReadUInt16BigEndian(header[1..]); @@ -351,6 +362,11 @@ private void Fill(int count) private async ValueTask FillSomeAsync(CancellationToken cancellationToken) { Compact(1); + + // Callers hold fewer bytes than a UUID and a frame header, or none. A zero-length read would + // return 0, which in Undecided mode switches the stream to Raw and hands the UUID and the + // padding to the caller as payload. + Debug.Assert(_end < _buffer.Length); int read = await _inner.ReadAsync(_buffer.AsMemory(_end, _buffer.Length - _end), cancellationToken) .ConfigureAwait(false); _end += read; @@ -360,6 +376,7 @@ private async ValueTask FillSomeAsync(CancellationToken cancellationToken) private int FillSome() { Compact(1); + Debug.Assert(_end < _buffer.Length); // see FillSomeAsync int read = _inner.Read(_buffer.AsSpan(_end)); _end += read; return read; @@ -463,6 +480,7 @@ private bool TryRentPaddedFrame(ReadOnlySpan content, out byte[] frame, ou int padding = PaddingLength(content.Length); length = overhead + content.Length + padding; + Debug.Assert(padding >= 0 && length <= MaxFrame); frame = ArrayPool.Shared.Rent(length); Span span = frame.AsSpan(0, length); diff --git a/QuickProxyNet/Internal/VlessHelper.cs b/QuickProxyNet/Internal/VlessHelper.cs index ba0ffde..bf6ce19 100644 --- a/QuickProxyNet/Internal/VlessHelper.cs +++ b/QuickProxyNet/Internal/VlessHelper.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Text; namespace QuickProxyNet; @@ -53,6 +54,9 @@ internal static async ValueTask EstablishVlessTunnelAsync( try { int length = BuildRequest(buffer, options.Id, host, port, vision ? VisionStream.FlowName : default); + + // MaxRequestSize is the rental; ArrayPool's rounding would hide a request that outgrew it. + Debug.Assert(length <= MaxRequestSize); await stream.WriteAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } diff --git a/QuickProxyNet/Internal/Vmess/VmessKdf.cs b/QuickProxyNet/Internal/Vmess/VmessKdf.cs index 8626a74..0bbb6d0 100644 --- a/QuickProxyNet/Internal/Vmess/VmessKdf.cs +++ b/QuickProxyNet/Internal/Vmess/VmessKdf.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Diagnostics; using System.Security.Cryptography; namespace QuickProxyNet; @@ -122,6 +123,10 @@ private static void Derive( if (destination.Length < length) throw new ArgumentException($"Destination must be at least {length} bytes.", nameof(destination)); + // The public overloads give one path element or three. Two would compute over a pad pair that + // InitLevel never wrote. + Debug.Assert(levels is 1 or 3); + // Path order matters: label wraps the seed first (level 0), then arg1, then // arg2 (outermost). Pads for all levels live in one stack buffer. Span pads = stackalloc byte[MaxLevels * PadPairSize]; diff --git a/QuickProxyNet/Internal/Vmess/VmessStream.cs b/QuickProxyNet/Internal/Vmess/VmessStream.cs index a339a95..012f6fe 100644 --- a/QuickProxyNet/Internal/Vmess/VmessStream.cs +++ b/QuickProxyNet/Internal/Vmess/VmessStream.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Security.Cryptography; @@ -290,6 +291,10 @@ private void EnsurePlainCapacity(int plaintextLength) private void OpenChunk(int sealedLength, Memory plaintext) { int plaintextLength = sealedLength - TagSize; + + // A plaintext of any other length makes the AEAD throw ArgumentException, which the + // CryptographicException translation below would let straight out of ReadAsync. + Debug.Assert(plaintext.Length == plaintextLength); try { _reader.Open( @@ -375,6 +380,9 @@ public async ValueTask CompleteWriteAsync(CancellationToken cancellationToken = // Frames and seals one chunk into _sendBuffer; returns the number of wire bytes. private int SealChunk(ReadOnlySpan plaintext) { + // WriteAsync cuts every write to this, which is what the send buffer holds with the length + // prefix and the tag. + Debug.Assert(plaintext.Length <= MaxSendPlaintextSize); byte[] buffer = _sendBuffer ??= ArrayPool.Shared.Rent(SendBufferSize); int sealedLength = plaintext.Length + TagSize; From a7ae68e5e310142af4b46a04603a546bc5a694b5 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 17:48:09 +0500 Subject: [PATCH 30/35] fix: report a socks link without a host as malformed, as an http one already was Proxy.Create(string) documents FormatException for a link whose scheme is known but which is malformed. "http://" was reported that way, because Uri refuses an http URI with no host. "socks4://", "socks4a://" and "socks5://" were not: Uri takes an empty authority for a scheme it has no rules of its own for, so those links parsed, reached the client constructor as an empty host and left as an ArgumentException. The same broken link changed exception type with its scheme, and ArgumentException is the type the contract keeps for a caller's own mistake. The classic branch of Proxy.Create(string) now refuses a parsed link with an empty host as a FormatException, before any client exists. Proxy.Create(Uri) is unchanged; there the Uri is the caller's own argument. Found while tightening Create_MalformedKnownScheme_ThrowsFormat, which asserted ThrowsAny for "socks5://" and so would also have passed on a failed Debug.Assert. It now asserts FormatException for socks4://, socks4a://, socks5:// and http://. Against the old Proxy.cs (stashed, test kept) it fails on both TFMs at socks4://: expected FormatException, actual ArgumentException. Left as it was: "socks5://example.com", with no port, still leaves as an ArgumentOutOfRangeException. Uri reports the missing port as -1 for these schemes and the constructor refuses a negative port, where http:// gets 80 from Uri. Refusing that link as malformed and defaulting to port 1080 are both defensible, and choosing between them is not a fix. Verified: dotnet test (Debug, asserts live) passed 922, skipped 37, of 959 on net10.0 and net11.0. The library builds in Release with 0 warnings on all four TFMs, the solution with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly. Co-Authored-By: Claude Opus 5 (1M context) --- QuickProxyNet.Tests/ProxyFactoryTest.cs | 10 ++++++++-- QuickProxyNet/Proxy.cs | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index 7eaa5a7..260460a 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -351,8 +351,14 @@ public void Create_LinkWhoseProxyHostHasAControlCharacter_IsRefusedAsMalformed() [Fact] public void Create_MalformedKnownScheme_ThrowsFormat() { - Assert.ThrowsAny(() => Create("socks5://")); - Assert.ThrowsAny(() => Create("vless://not-a-valid-link")); + // Uri takes an empty authority for the socks schemes, which it has no rules for, so these reached + // the client constructor as an empty host and left as an ArgumentException. "http://" was + // already a FormatException, and the same link must not change type with its scheme. + Assert.Throws(() => Create("socks4://")); + Assert.Throws(() => Create("socks4a://")); + Assert.Throws(() => Create("socks5://")); + Assert.Throws(() => Create("http://")); + Assert.Throws(() => Create("vless://not-a-valid-link")); } // === Proxy.ConnectAsync(string, ...) === diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index b73d70a..c89e3b3 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -161,6 +161,13 @@ public static IProxyClient Create(string proxyLink) throw new FormatException( $"The {scheme} proxy link is not a well-formed URI (expected '{scheme}://[user:password@]host:port')."); + // Uri takes an empty authority for a scheme it has no rules of its own for, so "socks5://" + // parsed, reached the client constructor as an empty host and left as an ArgumentException, + // while "http://" was already refused here. + if (uri.Host.Length == 0) + throw new FormatException( + $"The {scheme} proxy link has no host (expected '{scheme}://[user:password@]host:port')."); + return Tag(Create(uri), trimmed); } From 624435bf1ff9e833c1b3654f7430d22df8da12b6 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 17:54:10 +0500 Subject: [PATCH 31/35] test: assert the exact exception where a catch-all would pass a failed assert Under dotnet test a failed Debug.Assert is a DebugAssertException thrown into the test that hit it, so a test that accepts any exception passes it. These did. Every row's exception was checked by running it, and none varies: - ProxyFactoryTest.Create_MalformedLink_NeverEchoesTheCredential took ThrowsAny; all four links are FormatException. - ProxyFactoryTest.TryCreate_BadLink_ReturnsFalseWithAReason required only that the reason contain "Exception", which "DebugAssertException: ..." does. Each row now names its type: ArgumentException for the empty, blank and scheme-less text, NotSupportedException for hysteria2 and rc4-md5, FormatException for the broken ss userinfo, the vmess payload that is not base64 JSON and the 31-character vless id. - ShadowsocksShareLinkTest.Errors_NeverEchoTheCredential took ThrowsAny. Each row now names its type: FormatException for the three malformed authorities, NotSupportedException for the refused cipher, the plugin and the three swapped-field links. - VlessTest.HtmlEscapedRealityLink_StartsATlsHandshake_NotACleartextRequest took ThrowsAny around a handshake over an empty MemoryStream, which ends as a RealityHandshakeException. - FactoryTest.BadProtocolUri caught Exception and checked its type, so it could not hide an assert, but it passed when nothing was thrown; it now asserts NotSupportedException. The audit that found these named four sites by line at 8e6166a. VlessTest.cs:281, ProxyFactoryTest.cs:315 and ShadowsocksShareLinkTest.cs:264 are three of the tests above; ProxyFactoryTest.cs:241, Create_MalformedKnownScheme_ThrowsFormat, was tightened in a7ae68e together with the fix it turned up. TryCreate_BadLink_ReturnsFalseWithAReason and FactoryTest.BadProtocolUri were not on that list. The remaining catch blocks in the tests rethrow or filter on named exception types, so a DebugAssertException passes through them. Verified: dotnet test (Debug, asserts live) passed 922, skipped 37, of 959 on net10.0 and net11.0, with every row of the tightened tests passing and no DebugAssertException, and dotnet test -c Release gave the same counts. The library builds in Release with 0 warnings on all four TFMs, the solution with the two known CA2022 warnings in TlsRecordStreamTest, and tools/CorpusCheck cleanly. Co-Authored-By: Claude Opus 5 (1M context) --- QuickProxyNet.Tests/FactoryTest.cs | 10 ++----- QuickProxyNet.Tests/ProxyFactoryTest.cs | 28 +++++++++++-------- .../ShadowsocksShareLinkTest.cs | 25 +++++++++-------- QuickProxyNet.Tests/VlessTest.cs | 2 +- 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/QuickProxyNet.Tests/FactoryTest.cs b/QuickProxyNet.Tests/FactoryTest.cs index c8f9511..b3fd3f6 100644 --- a/QuickProxyNet.Tests/FactoryTest.cs +++ b/QuickProxyNet.Tests/FactoryTest.cs @@ -10,14 +10,8 @@ public void BadProtocolUri(string stringUri) { Uri uri = new Uri(stringUri); - try - { - Proxy.Create(uri); - } - catch (Exception e) - { - Assert.IsType(e); - } + // The try and catch this replaces also passed when nothing was thrown. + Assert.Throws(() => Proxy.Create(uri)); } [Theory] diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs index 260460a..c59a827 100644 --- a/QuickProxyNet.Tests/ProxyFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -431,7 +431,9 @@ public async Task ProxyConnect_WithASilentProxy_TimesOutWithTheTimeoutCode() [InlineData("vmess://SECRETPASSWORD-this-is-not-base64-json")] // not base64 JSON public void Create_MalformedLink_NeverEchoesTheCredential(string link) { - Exception ex = Assert.ThrowsAny(() => Create(link)); + // Every one of these is a malformed link, whichever parser refuses it. Accepting any exception + // would also have accepted a failed Debug.Assert. + var ex = Assert.Throws(() => Create(link)); for (Exception? e = ex; e is not null; e = e.InnerException) Assert.DoesNotContain("SECRET", e.Message); @@ -452,23 +454,25 @@ public void TryCreate_GoodLink_ReturnsTrueAndNoError(string link) } [Theory] - [InlineData("")] // empty - [InlineData(" ")] // whitespace only - [InlineData("example.com:1080")] // no scheme - [InlineData("hysteria2://not-a-scheme-we-speak@host:443")] // unsupported scheme - [InlineData("ss://not!base64!@host:443")] // known scheme, broken userinfo - [InlineData("ss://rc4-md5:pw@host:8388")] // known scheme, cipher we refuse - [InlineData("vmess://this-is-not-base64-json")] // known scheme, broken payload - [InlineData("vless://" + "a-31-character-id-aaaaaaaaaaaaa" + "@example.com:443")] // id length 31: too long to derive, too short to be hex - public void TryCreate_BadLink_ReturnsFalseWithAReason(string link) + [InlineData("", "ArgumentException")] // empty + [InlineData(" ", "ArgumentException")] // whitespace only + [InlineData("example.com:1080", "ArgumentException")] // no scheme + [InlineData("hysteria2://not-a-scheme-we-speak@host:443", "NotSupportedException")] // unsupported scheme + [InlineData("ss://not!base64!@host:443", "FormatException")] // known scheme, broken userinfo + [InlineData("ss://rc4-md5:pw@host:8388", "NotSupportedException")] // known scheme, cipher we refuse + [InlineData("vmess://this-is-not-base64-json", "FormatException")] // known scheme, broken payload + [InlineData("vless://" + "a-31-character-id-aaaaaaaaaaaaa" + "@example.com:443", "FormatException")] // id length 31: too long to derive, too short to be hex + public void TryCreate_BadLink_ReturnsFalseWithAReason(string link, string exception) { Assert.False(Proxy.TryCreate(link, out IProxyClient? client, out string? error)); Assert.Null(client); Assert.NotNull(error); // The reason has to name the exception type: that is what separates "this link is junk" - // from "a parser threw something nobody planned for", which is a library bug. - Assert.Contains("Exception", error); + // from "a parser threw something nobody planned for", which is a library bug. It has to be + // this link's type, too: any name ending in Exception would also let a failed Debug.Assert + // through, as "DebugAssertException: ...". + Assert.StartsWith(exception + ":", error); } /// diff --git a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs index 9a4a8ec..601e3ab 100644 --- a/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs +++ b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs @@ -251,17 +251,20 @@ public void Parse_Legacy_MinimalHostWithoutPort_ParsesWithTheDefaultPort() /// Whatever a malformed link throws, the credential in it must not be in the message. [Theory] - [InlineData("ss://aes-256-gcm:SECRETPASSWORD@:8388")] - [InlineData("ss://aes-256-gcm:SECRETPASSWORD@example.com:99999")] - [InlineData("ss://aes-256-gcm:SECRETPASSWORD@[2001:db8::1")] - [InlineData("ss://rc4-md5:SECRETPASSWORD@example.com:8388")] - [InlineData("ss://aes-256-gcm:SECRETPASSWORD@example.com:8388/?plugin=v2ray-plugin%3Bmode%3Dwebsocket")] - [InlineData("ss://SECRETPASSWORD:aes-256-gcm@example.com:8388")] // swapped fields: the password lands where the cipher goes - [InlineData("ss://SECRET%2FPASSWORD%3D:aes-256-gcm@example.com:8388")] // same, with characters no cipher name has - [InlineData("ss://U0VDUkVUUEFTU1dPUkQ6YWVzLTI1Ni1nY20@example.com:8388")] // same, base64("SECRETPASSWORD:aes-256-gcm") - public void Errors_NeverEchoTheCredential(string link) - { - Exception ex = Assert.ThrowsAny(() => ShadowsocksClient.FromShareLink(link)); + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@:8388", typeof(FormatException))] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@example.com:99999", typeof(FormatException))] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@[2001:db8::1", typeof(FormatException))] + [InlineData("ss://rc4-md5:SECRETPASSWORD@example.com:8388", typeof(NotSupportedException))] + [InlineData("ss://aes-256-gcm:SECRETPASSWORD@example.com:8388/?plugin=v2ray-plugin%3Bmode%3Dwebsocket", typeof(NotSupportedException))] + [InlineData("ss://SECRETPASSWORD:aes-256-gcm@example.com:8388", typeof(NotSupportedException))] // swapped fields: the password lands where the cipher goes + [InlineData("ss://SECRET%2FPASSWORD%3D:aes-256-gcm@example.com:8388", typeof(NotSupportedException))] // same, with characters no cipher name has + [InlineData("ss://U0VDUkVUUEFTU1dPUkQ6YWVzLTI1Ni1nY20@example.com:8388", typeof(NotSupportedException))] // same, base64("SECRETPASSWORD:aes-256-gcm") + public void Errors_NeverEchoTheCredential(string link, Type expected) + { + // A link the parser cannot read is a FormatException; one naming a cipher or plugin this library + // does not speak is a NotSupportedException. Any exception at all would also have been a failed + // Debug.Assert. + Exception ex = Assert.Throws(expected, () => ShadowsocksClient.FromShareLink(link)); for (Exception? e = ex; e is not null; e = e.InnerException) Assert.DoesNotContain("SECRET", e.Message); diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index 04c0d46..3b2370c 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -279,7 +279,7 @@ public void HtmlEscapedRealityLink_StartsATlsHandshake_NotACleartextRequest() // A MemoryStream answers every read with "end of stream", so the handshake cannot // complete — but what was written before it failed is the point. var transport = new MemoryStream(); - Assert.ThrowsAny(() => + Assert.Throws(() => new VlessClient(o).ConnectAsync(transport, "example.com", 443).AsTask().GetAwaiter().GetResult()); byte[] written = transport.ToArray(); From 0fcbe7e0e769c1b8161eab2d66043b2f16e45b9d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 17:54:11 +0500 Subject: [PATCH 32/35] docs: say what a failed Debug.Assert does under dotnet test, and what may be asserted The library now asserts its internal invariants (ad3d356), and the suite runs the Debug build, so they are live in every test run. AGENTS.md's Testing section says what that means for someone writing a test or an assert: a failed one fails only the test that hit it, and aborts the whole run from a thread nobody awaits; an assert is for an invariant only a bug in this library can break, never for anything a peer, a share link or a caller controls, because it is gone from the Release build; and a test that catches Exception, in whatever form, hides one. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 422d5ac..caab3dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -419,6 +419,16 @@ with the raw token in the message. Discovery-time `FactAttribute.Skip` is the mechanism that actually works, and environment variables do not change mid-run, so evaluating the gate in the attribute constructor is exact. +**A failed `Debug.Assert` fails only the test that hit it.** `dotnet test` runs the Debug build, so +the library's asserts are live in the suite. testhost's trace listener turns a failed one into a +`DebugAssertException` on the asserting thread, which fails the test awaiting it; from a thread +nobody awaits, it crashes the test host and aborts the run. Asserts are for invariants that only a +bug in this library can break, never for anything a peer, a share link or a caller controls: that +must throw, because an assert is gone from the Release build and a hostile peer walks past it. And +anything that catches `Exception` hides a failed assert — `Assert.ThrowsAny`, a `catch` +that only inspects a message, or a test that accepts whatever reason `Proxy.TryCreate` gives. Assert +the exact exception type the code throws. + If a docker run is interrupted, clean up with: ```bash From 1acdbad9cdf8d092a6e45b237317d5dc6a18b5c0 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Mon, 14 Sep 2026 17:56:35 +0500 Subject: [PATCH 33/35] chore: store SocksHelper.cs as text again An earlier line-ending pass left the file ending in a lone CR, and git's text=auto detection then treated it as binary: stored unnormalised, with its diffs shown as binary. It now ends in CRLF like the rest of the working tree and is stored with LF like every other source file. Line endings only; no content changes. Co-Authored-By: Claude Opus 5 (1M context) --- QuickProxyNet/Internal/SocksHelper.cs | 684 +++++++++++++------------- 1 file changed, 342 insertions(+), 342 deletions(-) diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index a9e3434..09f7082 100644 --- a/QuickProxyNet/Internal/SocksHelper.cs +++ b/QuickProxyNet/Internal/SocksHelper.cs @@ -1,342 +1,342 @@ -using System.Buffers; -using System.Buffers.Binary; -using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using System.Text; - -namespace QuickProxyNet; - -internal static class SocksHelper -{ - // One buffer holds every message this helper writes, so it is sized for the largest of them: - // SOCKS4a request VN(1) CD(1) DSTPORT(2) DSTIP(4) USERID(255) NUL(1) HOST(255) NUL(1) 520 - // SOCKS5 auth VER(1) ULEN(1) UNAME(255) PLEN(1) PASSWD(255) 513 - // SOCKS4 request VN(1) CD(1) DSTPORT(2) DSTIP(4) USERID(255) NUL(1) 264 - // SOCKS5 request VER(1) CMD(1) RSV(1) ATYP(1) LEN(1) DST.ADDR(255) DST.PORT(2) 262 - // SOCKS5 greeting VER(1) NMETHODS(1) METHODS(2) 4 - // Replies are read into the same buffer and are smaller: at most 257 bytes in one read (the - // rest of a SOCKS5 reply naming a domain), 8 for SOCKS4. It was 513, which a SOCKS4a request - // with a long user id and host does not fit; ArrayPool rounding the rental up to 1024 is the - // only reason that worked. - internal const int BufferSize = 520; - private const int ProtocolVersion4 = 4; - private const int ProtocolVersion5 = 5; - private const int SubnegotiationVersion = 1; // Socks5 username & password auth - private const byte METHOD_NO_AUTH = 0; - private const byte METHOD_USERNAME_PASSWORD = 2; - private const byte CMD_CONNECT = 1; - private const byte ATYP_IPV4 = 1; - private const byte ATYP_DOMAIN_NAME = 3; - private const byte ATYP_IPV6 = 4; - private const byte Socks5_Success = 0; - private const byte Socks4_Success = 90; - private const byte Socks4_AuthFailed = 93; - - - internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string host, int port, - NetworkCredential? credentials, CancellationToken cancellationToken) - { - var buffer = ArrayPool.Shared.Rent(BufferSize); - try - { - // https://tools.ietf.org/html/rfc1928 - - // +----+----------+----------+ - // |VER | NMETHODS | METHODS | - // +----+----------+----------+ - // | 1 | 1 | 1 to 255 | - // +----+----------+----------+ - buffer[0] = ProtocolVersion5; - if (credentials is null) - { - buffer[1] = 1; - buffer[2] = METHOD_NO_AUTH; - } - else - { - buffer[1] = 2; - buffer[2] = METHOD_NO_AUTH; - buffer[3] = METHOD_USERNAME_PASSWORD; - } - - await stream.WriteAsync(buffer.AsMemory(0, buffer[1] + 2), cancellationToken).ConfigureAwait(false); - - // +----+--------+ - // |VER | METHOD | - // +----+--------+ - // | 1 | 1 | - // +----+--------+ - await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); - VerifyProtocolVersion(ProtocolVersion5, buffer[0]); - - switch (buffer[1]) - { - case METHOD_NO_AUTH: - // continue - break; - - case METHOD_USERNAME_PASSWORD: - { - // https://tools.ietf.org/html/rfc1929 - if (credentials is null) - // If the server is behaving well, it shouldn't pick username and password auth - // because we don't claim to support it when we don't have credentials. - // Just being defensive here. - throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, "SOCKS server requested username & password authentication."); - - // +----+------+----------+------+----------+ - // |VER | ULEN | UNAME | PLEN | PASSWD | - // +----+------+----------+------+----------+ - // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | - // +----+------+----------+------+----------+ - buffer[0] = SubnegotiationVersion; - var usernameLength = EncodeString(credentials.UserName, buffer.AsSpan(2), - nameof(credentials.UserName)); - buffer[1] = usernameLength; - var passwordLength = EncodeString(credentials.Password, buffer.AsSpan(3 + usernameLength), - nameof(credentials.Password)); - buffer[2 + usernameLength] = passwordLength; - await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), cancellationToken) - .ConfigureAwait(false); - - // +----+--------+ - // |VER | STATUS | - // +----+--------+ - // | 1 | 1 | - // +----+--------+ - await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); - if (buffer[0] != SubnegotiationVersion) - throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, - $"Unexpected SOCKS5 auth subnegotiation version. Expected {SubnegotiationVersion}, got {buffer[0]}."); - if (buffer[1] != Socks5_Success) - throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); - break; - } - - default: - throw new ProxyProtocolException(ProxyErrorCode.SocksNoAuthMethod, "SOCKS server did not return a suitable authentication method."); - } - - - // +----+-----+-------+------+----------+----------+ - // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | - // +----+-----+-------+------+----------+----------+ - // | 1 | 1 | X'00' | 1 | Variable | 2 | - // +----+-----+-------+------+----------+----------+ - buffer[0] = ProtocolVersion5; - buffer[1] = CMD_CONNECT; - buffer[2] = 0; - int addressLength; - - if (IPAddress.TryParse(host, out var hostIP)) - { - if (hostIP.AddressFamily == AddressFamily.InterNetwork) - { - buffer[3] = ATYP_IPV4; - hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); - Debug.Assert(bytesWritten == 4); - addressLength = 4; - } - else - { - Debug.Assert(hostIP.AddressFamily == AddressFamily.InterNetworkV6); - buffer[3] = ATYP_IPV6; - hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); - Debug.Assert(bytesWritten == 16); - addressLength = 16; - } - } - else - { - buffer[3] = ATYP_DOMAIN_NAME; - var hostLength = EncodeString(host, buffer.AsSpan(5), nameof(host)); - buffer[4] = hostLength; - addressLength = hostLength + 1; - } - - BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)port); - - await stream.WriteAsync(buffer.AsMemory(0, addressLength + 6), cancellationToken).ConfigureAwait(false); - - // +----+-----+-------+------+----------+----------+ - // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT | - // +----+-----+-------+------+----------+----------+ - // | 1 | 1 | X'00' | 1 | Variable | 2 | - // +----+-----+-------+------+----------+----------+ - await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), cancellationToken).ConfigureAwait(false); - VerifyProtocolVersion(ProtocolVersion5, buffer[0]); - if (buffer[1] != Socks5_Success) - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, - $"SOCKS5 server rejected connection to {host}:{port} (reply code: 0x{buffer[1]:X2})."); - var bytesToSkip = buffer[3] switch - { - ATYP_IPV4 => 5, - ATYP_IPV6 => 17, - ATYP_DOMAIN_NAME => buffer[4] + 2, - _ => throw new ProxyProtocolException(ProxyErrorCode.SocksBadAddressType, "SOCKS server returned an unknown address type.") - }; - await stream.ReadExactlyAsync(buffer.AsMemory(0, bytesToSkip), cancellationToken).ConfigureAwait(false); - // response address not used - } - finally - { - // The request held the username and password (SOCKS5) or the user id (SOCKS4). - ArrayPool.Shared.Return(buffer, clearArray: credentials is not null); - } - } - - internal static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool isVersion4a, string host, int port, - NetworkCredential? credentials, CancellationToken cancellationToken) - { - // The client constructors have checked this already, but NetworkCredential is mutable, and a - // user name changed after construction must not reach the wire either. - ValidateUserId(credentials, nameof(credentials)); - - var buffer = ArrayPool.Shared.Rent(BufferSize); - - try - { - // https://www.openssh.com/txt/socks4.protocol - - // +----+----+----+----+----+----+----+----+----+----+....+----+ - // | VN | CD | DSTPORT | DSTIP | USERID |NULL| - // +----+----+----+----+----+----+----+----+----+----+....+----+ - // 1 1 2 4 variable 1 - buffer[0] = ProtocolVersion4; - buffer[1] = CMD_CONNECT; - - BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(2), (ushort)port); - - IPAddress? ipv4Address = null; - if (IPAddress.TryParse(host, out var hostIP)) - { - if (hostIP.AddressFamily == AddressFamily.InterNetwork) - ipv4Address = hostIP; - else if (hostIP.IsIPv4MappedToIPv6) - ipv4Address = hostIP.MapToIPv4(); - else - throw new ProxyProtocolException(ProxyErrorCode.SocksIPv6NotSupported, "SOCKS4 does not support IPv6 addresses."); - } - else if (!isVersion4a) - { - // Socks4 does not support domain names - try to resolve it here - IPAddress[] addresses; - try - { - addresses = - await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationToken) - .ConfigureAwait(false); - } - catch (Exception ex) - { - throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address.", ex); - } - - if (addresses.Length == 0) - throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address."); - - ipv4Address = addresses[0]; - } - - if (ipv4Address is null) - { - Debug.Assert(isVersion4a); - buffer[4] = 0; - buffer[5] = 0; - buffer[6] = 0; - buffer[7] = 255; - } - else - { - ipv4Address.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); - Debug.Assert(bytesWritten == 4); - } - - var usernameLength = EncodeString(credentials?.UserName, buffer.AsSpan(8), nameof(credentials.UserName)); - buffer[8 + usernameLength] = 0; - var totalLength = 9 + usernameLength; - - if (ipv4Address is null) - { - // https://www.openssh.com/txt/socks4a.protocol - var hostLength = EncodeString(host, buffer.AsSpan(totalLength), nameof(host)); - buffer[totalLength + hostLength] = 0; - totalLength += hostLength + 1; - } - - // BufferSize is the largest SOCKS4a request, and ArrayPool's rounding would hide a request - // that outgrew it. - Debug.Assert(totalLength <= BufferSize); - await stream.WriteAsync(buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); - - // +----+----+----+----+----+----+----+----+ - // | VN | CD | DSTPORT | DSTIP | - // +----+----+----+----+----+----+----+----+ - // 1 1 2 4 - - - await stream.ReadExactlyAsync(buffer.AsMemory(0, 8), cancellationToken).ConfigureAwait(false); - - if (buffer[0] != 0) - throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, - $"Unexpected SOCKS4 reply version. Expected 0, got {buffer[0]}."); - - switch (buffer[1]) - { - case Socks4_Success: - // Nothing to do - break; - case Socks4_AuthFailed: - throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); - default: - throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); - } - // response address not used - } - finally - { - // The request held the username and password (SOCKS5) or the user id (SOCKS4). - ArrayPool.Shared.Return(buffer, clearArray: credentials is not null); - } - } - - /// - /// Refuses a SOCKS4 user id the wire format cannot carry: one with a NUL in it. - /// - /// - /// SOCKS4 ends the user id at its first NUL, and the proxy reads whatever follows as the next - /// field. For SOCKS4a that is the host, so alice\0evil.example as the user id of a request - /// for good.example reached the proxy as user alice asking for - /// evil.example. The message never includes the user id, which is a credential. - /// - internal static void ValidateUserId(NetworkCredential? credentials, string paramName) - { - if (credentials is not null && credentials.UserName.Contains('\0')) - throw new ArgumentException( - "A SOCKS4 user id cannot contain a NUL character: the protocol ends the user id at the " + - "first one, and the proxy would read what follows as the next field.", - paramName); - } - - private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName) - { - // The length goes out as a single byte, so the write is capped at 255 whatever room the - // rented buffer has. ArrayPool rounds 520 up to 1024, and a string that fit the buffer but - // not the length byte used to escape ConnectAsync as an OverflowException from the cast. - if (!Encoding.UTF8.TryGetBytes(chars, buffer[..Math.Min(buffer.Length, 255)], out int written)) - throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, - $"Encoding the {parameterName} took more than the maximum of 255 bytes"); - - return (byte)written; - } - - private static void VerifyProtocolVersion(byte expected, byte version) - { - if (expected != version) - throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, - $"Unexpected SOCKS protocol version. Required {expected}, got {version}."); - } - - -} \ No newline at end of file +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet; + +internal static class SocksHelper +{ + // One buffer holds every message this helper writes, so it is sized for the largest of them: + // SOCKS4a request VN(1) CD(1) DSTPORT(2) DSTIP(4) USERID(255) NUL(1) HOST(255) NUL(1) 520 + // SOCKS5 auth VER(1) ULEN(1) UNAME(255) PLEN(1) PASSWD(255) 513 + // SOCKS4 request VN(1) CD(1) DSTPORT(2) DSTIP(4) USERID(255) NUL(1) 264 + // SOCKS5 request VER(1) CMD(1) RSV(1) ATYP(1) LEN(1) DST.ADDR(255) DST.PORT(2) 262 + // SOCKS5 greeting VER(1) NMETHODS(1) METHODS(2) 4 + // Replies are read into the same buffer and are smaller: at most 257 bytes in one read (the + // rest of a SOCKS5 reply naming a domain), 8 for SOCKS4. It was 513, which a SOCKS4a request + // with a long user id and host does not fit; ArrayPool rounding the rental up to 1024 is the + // only reason that worked. + internal const int BufferSize = 520; + private const int ProtocolVersion4 = 4; + private const int ProtocolVersion5 = 5; + private const int SubnegotiationVersion = 1; // Socks5 username & password auth + private const byte METHOD_NO_AUTH = 0; + private const byte METHOD_USERNAME_PASSWORD = 2; + private const byte CMD_CONNECT = 1; + private const byte ATYP_IPV4 = 1; + private const byte ATYP_DOMAIN_NAME = 3; + private const byte ATYP_IPV6 = 4; + private const byte Socks5_Success = 0; + private const byte Socks4_Success = 90; + private const byte Socks4_AuthFailed = 93; + + + internal static async ValueTask EstablishSocks5TunnelAsync(Stream stream, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + // https://tools.ietf.org/html/rfc1928 + + // +----+----------+----------+ + // |VER | NMETHODS | METHODS | + // +----+----------+----------+ + // | 1 | 1 | 1 to 255 | + // +----+----------+----------+ + buffer[0] = ProtocolVersion5; + if (credentials is null) + { + buffer[1] = 1; + buffer[2] = METHOD_NO_AUTH; + } + else + { + buffer[1] = 2; + buffer[2] = METHOD_NO_AUTH; + buffer[3] = METHOD_USERNAME_PASSWORD; + } + + await stream.WriteAsync(buffer.AsMemory(0, buffer[1] + 2), cancellationToken).ConfigureAwait(false); + + // +----+--------+ + // |VER | METHOD | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); + VerifyProtocolVersion(ProtocolVersion5, buffer[0]); + + switch (buffer[1]) + { + case METHOD_NO_AUTH: + // continue + break; + + case METHOD_USERNAME_PASSWORD: + { + // https://tools.ietf.org/html/rfc1929 + if (credentials is null) + // If the server is behaving well, it shouldn't pick username and password auth + // because we don't claim to support it when we don't have credentials. + // Just being defensive here. + throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, "SOCKS server requested username & password authentication."); + + // +----+------+----------+------+----------+ + // |VER | ULEN | UNAME | PLEN | PASSWD | + // +----+------+----------+------+----------+ + // | 1 | 1 | 1 to 255 | 1 | 1 to 255 | + // +----+------+----------+------+----------+ + buffer[0] = SubnegotiationVersion; + var usernameLength = EncodeString(credentials.UserName, buffer.AsSpan(2), + nameof(credentials.UserName)); + buffer[1] = usernameLength; + var passwordLength = EncodeString(credentials.Password, buffer.AsSpan(3 + usernameLength), + nameof(credentials.Password)); + buffer[2 + usernameLength] = passwordLength; + await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), cancellationToken) + .ConfigureAwait(false); + + // +----+--------+ + // |VER | STATUS | + // +----+--------+ + // | 1 | 1 | + // +----+--------+ + await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); + if (buffer[0] != SubnegotiationVersion) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS5 auth subnegotiation version. Expected {SubnegotiationVersion}, got {buffer[0]}."); + if (buffer[1] != Socks5_Success) + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); + break; + } + + default: + throw new ProxyProtocolException(ProxyErrorCode.SocksNoAuthMethod, "SOCKS server did not return a suitable authentication method."); + } + + + // +----+-----+-------+------+----------+----------+ + // |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + buffer[0] = ProtocolVersion5; + buffer[1] = CMD_CONNECT; + buffer[2] = 0; + int addressLength; + + if (IPAddress.TryParse(host, out var hostIP)) + { + if (hostIP.AddressFamily == AddressFamily.InterNetwork) + { + buffer[3] = ATYP_IPV4; + hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); + Debug.Assert(bytesWritten == 4); + addressLength = 4; + } + else + { + Debug.Assert(hostIP.AddressFamily == AddressFamily.InterNetworkV6); + buffer[3] = ATYP_IPV6; + hostIP.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); + Debug.Assert(bytesWritten == 16); + addressLength = 16; + } + } + else + { + buffer[3] = ATYP_DOMAIN_NAME; + var hostLength = EncodeString(host, buffer.AsSpan(5), nameof(host)); + buffer[4] = hostLength; + addressLength = hostLength + 1; + } + + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(addressLength + 4), (ushort)port); + + await stream.WriteAsync(buffer.AsMemory(0, addressLength + 6), cancellationToken).ConfigureAwait(false); + + // +----+-----+-------+------+----------+----------+ + // |VER | REP | RSV | ATYP | DST.ADDR | DST.PORT | + // +----+-----+-------+------+----------+----------+ + // | 1 | 1 | X'00' | 1 | Variable | 2 | + // +----+-----+-------+------+----------+----------+ + await stream.ReadExactlyAsync(buffer.AsMemory(0, 5), cancellationToken).ConfigureAwait(false); + VerifyProtocolVersion(ProtocolVersion5, buffer[0]); + if (buffer[1] != Socks5_Success) + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"SOCKS5 server rejected connection to {host}:{port} (reply code: 0x{buffer[1]:X2})."); + var bytesToSkip = buffer[3] switch + { + ATYP_IPV4 => 5, + ATYP_IPV6 => 17, + ATYP_DOMAIN_NAME => buffer[4] + 2, + _ => throw new ProxyProtocolException(ProxyErrorCode.SocksBadAddressType, "SOCKS server returned an unknown address type.") + }; + await stream.ReadExactlyAsync(buffer.AsMemory(0, bytesToSkip), cancellationToken).ConfigureAwait(false); + // response address not used + } + finally + { + // The request held the username and password (SOCKS5) or the user id (SOCKS4). + ArrayPool.Shared.Return(buffer, clearArray: credentials is not null); + } + } + + internal static async ValueTask EstablishSocks4TunnelAsync(Stream stream, bool isVersion4a, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) + { + // The client constructors have checked this already, but NetworkCredential is mutable, and a + // user name changed after construction must not reach the wire either. + ValidateUserId(credentials, nameof(credentials)); + + var buffer = ArrayPool.Shared.Rent(BufferSize); + + try + { + // https://www.openssh.com/txt/socks4.protocol + + // +----+----+----+----+----+----+----+----+----+----+....+----+ + // | VN | CD | DSTPORT | DSTIP | USERID |NULL| + // +----+----+----+----+----+----+----+----+----+----+....+----+ + // 1 1 2 4 variable 1 + buffer[0] = ProtocolVersion4; + buffer[1] = CMD_CONNECT; + + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(2), (ushort)port); + + IPAddress? ipv4Address = null; + if (IPAddress.TryParse(host, out var hostIP)) + { + if (hostIP.AddressFamily == AddressFamily.InterNetwork) + ipv4Address = hostIP; + else if (hostIP.IsIPv4MappedToIPv6) + ipv4Address = hostIP.MapToIPv4(); + else + throw new ProxyProtocolException(ProxyErrorCode.SocksIPv6NotSupported, "SOCKS4 does not support IPv6 addresses."); + } + else if (!isVersion4a) + { + // Socks4 does not support domain names - try to resolve it here + IPAddress[] addresses; + try + { + addresses = + await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address.", ex); + } + + if (addresses.Length == 0) + throw new ProxyProtocolException(ProxyErrorCode.SocksNoIPv4Address, "Failed to resolve the destination host to an IPv4 address."); + + ipv4Address = addresses[0]; + } + + if (ipv4Address is null) + { + Debug.Assert(isVersion4a); + buffer[4] = 0; + buffer[5] = 0; + buffer[6] = 0; + buffer[7] = 255; + } + else + { + ipv4Address.TryWriteBytes(buffer.AsSpan(4), out var bytesWritten); + Debug.Assert(bytesWritten == 4); + } + + var usernameLength = EncodeString(credentials?.UserName, buffer.AsSpan(8), nameof(credentials.UserName)); + buffer[8 + usernameLength] = 0; + var totalLength = 9 + usernameLength; + + if (ipv4Address is null) + { + // https://www.openssh.com/txt/socks4a.protocol + var hostLength = EncodeString(host, buffer.AsSpan(totalLength), nameof(host)); + buffer[totalLength + hostLength] = 0; + totalLength += hostLength + 1; + } + + // BufferSize is the largest SOCKS4a request, and ArrayPool's rounding would hide a request + // that outgrew it. + Debug.Assert(totalLength <= BufferSize); + await stream.WriteAsync(buffer.AsMemory(0, totalLength), cancellationToken).ConfigureAwait(false); + + // +----+----+----+----+----+----+----+----+ + // | VN | CD | DSTPORT | DSTIP | + // +----+----+----+----+----+----+----+----+ + // 1 1 2 4 + + + await stream.ReadExactlyAsync(buffer.AsMemory(0, 8), cancellationToken).ConfigureAwait(false); + + if (buffer[0] != 0) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS4 reply version. Expected 0, got {buffer[0]}."); + + switch (buffer[1]) + { + case Socks4_Success: + // Nothing to do + break; + case Socks4_AuthFailed: + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, "Failed to authenticate with the SOCKS server."); + default: + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, "SOCKS server failed to connect to the destination."); + } + // response address not used + } + finally + { + // The request held the username and password (SOCKS5) or the user id (SOCKS4). + ArrayPool.Shared.Return(buffer, clearArray: credentials is not null); + } + } + + /// + /// Refuses a SOCKS4 user id the wire format cannot carry: one with a NUL in it. + /// + /// + /// SOCKS4 ends the user id at its first NUL, and the proxy reads whatever follows as the next + /// field. For SOCKS4a that is the host, so alice\0evil.example as the user id of a request + /// for good.example reached the proxy as user alice asking for + /// evil.example. The message never includes the user id, which is a credential. + /// + internal static void ValidateUserId(NetworkCredential? credentials, string paramName) + { + if (credentials is not null && credentials.UserName.Contains('\0')) + throw new ArgumentException( + "A SOCKS4 user id cannot contain a NUL character: the protocol ends the user id at the " + + "first one, and the proxy would read what follows as the next field.", + paramName); + } + + private static byte EncodeString(ReadOnlySpan chars, Span buffer, string parameterName) + { + // The length goes out as a single byte, so the write is capped at 255 whatever room the + // rented buffer has. ArrayPool rounds 520 up to 1024, and a string that fit the buffer but + // not the length byte used to escape ConnectAsync as an OverflowException from the cast. + if (!Encoding.UTF8.TryGetBytes(chars, buffer[..Math.Min(buffer.Length, 255)], out int written)) + throw new ProxyProtocolException(ProxyErrorCode.SocksStringTooLong, + $"Encoding the {parameterName} took more than the maximum of 255 bytes"); + + return (byte)written; + } + + private static void VerifyProtocolVersion(byte expected, byte version) + { + if (expected != version) + throw new ProxyProtocolException(ProxyErrorCode.SocksUnexpectedVersion, + $"Unexpected SOCKS protocol version. Required {expected}, got {version}."); + } + + +} From e88d998f23be34a0ea42793ffd5f6818e5a06492 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Wed, 30 Sep 2026 20:59:35 +0500 Subject: [PATCH 34/35] fix(http): clear the response buffer before returning it to the pool HttpResponseParser returned its buffer to ArrayPool.Shared uncleared, both when it grew and on Dispose. After a CONNECT 200 or an HTTP upgrade 101 the buffer holds the tunnel bytes the server sent past the headers, the same class of leak 11c1f81 fixed in RealityTlsStream and WebSocketStream. PrefixedStream.WrapIfNeeded copies OverreadBytes before Dispose, so clearing does not touch data a caller still reads. Verified: dotnet test -c Release with QPN_DOCKER_TESTS=1 and QPN_XRAY_PATH: 969 passed, 3 skipped (external proxy env vars) on net10.0 and net11.0. The 3 DualStackConnectTest cases fail only because this machine refuses a connect to ::1. Co-Authored-By: Claude Opus 5.5 (1M context) --- QuickProxyNet/Internal/HttpResponseParser.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QuickProxyNet/Internal/HttpResponseParser.cs b/QuickProxyNet/Internal/HttpResponseParser.cs index 216cda6..a3ea809 100644 --- a/QuickProxyNet/Internal/HttpResponseParser.cs +++ b/QuickProxyNet/Internal/HttpResponseParser.cs @@ -38,7 +38,7 @@ public Memory GetMemory() // Grow: rent a larger buffer, copy, return old byte[] next = ArrayPool.Shared.Rent(_writtenCount + BufferSize); _buffer.AsSpan(0, _writtenCount).CopyTo(next); - ArrayPool.Shared.Return(_buffer); + ArrayPool.Shared.Return(_buffer, clearArray: true); _buffer = next; return _buffer.AsMemory(_writtenCount); } @@ -127,6 +127,6 @@ public void Dispose() byte[] buf = _buffer; _buffer = null!; if (buf is not null) - ArrayPool.Shared.Return(buf); + ArrayPool.Shared.Return(buf, clearArray: true); } } From dd8a1782af54f88e02b13c389fdfc74b6be71482 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Wed, 30 Sep 2026 20:59:35 +0500 Subject: [PATCH 35/35] docs: package notes and READMEs for 5.0.0 QuickProxyNet.csproj: PackageReleaseNotes describe 5.0.0 instead of 4.0.0, Description and PackageTags name Shadowsocks, and MinVerMinimumMajorMinor is 5.0, so untagged builds version as 5.0.0-alpha. README.md: the error code table gains StringTooLong, TransportUpgradeFailed and TlsHandshakeFailed. A "Many links at once" section shows Proxy.TryCreate and SourceLink. QuickProxyNet/README.md, the package readme, names Shadowsocks, the ss scheme, TryCreate and the EndPoint target. docs/README.md lists shadowsocks.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- QuickProxyNet/QuickProxyNet.csproj | 8 ++++---- QuickProxyNet/README.md | 6 ++++-- README.md | 20 ++++++++++++++++++++ docs/README.md | 1 + 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index 5456c1c..cfd3be9 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -8,18 +8,18 @@ (a JsonSerializer call, say) fails the build instead of a user's published app. --> true v - 4.0 + 5.0 QuickProxyNet Titlehhhh Titlehhhh - QuickProxyNet is a high-performance, zero-dependency .NET library for connecting to servers via HTTP, HTTPS, SOCKS4, SOCKS4a and SOCKS5 proxies, and via the VPN-style protocols VLESS, VMess and Trojan over tcp, ws or httpupgrade. Provides direct Stream access for low-level network operations. VLESS REALITY and the xtls-rprx-vision flow are implemented in managed code, with no external binary. - proxy;networking;http;socks;socks5;vless;vmess;trojan;reality;xtls;vision;xray;v2ray;vpn;high-performance + QuickProxyNet is a high-performance, zero-dependency .NET library for connecting to servers via HTTP, HTTPS, SOCKS4, SOCKS4a and SOCKS5 proxies, and via the VPN-style protocols VLESS, VMess and Trojan over tcp, ws or httpupgrade, and Shadowsocks AEAD over tcp. Provides direct Stream access for low-level network operations. VLESS REALITY and the xtls-rprx-vision flow are implemented in managed code, with no external binary. + proxy;networking;http;socks;socks5;vless;vmess;trojan;shadowsocks;reality;xtls;vision;xray;v2ray;vpn;high-performance Copyright © Titlehhhh 2024-2026 https://github.com/Titlehhhh/QuickProxyNet https://github.com/Titlehhhh/QuickProxyNet - 4.0.0 adds VLESS, VMess and Trojan outbounds next to the HTTP/SOCKS clients. VLESS REALITY and the xtls-rprx-vision flow are implemented in managed code — no Xray or other external binary. ProxyClientFactory.Create(string) and Proxy.ConnectAsync(string, host, port) take a link of any supported scheme, including vmess:// links that System.Uri cannot represent. No public type or member from 3.0.0 was removed or changed; the major bump covers new ProxyType/ProxyErrorCode members and Create(Uri) now returning a client for vless/trojan/vmess instead of throwing. Not included: grpc and xhttp transports, Hysteria2, and a browser-grade ClientHello fingerprint for REALITY. Full notes: https://github.com/Titlehhhh/QuickProxyNet/releases/tag/v4.0.0 + 5.0.0 removes Uri and the read and write timeouts from the public API. ProxyClientFactory is gone: its Create methods are now static on Proxy. Removed: IProxyClient.ProxyUri, IProxyClient.ReadTimeout and WriteTimeout, the Proxy.ConnectAsync(Uri, ...) overloads and ProxyUriExtensions. New: Shadowsocks AEAD over tcp, a connect target as an EndPoint (for SocketsHttpHandler.ConnectCallback), Proxy.TryCreate for bulk link parsing, IProxyClient.SourceLink and ProxyErrorCode.TlsHandshakeFailed. Fixed: IPv6 proxy addresses, the HTTPS proxy certificate check, escaped credentials in links, and header injection through a target host or a ws path. Migration guide and full notes: https://github.com/Titlehhhh/QuickProxyNet/releases/tag/v5.0.0 diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index 60e98bb..aeb2eda 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -1,6 +1,6 @@ # QuickProxyNet -High-performance, zero-dependency C# library for connecting through HTTP, HTTPS, SOCKS4, SOCKS4a and SOCKS5 proxies, and through the VPN-style protocols VLESS, VMess and Trojan. Returns a raw `Stream` for direct data access. +High-performance, zero-dependency C# library for connecting through HTTP, HTTPS, SOCKS4, SOCKS4a and SOCKS5 proxies, and through the VPN-style protocols VLESS, VMess, Trojan and Shadowsocks. Returns a raw `Stream` for direct data access. VLESS REALITY works in-process — the TLS 1.3 handshake it needs is implemented here (including the `xtls-rprx-vision` flow), so it costs no extra package and no external binary, and the zero-dependency promise still holds. Its ClientHello is not yet a browser fingerprint; see `docs/reality-fingerprint-plan.md` in the repository for what that means. @@ -13,7 +13,7 @@ No companion package and no external binary are involved. The `grpc` and `xhttp` `Proxy.ConnectAsync` takes the share link as a string and dispatches on the scheme itself — including `vmess://` links, whose base64 payload `System.Uri` cannot parse. ```csharp -// Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess links +// Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess/ss links await using var stream = await Proxy.ConnectAsync( "socks5://user:pass@127.0.0.1:1080", "example.com", 443, @@ -28,6 +28,8 @@ await using var stream = await Proxy.ConnectAsync( - Structured errors: `ProxyProtocolException` with `ProxyErrorCode` enum - Per-connection timeouts with `ProxyErrorCode.Timeout` - Static API (`Proxy.ConnectAsync`) and factory API (`Proxy.Create(link)`) +- `Proxy.TryCreate(link, out client, out error)` for subscriptions: it never throws and tells why a link was refused +- Target as a `DnsEndPoint` or `IPEndPoint`, so `SocketsHttpHandler.ConnectCallback` can send an `HttpClient` through any supported proxy ## Error Handling diff --git a/README.md b/README.md index 97d27dc..83b19f4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,23 @@ using var http = new HttpClient(new SocketsHttpHandler }); ``` +### Many links at once + +`Proxy.TryCreate` never throws, whatever the input. A subscription is other people's text, and one bad line must not stop the run. The `error` string starts with the exception type, so you can group the refusals. + +```csharp +foreach (var line in File.ReadLines("subscription.txt")) +{ + if (!Proxy.TryCreate(line, out var client, out var error)) + { + Console.WriteLine($"skipped: {error}"); + continue; + } + + // client.SourceLink is the original text, with the uuid, sni and transport +} +``` + ### With explicit proxy type and credentials ```csharp @@ -220,6 +237,9 @@ messages never contain the credential: a malformed user id is reported by length | `SocksIPv6NotSupported` | SOCKS4 does not support IPv6 | | `SocksNoIPv4Address` | Failed to resolve host to IPv4 (SOCKS4) | | `SocksStringTooLong` | SOCKS field exceeded 255-byte limit | +| `StringTooLong` | A protocol string field, such as a target host name, exceeded the 255-byte limit | +| `TransportUpgradeFailed` | The server refused the `ws` or `httpupgrade` upgrade, or did not answer with a WebSocket handshake | +| `TlsHandshakeFailed` | The TLS handshake with the proxy failed: an untrusted or expired certificate, a name the server does not serve, or no shared cipher suite | ## Supported Proxy Types diff --git a/docs/README.md b/docs/README.md index eaadea7..2a924de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Wire-level заметки по протоколам: - [VLESS](vless.md) — реализован - [VMess](vmess.md), [VMessAEAD request](vmess-aead-request.md), [VMessAEAD body](vmess-aead-body.md) — реализован - [Trojan](trojan.md) — реализован +- [Shadowsocks](shadowsocks.md) — реализован AEAD поверх TCP - [Hysteria2](hysteria2.md), [hy2](hy2.md), [TUIC](tuic.md) — **не реализованы** - [Анализ QUIC-протоколов](quic-protocols-analysis.md) — почему Hysteria2 и TUIC не ложатся на модель «один `ConnectAsync` — один сокет»