diff --git a/AGENTS.md b/AGENTS.md index 1c5bf26..caab3dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,14 +34,25 @@ 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. -- `ProxyUriExtensions` adds `Uri.ConnectThroughProxyAsync(...)`. +- `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. +- 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. +- 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`. + `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. -- `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. @@ -141,12 +152,25 @@ 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 `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 +237,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 +321,36 @@ 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. + + 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 + 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 @@ -366,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 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.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/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.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 new file mode 100644 index 0000000..da4444e --- /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 Link_WithBracketedIPv6_ReachesProxyOnIPv6() + { + using var proxy = new LoopbackConnectProxy(IPAddress.IPv6Loopback); + + await using Stream stream = await Proxy.ConnectAsync( + $"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/EndPointConnectTest.cs b/QuickProxyNet.Tests/EndPointConnectTest.cs new file mode 100644 index 0000000..f7e8b8e --- /dev/null +++ b/QuickProxyNet.Tests/EndPointConnectTest.cs @@ -0,0 +1,132 @@ +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 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 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.Tests/FactoryTest.cs b/QuickProxyNet.Tests/FactoryTest.cs index 0fdd64c..b3fd3f6 100644 --- a/QuickProxyNet.Tests/FactoryTest.cs +++ b/QuickProxyNet.Tests/FactoryTest.cs @@ -10,16 +10,8 @@ public void BadProtocolUri(string stringUri) { Uri uri = new Uri(stringUri); - ProxyClientFactory factory = new ProxyClientFactory(); - - try - { - factory.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] @@ -32,10 +24,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 +43,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/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/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/HttpHelperTest.cs b/QuickProxyNet.Tests/HttpHelperTest.cs index 9c63175..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,7 +6,70 @@ namespace QuickProxyNet.Tests; public class HttpHelperTest { - private static readonly Uri ProxyUri = new("http://proxy.example.com:8080"); + 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() @@ -13,7 +77,7 @@ 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 +91,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 +112,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 +125,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 +137,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); @@ -82,6 +146,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,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() { @@ -89,7 +173,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); @@ -108,7 +192,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/HttpsProxyClientTest.cs b/QuickProxyNet.Tests/HttpsProxyClientTest.cs new file mode 100644 index 0000000..d85c78e --- /dev/null +++ b/QuickProxyNet.Tests/HttpsProxyClientTest.cs @@ -0,0 +1,87 @@ +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}"); + } + + /// + /// 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); + 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.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/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/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/ProxyClientFactoryTest.cs b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs deleted file mode 100644 index 0fae083..0000000 --- a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System.Net; -using System.Text; - -namespace QuickProxyNet.Tests; - -/// -/// The string entry point: one call that takes a link of any supported scheme. -/// -/// -/// The reason it exists rather than being a thin wrapper over is the vmess -/// 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 -{ - private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; - - private static IProxyClient Create(string link) => ProxyClientFactory.Instance.Create(link); - - [Fact] - public void Create_Vless_ReturnsAVlessClient() - { - var client = Assert.IsType( - Create($"vless://{Uuid}@example.com:443?type=tcp&security=tls&sni=cdn.example.com#node")); - - Assert.Equal("example.com", client.Options.Host); - Assert.Equal(443, client.Options.Port); - Assert.Equal("cdn.example.com", client.Options.Sni); - } - - [Fact] - public void Create_VlessReality_ReturnsAClientThatWillSpeakReality() - { - var client = Assert.IsType(Create( - $"vless://{Uuid}@example.com:443?security=reality&pbk=BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g" + - "&sid=ab12&sni=www.example.org&flow=xtls-rprx-vision&fp=chrome")); - - Assert.Equal(VlessSecurity.Reality, client.Options.Security); - Assert.Equal("xtls-rprx-vision", client.Options.Flow); - } - - [Fact] - public void Create_Trojan_ReturnsATrojanClient() - { - var client = Assert.IsType(Create("trojan://secret@example.com:443?sni=cdn.example.com")); - Assert.Equal("example.com", client.Options.Host); - } - - /// - /// A realistic vmess link — base64 JSON, padded, far longer than a host may be. The - /// overload cannot take this, which is the whole reason for the string one. - /// - [Fact] - public void Create_Vmess_HandlesLinksThatCannotBecomeAUri() - { - string json = - $$""" - {"v":"2","ps":"a node with a long enough remark to matter","add":"example.com","port":"443", - "id":"{{Uuid}}","aid":"0","net":"ws","host":"cdn.example.com","path":"/websocket-path","tls":"tls"} - """; - string link = "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); - - Assert.False(Uri.TryCreate(link, UriKind.Absolute, out _), "the link should be beyond Uri, or this test proves nothing"); - - var client = Assert.IsType(Create(link)); - Assert.Equal("example.com", client.Options.Host); - Assert.Equal("ws", client.Options.Transport); - } - - [Theory] - [InlineData("socks5://127.0.0.1:1080", typeof(Socks5Client))] - [InlineData("socks4://127.0.0.1:1080", typeof(Socks4Client))] - [InlineData("socks4a://127.0.0.1:1080", typeof(Socks4aClient))] - [InlineData("http://127.0.0.1:8080", typeof(HttpProxyClient))] - [InlineData("https://127.0.0.1:8443", typeof(HttpsProxyClient))] - public void Create_ClassicSchemes_MatchTheUriOverload(string link, Type expected) - { - Assert.IsType(expected, Create(link)); - Assert.IsType(expected, ProxyClientFactory.Instance.Create(new Uri(link))); - } - - [Fact] - public void Create_ClassicScheme_KeepsCredentials() - { - var client = Create("socks5://user:pass@127.0.0.1:1080"); - - Assert.Equal("user", client.ProxyCredentials?.UserName); - Assert.Equal("pass", client.ProxyCredentials?.Password); - } - - [Fact] - public void Create_IsCaseInsensitiveAndIgnoresSurroundingWhitespace() - { - Assert.IsType(Create(" SOCKS5://127.0.0.1:1080\n")); - Assert.IsType(Create($" VLESS://{Uuid}@example.com:443?security=none ")); - } - - /// - /// An unsupported protocol must name itself in the error. "Unsupported proxy scheme" with no - /// scheme in it is the kind of message that sends someone reading library source. - /// - [Fact] - public void Create_UnsupportedScheme_SaysWhichOne() - { - var ex = Assert.Throws(() => Create("hysteria2://pass@example.com:443")); - - Assert.Contains("hysteria2", ex.Message); - Assert.Contains("vless", ex.Message); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData("example.com:1080")] - [InlineData("://example.com")] - public void Create_WithoutAScheme_ThrowsArgumentException(string link) - { - Assert.ThrowsAny(() => Create(link)); - } - - /// An error message must not carry the credential that was in the link. - [Fact] - public void Create_UnsupportedScheme_DoesNotEchoTheWholeLink() - { - var ex = Assert.Throws( - () => Create("ss://verySecretPasswordThatMustNotLeak@example.com:8388")); - - Assert.DoesNotContain("verySecretPasswordThatMustNotLeak", ex.Message); - } - - [Fact] - public void Create_MalformedKnownScheme_ThrowsFormat() - { - Assert.ThrowsAny(() => Create("socks5://")); - Assert.ThrowsAny(() => Create("vless://not-a-valid-link")); - } - - // === Proxy.ConnectAsync(string, ...) === - - [Fact] - public async Task ProxyConnect_WithAnUnsupportedScheme_FailsBeforeTouchingTheNetwork() - { - await Assert.ThrowsAsync(async () => - await Proxy.ConnectAsync("tuic://example.com:443", "example.com", 443)); - } - - /// - /// The static entry point accepts what the factory accepts — the point of adding it. A - /// connection is attempted against a port nothing listens on, so reaching a connection - /// failure proves the link itself was understood. - /// - [Fact] - public async Task ProxyConnect_WithAVlessLink_GetsPastParsingIntoTheNetwork() - { - var ex = await Assert.ThrowsAsync(async () => - await Proxy.ConnectAsync( - $"vless://{Uuid}@127.0.0.1:{UnusedPort()}?security=none", - "example.com", 443, TimeSpan.FromSeconds(5))); - - Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); - } - - /// - /// A proxy that accepts and then says nothing must end in - /// through the string entry point — distinguishable from "could not connect", because the - /// caller's remedy differs (wait longer vs. give up on the node). - /// - [Fact] - public async Task ProxyConnect_WithASilentProxy_TimesOutWithTheTimeoutCode() - { - // SOCKS5 is the right protocol for this: the client must read the server's method - // selection before it can do anything, so a silent server hangs the handshake. (VLESS - // would not — it writes its header and reads nothing until the first payload read.) - var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - try - { - // Accept and then hold the socket open and silent. The accepted client is kept - // referenced until the end: discarded, it would be finalized under GC pressure and - // the close would reach our side as a reset — a different failure than the one - // this test is about. - Task accepted = listener.AcceptTcpClientAsync(); - - var ex = await Assert.ThrowsAsync(async () => - await Proxy.ConnectAsync($"socks5://127.0.0.1:{port}", "example.com", 80, TimeSpan.FromMilliseconds(500))); - - Assert.Equal(ProxyErrorCode.Timeout, ex.ErrorCode); - (await accepted).Dispose(); - } - finally - { - listener.Stop(); - } - } - - /// - /// Whatever a malformed link throws — from the factory, from a parser, from the client - /// constructor — the credential in it must not be in the message or any inner message. - /// - [Theory] - [InlineData("vless://SECRETSECRETSECRETSECRETSECRETSECRETSECRET1@example.com:443?security=none")] // 44-char id: rejected - [InlineData("trojan://SECRETPASSWORD@:443")] // no host - [InlineData("socks5://user:SECRETPASSWORD@[not an address")] // not a URI - [InlineData("vmess://SECRETPASSWORD-this-is-not-base64-json")] // not base64 JSON - public void Create_MalformedLink_NeverEchoesTheCredential(string link) - { - Exception ex = Assert.ThrowsAny(() => Create(link)); - - for (Exception? e = ex; e is not null; e = e.InnerException) - Assert.DoesNotContain("SECRET", e.Message); - } - - /// A port that was bound and immediately released — nothing is listening on it. - private static int UnusedPort() - { - var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); - listener.Start(); - int port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } -} diff --git a/QuickProxyNet.Tests/ProxyFactoryTest.cs b/QuickProxyNet.Tests/ProxyFactoryTest.cs new file mode 100644 index 0000000..c59a827 --- /dev/null +++ b/QuickProxyNet.Tests/ProxyFactoryTest.cs @@ -0,0 +1,594 @@ +using System.Net; +using System.Text; + +namespace QuickProxyNet.Tests; + +/// +/// The string entry point: one call that takes a link of any supported scheme. +/// +/// +/// The reason it exists rather than being a thin wrapper over is the vmess +/// case below — those links are base64 JSON that cannot represent at all — so +/// that test is the one that matters most here. +/// +public class ProxyFactoryTest +{ + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + + private static IProxyClient Create(string link) => Proxy.Create(link); + + [Fact] + public void Create_Vless_ReturnsAVlessClient() + { + var client = Assert.IsType( + Create($"vless://{Uuid}@example.com:443?type=tcp&security=tls&sni=cdn.example.com#node")); + + Assert.Equal("example.com", client.Options.Host); + Assert.Equal(443, client.Options.Port); + Assert.Equal("cdn.example.com", client.Options.Sni); + } + + [Fact] + public void Create_VlessReality_ReturnsAClientThatWillSpeakReality() + { + var client = Assert.IsType(Create( + $"vless://{Uuid}@example.com:443?security=reality&pbk=BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g" + + "&sid=ab12&sni=www.example.org&flow=xtls-rprx-vision&fp=chrome")); + + Assert.Equal(VlessSecurity.Reality, client.Options.Security); + Assert.Equal("xtls-rprx-vision", client.Options.Flow); + } + + [Fact] + public void Create_Trojan_ReturnsATrojanClient() + { + var client = Assert.IsType(Create("trojan://secret@example.com:443?sni=cdn.example.com")); + Assert.Equal("example.com", client.Options.Host); + } + + /// + /// A realistic vmess link — base64 JSON, padded, far longer than a host may be. The + /// overload cannot take this, which is the whole reason for the string one. + /// + [Fact] + public void Create_Vmess_HandlesLinksThatCannotBecomeAUri() + { + string json = + $$""" + {"v":"2","ps":"a node with a long enough remark to matter","add":"example.com","port":"443", + "id":"{{Uuid}}","aid":"0","net":"ws","host":"cdn.example.com","path":"/websocket-path","tls":"tls"} + """; + string link = "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + Assert.False(Uri.TryCreate(link, UriKind.Absolute, out _), "the link should be beyond Uri, or this test proves nothing"); + + var client = Assert.IsType(Create(link)); + Assert.Equal("example.com", client.Options.Host); + Assert.Equal("ws", client.Options.Transport); + } + + [Theory] + [InlineData("socks5://127.0.0.1:1080", typeof(Socks5Client))] + [InlineData("socks4://127.0.0.1:1080", typeof(Socks4Client))] + [InlineData("socks4a://127.0.0.1:1080", typeof(Socks4aClient))] + [InlineData("http://127.0.0.1:8080", typeof(HttpProxyClient))] + [InlineData("https://127.0.0.1:8443", typeof(HttpsProxyClient))] + public void Create_ClassicSchemes_MatchTheUriOverload(string link, Type expected) + { + Assert.IsType(expected, Create(link)); + Assert.IsType(expected, Proxy.Create(new Uri(link))); + } + + [Fact] + public void Create_ClassicScheme_KeepsCredentials() + { + var client = Create("socks5://user:pass@127.0.0.1:1080"); + + Assert.Equal("user", client.ProxyCredentials?.UserName); + 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 a Uri 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() + { + Assert.IsType(Create(" SOCKS5://127.0.0.1:1080\n")); + Assert.IsType(Create($" VLESS://{Uuid}@example.com:443?security=none ")); + } + + /// + /// An unsupported protocol must name itself in the error. "Unsupported proxy scheme" with no + /// scheme in it is the kind of message that sends someone reading library source. + /// + [Fact] + public void Create_UnsupportedScheme_SaysWhichOne() + { + var ex = Assert.Throws(() => Create("hysteria2://pass@example.com:443")); + + Assert.Contains("hysteria2", ex.Message); + Assert.Contains("vless", ex.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("example.com:1080")] + [InlineData("://example.com")] + public void Create_WithoutAScheme_ThrowsArgumentException(string link) + { + Assert.ThrowsAny(() => Create(link)); + } + + /// An error message must not carry the credential that was in the link. + [Fact] + public void Create_UnsupportedScheme_DoesNotEchoTheWholeLink() + { + var ex = Assert.Throws( + () => 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.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); + } + + /// + /// 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); + } + + /// + /// 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() + { + // 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, ...) === + + [Fact] + public async Task ProxyConnect_WithAnUnsupportedScheme_FailsBeforeTouchingTheNetwork() + { + await Assert.ThrowsAsync(async () => + await Proxy.ConnectAsync("tuic://example.com:443", "example.com", 443)); + } + + /// + /// The static entry point accepts what the factory accepts — the point of adding it. A + /// connection is attempted against a port nothing listens on, so reaching a connection + /// failure proves the link itself was understood. + /// + [Fact] + public async Task ProxyConnect_WithAVlessLink_GetsPastParsingIntoTheNetwork() + { + var ex = await Assert.ThrowsAsync(async () => + await Proxy.ConnectAsync( + $"vless://{Uuid}@127.0.0.1:{UnusedPort()}?security=none", + "example.com", 443, TimeSpan.FromSeconds(5))); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + + /// + /// A proxy that accepts and then says nothing must end in + /// through the string entry point — distinguishable from "could not connect", because the + /// caller's remedy differs (wait longer vs. give up on the node). + /// + [Fact] + public async Task ProxyConnect_WithASilentProxy_TimesOutWithTheTimeoutCode() + { + // SOCKS5 is the right protocol for this: the client must read the server's method + // selection before it can do anything, so a silent server hangs the handshake. (VLESS + // would not — it writes its header and reads nothing until the first payload read.) + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + try + { + // Accept and then hold the socket open and silent. The accepted client is kept + // referenced until the end: discarded, it would be finalized under GC pressure and + // the close would reach our side as a reset — a different failure than the one + // this test is about. + Task accepted = listener.AcceptTcpClientAsync(); + + var ex = await Assert.ThrowsAsync(async () => + await Proxy.ConnectAsync($"socks5://127.0.0.1:{port}", "example.com", 80, TimeSpan.FromMilliseconds(500))); + + Assert.Equal(ProxyErrorCode.Timeout, ex.ErrorCode); + (await accepted).Dispose(); + } + finally + { + listener.Stop(); + } + } + + /// + /// Whatever a malformed link throws — from the factory, from a parser, from the client + /// constructor — the credential in it must not be in the message or any inner message. + /// + [Theory] + [InlineData("vless://SECRETSECRETSECRETSECRETSECRETSECRETSECRET1@example.com:443?security=none")] // 44-char id: rejected + [InlineData("trojan://SECRETPASSWORD@:443")] // no host + [InlineData("socks5://user:SECRETPASSWORD@[not an address")] // not a URI + [InlineData("vmess://SECRETPASSWORD-this-is-not-base64-json")] // not base64 JSON + public void Create_MalformedLink_NeverEchoesTheCredential(string 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); + } + + // --- 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("", "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. 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); + } + + /// + /// 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 ToString() 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: nothing else on the client keeps what makes the + // node reachable — the uuid, the sni, the transport. + 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] + 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)] + [InlineData(ProxyType.Shadowsocks)] + public void Create_ShareLinkFamilyFromHostAndPort_IsRejected(ProxyType type) + { + // 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. + private static int UnusedPort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} 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..601e3ab --- /dev/null +++ b/QuickProxyNet.Tests/ShadowsocksShareLinkTest.cs @@ -0,0 +1,543 @@ +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", 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); + } + + // ================================ 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://[2001:db8::1]:8388", client.ToString()); + } + + [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"); + + // 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('ж', 200), 443)); + + 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.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/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/SkipGates.cs b/QuickProxyNet.Tests/SkipGates.cs index 675b43e..b6e4306 100644 --- a/QuickProxyNet.Tests/SkipGates.cs +++ b/QuickProxyNet.Tests/SkipGates.cs @@ -1,3 +1,9 @@ +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Security.Cryptography; +using Xunit.Sdk; + namespace QuickProxyNet.Tests; /// @@ -131,3 +137,66 @@ 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]; +} + +/// +/// 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.Tests/Socks5HelperTest.cs b/QuickProxyNet.Tests/Socks5HelperTest.cs index 4e6ce8f..7552943 100644 --- a/QuickProxyNet.Tests/Socks5HelperTest.cs +++ b/QuickProxyNet.Tests/Socks5HelperTest.cs @@ -143,6 +143,181 @@ public async Task Socks5_ConnectFailed_Throws() Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); } + // 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] + [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); + } + + /// + /// 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 + /// 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.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index 066e44f..bbbe0da 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -386,13 +386,48 @@ 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])); 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.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 c5f3eae..3dbed11 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); @@ -180,12 +180,31 @@ 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] 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..3b2370c 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; @@ -193,9 +194,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 +251,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); } @@ -278,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(); @@ -319,7 +320,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); @@ -437,25 +438,208 @@ 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); + + 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); + } + + 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.Contains(pbk, ex.Message); + 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() + { + 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. @@ -502,7 +686,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..0fb4321 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -340,6 +340,46 @@ 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); + } + + // 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 @@ -667,7 +707,26 @@ 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()); + } + + /// + /// 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] @@ -710,7 +769,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 +783,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/HttpProxyClient.cs b/QuickProxyNet/Clients/HttpProxyClient.cs index dbd0aac..6246da5 100644 --- a/QuickProxyNet/Clients/HttpProxyClient.cs +++ b/QuickProxyNet/Clients/HttpProxyClient.cs @@ -1,28 +1,27 @@ -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) + { + 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 2f94960..57a646d 100644 --- a/QuickProxyNet/Clients/HttpsProxyClient.cs +++ b/QuickProxyNet/Clients/HttpsProxyClient.cs @@ -32,31 +32,40 @@ public HttpsProxyClient(string host, int port, NetworkCredential credentials) : public override ProxyType Type => ProxyType.Https; - private SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string host) + // 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() { return new SslClientAuthenticationOptions { 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, - TargetHost = host + TargetHost = ProxyHost }; } - 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) { + // 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 { - await ssl.AuthenticateAsClientAsync(GetSslClientAuthenticationOptions(host), cancellationToken); + await TlsHandshake.AuthenticateAsync( + ssl, GetSslClientAuthenticationOptions(), $"{ProxyHost}:{ProxyPort}", cancellationToken); } catch { @@ -64,7 +73,7 @@ public override async ValueTask ConnectAsync(Stream stream, string host, 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 a9ef0ab..bf398bf 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -1,78 +1,63 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; 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) { - 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.", + ArgumentException.ThrowIfNullOrEmpty(host); + 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)); - 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); - 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; - - ProxyUri = new Uri($"{protocol}://{credentials.UserName}:{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; + /// + /// 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. + public string? SourceLink { get; internal set; } - public Uri ProxyUri { get; private set; } public abstract ProxyType Type { get; } public NetworkCredential? ProxyCredentials { get; } @@ -86,129 +71,233 @@ private static string FormatUriHost(string host) => 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() { - 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; - } - - public async ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) - { - ValidateArguments(host, port); - - cancellationToken.ThrowIfCancellationRequested(); - - var socket = CreateSocket(); - + // 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 { - await socket.ConnectAsync(ProxyHost, ProxyPort, cancellationToken); - } - catch (Exception ex) - { - socket.Dispose(); - 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) 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); + // 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; + if (LingerState is not null) + socket.LingerState = LingerState; + if (local is not null) + socket.Bind(local); + return socket; } catch { - await stream.DisposeAsync(); + // 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 virtual async ValueTask ConnectAsync(string host, int port, TimeSpan timeout, - 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(); - var socket = CreateSocket(); - var timedOut = new StrongBox(false); + // 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; - 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); + 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); + } + 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(); - 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); + 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); + return await ConnectAsync(stream, host, port, token); } 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; + + // 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; } } + /// + /// 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) + { + if (cancellationToken.IsCancellationRequested) + return new OperationCanceledException( + $"The connection to proxy {ProxyHost}:{ProxyPort} was canceled.", ex, cancellationToken); + + if (timeoutSource is { IsCancellationRequested: true }) + return new ProxyProtocolException(ProxyErrorCode.Timeout, + $"Connection to proxy {ProxyHost}:{ProxyPort} timed out after {timeout}.", ex); + + return null; + } + public abstract ValueTask ConnectAsync(Stream source, string host, int port, CancellationToken cancellationToken = default); - internal static void ValidateArguments(string host, int port) + /// + 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) { - if (host == null) - throw new ArgumentNullException(nameof(host)); + var (host, port) = SplitTarget(target); + return await ConnectAsync(host, port, timeout, cancellationToken); + } - if (host.Length == 0 || host.Length > 255) - throw new ArgumentException("The length of the host name must be between 0 and 256 characters.", + /// + public async ValueTask ConnectAsync(Stream source, EndPoint target, + CancellationToken cancellationToken = default) + { + var (host, port) = SplitTarget(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. + 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)); - if (port <= 0 || port > 65535) - throw new ArgumentOutOfRangeException(nameof(port)); + 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 + return i; + } + + return -1; + } + + // 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/Clients/ShadowsocksClient.cs b/QuickProxyNet/Clients/ShadowsocksClient.cs new file mode 100644 index 0000000..206d303 --- /dev/null +++ b/QuickProxyNet/Clients/ShadowsocksClient.cs @@ -0,0 +1,183 @@ +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); + ValidateArguments(host, port); + + 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/Clients/Socks4Client.cs b/QuickProxyNet/Clients/Socks4Client.cs index 8be048f..3d8c836 100644 --- a/QuickProxyNet/Clients/Socks4Client.cs +++ b/QuickProxyNet/Clients/Socks4Client.cs @@ -1,30 +1,31 @@ -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) + { + } + + /// 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; + + + 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 2f3f7d3..3dd1dc3 100644 --- a/QuickProxyNet/Clients/Socks4aClient.cs +++ b/QuickProxyNet/Clients/Socks4aClient.cs @@ -1,32 +1,33 @@ -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) + { + } + + /// 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)); + } + + + public override ProxyType Type => ProxyType.Socks4a; + + + 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 787a5fd..3fa4627 100644 --- a/QuickProxyNet/Clients/Socks5Client.cs +++ b/QuickProxyNet/Clients/Socks5Client.cs @@ -1,30 +1,29 @@ -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) + { + 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 8fde059..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); } @@ -62,7 +70,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 @@ -70,14 +80,15 @@ 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.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) @@ -104,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 e7509c7..df4d7fc 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -19,10 +19,16 @@ 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, 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) { @@ -37,6 +43,31 @@ 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)); + + // 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.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); } @@ -73,6 +104,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 @@ -85,7 +117,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.ServerName, cancellationToken).ConfigureAwait(false); } else if (Options.Security == VlessSecurity.Reality) { @@ -98,7 +131,7 @@ public override async ValueTask ConnectAsync(Stream stream, string host, 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) @@ -140,43 +173,20 @@ private TransportKind EnsureSupported() /// private RealityTlsOptions BuildRealityOptions() => new() { - ServerName = Options.Sni ?? Options.HostHeader ?? Options.Host, - PublicKey = DecodeBase64Url(Options.RealityPublicKey!), + // Bounded by the constructor, like the ALPN list below. + ServerName = Options.ServerName, + // 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 - // 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 1f9faae..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); } @@ -122,6 +129,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); @@ -136,14 +144,15 @@ 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.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 @@ -269,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/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..3a2520f --- /dev/null +++ b/QuickProxyNet/Configs/ShadowsocksShareLink.cs @@ -0,0 +1,489 @@ +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; + } + + 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; + return true; + } + } + + // 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, + 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; + } + + // 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, + 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 + { + if (!ShareLinkBase64.TryNormalize(payload, chars, out length)) + return false; + + 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/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 e018b43..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. @@ -86,4 +89,20 @@ public sealed class VlessOptions /// The resolved transport layer this configuration selects. internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); + + /// + /// 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 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 b5e5b4b..b1612e3 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; @@ -180,6 +193,25 @@ private static bool TryParse( RealityShortId = sid, 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.ServerName, options.Alpn, out error)) + { + options = null; + return false; + } + error = null; return true; } 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 1769e93..c7782d9 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)..]; @@ -338,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; } @@ -383,15 +394,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; @@ -401,48 +414,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, @@ -540,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."; @@ -607,6 +594,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,10 +615,17 @@ 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 }; + + // 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/IProxyClient.cs b/QuickProxyNet/IProxyClient.cs index e8a9c59..ca9a069 100644 --- a/QuickProxyNet/IProxyClient.cs +++ b/QuickProxyNet/IProxyClient.cs @@ -1,89 +1,156 @@ -using System.Net; -using System.Net.Sockets; - -namespace QuickProxyNet; - - -/// -/// Represents a client for connecting through a proxy server. -/// -public interface IProxyClient -{ - Uri ProxyUri { get; } - - /// - /// 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); -} \ 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; } + + /// + /// 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 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); + + /// + /// 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/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 8e583ed..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; @@ -18,10 +19,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 +43,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; @@ -69,7 +74,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; @@ -78,11 +83,24 @@ 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); } - internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, Uri proxyUri, string host, - int port, NetworkCredential? credentials, CancellationToken cancellationToken) + 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, string host, int port, + NetworkCredential? credentials, CancellationToken cancellationToken) { var (cmd, cmdLen) = BuildConnectionCommand(host, port, credentials); try @@ -91,7 +109,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(); 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); } } 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/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/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs index a56a3aa..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; @@ -79,22 +80,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); } } @@ -193,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); } } diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index e682ba5..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 ---- @@ -248,6 +260,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 ---- @@ -257,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( @@ -276,7 +303,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 +335,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 +599,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 +616,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 +673,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 adc3396..26ca6fe 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Diagnostics; using System.Runtime.CompilerServices; namespace QuickProxyNet; @@ -40,14 +42,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; @@ -78,7 +80,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 +100,17 @@ 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) { + // 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.Span); + _pending.Span[..count].CopyTo(buffer); _pending = _pending[count..]; return count; @@ -219,12 +224,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/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); } diff --git a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs index b2fff0a..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; @@ -41,9 +42,20 @@ 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)); + + // 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; - 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 +64,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. /// 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/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..084a26b --- /dev/null +++ b/QuickProxyNet/Internal/Shadowsocks/ShadowsocksStream.cs @@ -0,0 +1,785 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +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; + } + + Debug.Assert(buffer.Length - _end >= missing); + 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); + + // 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; + } + + // 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/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)]; + } +} diff --git a/QuickProxyNet/Internal/SocksHelper.cs b/QuickProxyNet/Internal/SocksHelper.cs index aa103d3..09f7082 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 @@ -172,13 +181,18 @@ await stream.WriteAsync(buffer.AsMemory(0, 3 + usernameLength + passwordLength), } finally { - ArrayPool.Shared.Return(buffer); + // 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 @@ -251,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); // +----+----+----+----+----+----+----+----+ @@ -279,21 +296,39 @@ await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork, cancellationTo } finally { - ArrayPool.Shared.Return(buffer); + // 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) { - 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"); - } + // 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) @@ -304,4 +339,4 @@ private static void VerifyProtocolVersion(byte expected, byte version) } -} \ No newline at end of file +} diff --git a/QuickProxyNet/Internal/TlsHandshake.cs b/QuickProxyNet/Internal/TlsHandshake.cs new file mode 100644 index 0000000..402384f --- /dev/null +++ b/QuickProxyNet/Internal/TlsHandshake.cs @@ -0,0 +1,75 @@ +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 +{ + /// + /// 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 + /// . + /// + /// 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/Internal/Transports/HttpUpgradeHandshake.cs b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs index 93ca4c9..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; @@ -146,10 +147,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 +160,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. @@ -240,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/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; } /// diff --git a/QuickProxyNet/Internal/Transports/WebSocketStream.cs b/QuickProxyNet/Internal/Transports/WebSocketStream.cs index f8f3be8..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) { @@ -133,6 +137,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 = ArrayPool.Shared.Rent(buffer.Length); + try + { + int read = Read(rented, 0, buffer.Length); + rented.AsSpan(0, read).CopyTo(buffer); + return read; + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + 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); + } + } + protected override void Dispose(bool disposing) { if (disposing) 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 3791d65..cb4087d 100644 --- a/QuickProxyNet/Internal/VisionStream.cs +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -1,5 +1,7 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace QuickProxyNet; @@ -131,6 +133,7 @@ public override long Position // ================================ reading ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { @@ -171,7 +174,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 @@ -185,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; @@ -195,6 +202,7 @@ public override async ValueTask ReadAsync( } int skipped = Math.Min(_remainingPadding, Buffered); + Debug.Assert(skipped > 0); _start += skipped; _remainingPadding -= skipped; } @@ -239,7 +247,7 @@ public override int Read(Span buffer) continue; } - Fill(HeaderSize, throwOnEof: false); + Fill(HeaderSize); if (Buffered == 0) return 0; @@ -256,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; @@ -263,6 +272,7 @@ public override int Read(Span buffer) } int skipped = Math.Min(_remainingPadding, Buffered); + Debug.Assert(skipped > 0); _start += skipped; _remainingPadding -= skipped; } @@ -301,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..]); @@ -322,47 +335,38 @@ 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); + + // 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; @@ -372,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; @@ -399,6 +404,7 @@ private void Compact(int count) // ================================ writing ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { @@ -474,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/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/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/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..012f6fe 100644 --- a/QuickProxyNet/Internal/Vmess/VmessStream.cs +++ b/QuickProxyNet/Internal/Vmess/VmessStream.cs @@ -1,5 +1,7 @@ using System.Buffers; using System.Buffers.Binary; +using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace QuickProxyNet; @@ -166,6 +168,7 @@ public override long Position // ================================ reading ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] public override async ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) { @@ -240,6 +243,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); @@ -287,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( @@ -308,6 +316,7 @@ private void OpenChunk(int sealedLength, Memory plaintext) // ================================ writing ================================ /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] public override async ValueTask WriteAsync( ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { @@ -371,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; diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index 64e2b19..c89e3b3 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -1,18 +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); /// /// @@ -20,27 +18,23 @@ 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. + /// The proxy URL or share link. See for the schemes this accepts. /// /// The target host to connect to through the proxy. /// The target port. /// 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 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. + /// 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) { - IProxyClient client = ProxyClientFactory.Instance.Create(proxyLink); + IProxyClient client = Create(proxyLink); return await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); } @@ -57,178 +51,299 @@ 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); } /// - /// 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. + /// Connects to a target endpoint through a proxy described by a URL or share link. /// - /// - /// Proxy URI including scheme, host, port, and optional credentials. - /// Supported schemes: http, https, socks4, socks4a, socks5. + /// The proxy URL or share link, in any scheme accepts. + /// + /// A , whose name the proxy resolves, or an . /// - /// 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, + /// + /// is neither a nor an . + /// + public static async ValueTask ConnectAsync(string proxyLink, EndPoint target, CancellationToken cancellationToken = default) { - return ConnectCoreAsync(proxyUri, host, port, timeout: null, cancellationToken); + IProxyClient client = Create(proxyLink); + return await client.ConnectAsync(target, cancellationToken).ConfigureAwait(false); } /// - /// Connects to a target host through the specified proxy with a timeout. + /// Connects to a target endpoint through a proxy described by a URL or share link, giving up + /// after . /// - /// - /// Proxy URI including scheme, host, port, and optional credentials. - /// Supported schemes: http, https, socks4, socks4a, socks5. + /// The proxy URL or share link, in any scheme accepts. + /// + /// A , whose name the proxy resolves, or an . /// - /// 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) + /// + /// is neither a nor an . + /// + public static async ValueTask ConnectAsync(string proxyLink, EndPoint target, TimeSpan timeout, + CancellationToken cancellationToken = default) { - return ConnectCoreAsync(proxyUri, host, port, timeout, cancellationToken); + IProxyClient client = Create(proxyLink); + return await client.ConnectAsync(target, timeout, cancellationToken).ConfigureAwait(false); } /// - /// 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. + /// Creates an from a proxy URL or share link, whatever its scheme. /// - /// - /// Proxy URI including scheme and optional credentials. - /// Supported schemes: http, https, socks4, socks4a, socks5. + /// + /// http, https, socks4, socks4a, socks5, vless, + /// trojan, vmess or ss. Credentials in the authority are honoured for the + /// classic schemes; the rest carry their configuration in the link itself. /// - /// 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) + /// A client ready to . + /// is empty or has no scheme. + /// + /// 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. + /// + /// + /// 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) { - var credentials = ParseCredentials(proxyUri); - return ProxyConnector.ConnectToProxyAsync(source, proxyUri, host, port, credentials, cancellationToken); - } + ArgumentException.ThrowIfNullOrWhiteSpace(proxyLink); - private static async ValueTask ConnectCoreAsync(Uri proxyUri, string host, int port, - TimeSpan? timeout, CancellationToken cancellationToken) - { - ProxyClient.ValidateArguments(host, port); - cancellationToken.ThrowIfCancellationRequested(); + 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)); - var credentials = ParseCredentials(proxyUri); + ReadOnlySpan scheme = trimmed.AsSpan(0, separator); - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) - { - NoDelay = true, - LingerState = new LingerOption(true, 0) - }; + // 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); - 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); - } + if (scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) + return Tag(new TrojanClient(TrojanShareLink.Parse(trimmed)), trimmed); - try - { - await socket.ConnectAsync(proxyUri.Host, proxyUri.Port, cancellationToken); - } - catch (Exception ex) + 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) || + scheme.Equals("socks4", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("socks4a", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("socks5", StringComparison.OrdinalIgnoreCase)) { - 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); + 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')."); + + // 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); } - var stream = new NetworkStream(socket, ownsSocket: true); + throw new NotSupportedException( + $"Proxy scheme '{scheme}' is not supported. This library speaks http, https, socks4, " + + "socks4a, socks5, vless, trojan, vmess and ss (Shadowsocks)."); + } + + /// + /// 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 { - 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; + client = Create(proxyLink); + error = null; + return true; } 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; + client = null; + error = $"{ex.GetType().Name}: {ex.Message}"; + return false; } } - private static NetworkCredential? ParseCredentials(Uri proxyUri) + /// + /// 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, 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. 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) { - if (string.IsNullOrEmpty(proxyUri.UserInfo)) - return null; + ArgumentNullException.ThrowIfNull(proxyUri); - var sep = proxyUri.UserInfo.IndexOf(':'); - if (sep < 0) - return new NetworkCredential(proxyUri.UserInfo, string.Empty); + // 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); - return new NetworkCredential( - proxyUri.UserInfo.Substring(0, sep), - proxyUri.UserInfo.Substring(sep + 1)); + 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); + + 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, + "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, vmess and ss (Shadowsocks).") + }; + + return Tag(Create(type, proxyUri.Host, proxyUri.Port, ParseCredentials(proxyUri)), proxyUri.OriginalString); } -} -/// -/// Extension methods for connecting through proxies via . -/// -public static class ProxyUriExtensions -{ /// - /// Connects to a target host through the proxy specified by this URI. + /// Creates an for one of the classic proxy families from explicit + /// settings. /// - /// 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) + /// 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, 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 + // 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) { - return Proxy.ConnectAsync(proxyUri, host, port, cancellationToken); + if (client is ProxyClient concrete) + concrete.SourceLink = link; + return client; } - /// - /// 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) + private static NetworkCredential? ParseCredentials(Uri proxyUri) { - return Proxy.ConnectAsync(proxyUri, host, port, timeout, cancellationToken); + 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(Uri.UnescapeDataString(proxyUri.UserInfo), string.Empty); + + return new NetworkCredential( + Uri.UnescapeDataString(proxyUri.UserInfo.Substring(0, sep)), + Uri.UnescapeDataString(proxyUri.UserInfo.Substring(sep + 1))); } } 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/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/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/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/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index 40f24d1..cfd3be9 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -4,19 +4,22 @@ enable enable latest + + 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 51cc745..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,20 +13,13 @@ 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, 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) @@ -34,7 +27,9 @@ 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)`) +- `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 a4614b9..83b19f4 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` @@ -33,10 +33,10 @@ 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 +// 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, @@ -51,28 +51,49 @@ await using var stream = await Proxy.ConnectAsync( TimeSpan.FromSeconds(10)); ``` -### Extension method on Uri +### Factory API (when you need to configure the client) ```csharp -var proxy = new Uri("http://proxy.example.com:8080"); -await using var stream = await proxy.ConnectThroughProxyAsync("example.com", 443); +var client = Proxy.Create("socks5://proxy:1080"); +client.NoDelay = true; + +await using var stream = await client.ConnectAsync("example.com", 443); ``` -### Factory API (when you need to configure the client) +### 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 client = ProxyClientFactory.Instance.Create("socks5://proxy:1080"); -client.NoDelay = true; -client.ReadTimeout = 5000; +var proxy = Proxy.Create("vless://..."); +using var http = new HttpClient(new SocketsHttpHandler +{ + ConnectCallback = (context, ct) => proxy.ConnectAsync(context.DnsEndPoint, ct) +}); +``` -await using var stream = await client.ConnectAsync("example.com", 443); +### 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 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, @@ -98,8 +119,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 @@ -203,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 @@ -216,6 +253,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 @@ -225,10 +263,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/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` — один сокет» 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`. Это самое слабое звено рекомендации, и я хочу быть честным: вызывающий, который никогда не проверяет, утечет живым туннелем. Альтернатива (расширение интерфейса) — жесткое ломающее изменение; если мажорная версия все равно на столе, расширение чище. 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": [ diff --git a/tools/CorpusCheck/LiveProbe.cs b/tools/CorpusCheck/LiveProbe.cs index a2e6e6f..ae1f0fd 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++; @@ -330,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. @@ -488,8 +537,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 +547,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();