diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0343ca2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cs] +# QuickProxyNet deliberately uses a single flat namespace (QuickProxyNet) for every +# type, so files can be organised into folders (e.g. Internal/Crypto, Internal/Vmess) +# without changing the public API surface or forcing 'using' churn on consumers. +# Suppress the "namespace should match folder structure" guidance accordingly. +dotnet_style_namespace_match_folder = false +dotnet_diagnostic.IDE0130.severity = none diff --git a/.gitattributes b/.gitattributes index 1ff0c42..851a678 100644 --- a/.gitattributes +++ b/.gitattributes @@ -61,3 +61,7 @@ #*.PDF diff=astextplain #*.rtf diff=astextplain #*.RTF diff=astextplain + +# Docker integration-test assets are consumed inside Linux containers. +# A CRLF checkout would break the shell script and confuse the servers. +tests/docker/** text eol=lf diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2965628..a3451df 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -20,6 +20,15 @@ jobs: 9.x 10.x + # Separate step: dotnet-quality applies to every version in a step's list, and + # there is no preview channel for the GA releases above, so asking for one there + # fails the install. setup-dotnet accumulates SDKs across steps. + - name: Setup .NET 11 (preview) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.x + dotnet-quality: preview + - name: Restore run: dotnet restore diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f531c71..11f8d01 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -20,6 +20,15 @@ jobs: 9.x 10.x + # Separate step: dotnet-quality applies to every version in a step's list, and + # there is no preview channel for the GA releases above, so asking for one there + # fails the install. setup-dotnet accumulates SDKs across steps. + - name: Setup .NET 11 (preview) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 11.0.x + dotnet-quality: preview + - name: Restore run: dotnet restore diff --git a/.gitignore b/.gitignore index 442a07d..b0f0f12 100644 --- a/.gitignore +++ b/.gitignore @@ -363,3 +363,9 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd + +# Local agent tooling session state +.omc/ + +# Isolated git worktrees created for background agents. +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1c5bf26 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,445 @@ +# QuickProxyNet Agent Guide + +## Project Overview + +QuickProxyNet is a high-performance C#/.NET library for opening direct `Stream` +connections through proxy protocols. It covers the classic proxy family (HTTP, +HTTPS, SOCKS4, SOCKS4a, SOCKS5) and the VPN-style family (VLESS, Trojan, VMess). + +VLESS REALITY, with the `xtls-rprx-vision` flow, is part of the core: a managed +TLS 1.3 client, no external binary, no extra package. See "REALITY" below. + +- NuGet package: `QuickProxyNet` +- Author: Titlehhhh +- License: MIT +- Core targets: `net8.0`, `net9.0`, `net10.0`, `net11.0` + +## Repository Layout + +```text +QuickProxyNet/ Core library and protocol logic +QuickProxyNet.Tests/ xUnit tests +QuickProxyNet.Benchmarks/ BenchmarkDotNet benchmarks +Sample/ Console usage example +build/ NUKE build automation +docs/ Protocol notes and implementation research +tools/CorpusCheck/ Manual diagnostic: share-link parsers vs a real-world corpus +tests/docker/ Xray + sing-box servers for the integration tests +``` + +`tools/CorpusCheck` is deliberately **not** in `QuickProxyNet.slnx`: it is a +hand-run diagnostic, and keeping it out of the solution keeps it out of CI. + +## Public API Shape + +All public library types live in the `QuickProxyNet` namespace. + +- `Proxy` exposes static one-call `ConnectAsync(...)` helpers. +- `ProxyUriExtensions` adds `Uri.ConnectThroughProxyAsync(...)`. +- `IProxyClient` is the client contract; connection methods return + `ValueTask`. +- `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. + +REALITY lives in `QuickProxyNet/Internal/Reality/` and is reached through +`VlessClient` like any other security mode; `RealityHandshakeException` is the one +public type it adds. + +## Current Protocol Implementations + +| Class | Protocol | +| --- | --- | +| `HttpProxyClient` | HTTP CONNECT | +| `HttpsProxyClient` | HTTPS CONNECT over TLS | +| `Socks4Client` | SOCKS4 | +| `Socks4aClient` | SOCKS4a | +| `Socks5Client` | SOCKS5 with optional username/password auth | +| `VlessClient` | VLESS, `security=none`, `tls` or `reality`, with or without `xtls-rprx-vision` | +| `TrojanClient` | Trojan over TLS | +| `VmessClient` | VMess (VMessAEAD, `alterId=0`), optional TLS | + +All three run over any of three transports: `tcp`/`raw`, `ws`/`websocket`, `httpupgrade`. +`grpc`, `xhttp` and `h2` are rejected with `NotSupportedException` before any byte is +written. + +Internal protocol helpers live under `QuickProxyNet/Internal/`: + +```text +Internal/HttpHelper.cs HTTP CONNECT request/response +Internal/HttpResponseParser.cs +Internal/SocksHelper.cs SOCKS4/4a/5 +Internal/ProxyAddress.cs shared atyp/host/port encoding +Internal/VlessHelper.cs VLESS request header +Internal/VlessResponseStream.cs lazy VLESS response-header reader +Internal/TrojanHelper.cs Trojan request header +Internal/PrefixedStream.cs replays handshake overread bytes +Internal/Transports/ProxyTransport.cs transport resolution + layering +Internal/Transports/HttpUpgradeHandshake.cs the shared HTTP upgrade exchange +Internal/Transports/WebSocketStream.cs RFC 6455 framing as a Stream +Internal/Crypto/Sha224.cs SHA-224/SHA-256 core (Trojan password hash, VMess KDF) +Internal/Crypto/Crc32.cs CRC-32/IEEE (VMess header checksum) +Internal/Crypto/Fnv1a32.cs FNV-1a (VMess body chunk verification) +Internal/Crypto/UuidCodec.cs big-endian UUID encoding + Xray's non-UUID id derivation +Internal/Vmess/VmessKdf.cs VMessAEAD KDF +Internal/Vmess/VmessAuthId.cs 16-byte encrypted auth id +Internal/Vmess/VmessCmdKey.cs cmdKey derivation +Internal/Vmess/VmessRequest.cs sealed request header +Internal/Vmess/VmessResponse.cs response header +Internal/Vmess/VmessResponseStream.cs lazy response-header reader +Internal/Vmess/VmessStream.cs AEAD chunk framing +``` + +## REALITY + +REALITY authenticates by hiding a key exchange inside the TLS `session_id` of a +ClientHello that must look like a browser's, and `SslStream` hands the handshake to +Schannel or OpenSSL with no way to author those bytes. So the handshake is written +here, in `QuickProxyNet/Internal/Reality/`: + +```text +X25519.cs RFC 7748, because net8-net10 have no X25519 anywhere +RealityAuth.cs authKey derivation, session_id sealing, the certificate HMAC +TlsKeySchedule.cs RFC 8446 §7.1 and §7.3 +TlsRecordLayer.cs Suites, record protection, record read/write +TlsWriter.cs TLS's length-prefixed vectors, with backpatching +TlsClientHello.cs The hello — NOT yet a browser fingerprint, see below +RealityTlsClient.cs The handshake state machine +RealityTlsStream.cs Application data over the record layer +``` + +`VisionStream` (in `Internal/`) carries the `xtls-rprx-vision` padding protocol, which +the great majority of deployed REALITY nodes require. Its TLS-in-TLS splice is not +implemented: that is throughput, not wire format. + +An earlier `QuickProxyNet.Reality` package drove a child Xray process to do all this. +It was deleted once the managed path was proven against real servers — it was never +published, and keeping a second implementation alive to cover a shrinking gap costs +more than the gap. `grpc`, `xhttp` and a genuine uTLS fingerprint went with it; they +are listed as unsupported rather than delegated. + +The managed path completes a real handshake against Xray-core and carries VLESS, with +no external process. What it is not, yet, is a fingerprint: the hello it emits has no +GREASE, no padding, an arbitrary extension order and a bare X25519 `key_share`, where +Chrome sends about 1.7 KB with `X25519MLKEM768`. **That gap is a correctness problem, +not polish** — a client whose hello merely works matches no deployed browser and so +puts its user in a smaller, stranger bucket than one that fails. Until it closes, +REALITY here is a protocol implementation, and the type says so in its own docs. +`docs/reality-fingerprint-plan.md` has the byte-level detail and the staged plan. + +Testing follows the same rule as the rest of the repo — prove it against something +independent: + +- `X25519Test` — RFC 7748 vectors, a keypair generated by `xray x25519`, and field + multiplication against `BigInteger` over ~42 000 products including maximal limbs. +- `TlsKeyScheduleTest` — every value in RFC 8448's published trace. +- `RealityAuthTest` — our sealed `session_id`, opened by the server algorithm + 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. +- `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. + +**Public shape, decided:** REALITY is reached through `VlessClient` — `security=reality` +in `VlessOptions`, or simply the share link via `ProxyClientFactory.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 +already says which security mode it wants. + +`Integration/ManagedRealityTunnelTests.PublicApi_*` are the tests that go through that +public path end to end; the other `Managed*` tests call the handshake directly. + +## Hard-Won Protocol Knowledge + +Every item below cost a separate investigation. Do not re-derive them, and do +not "clean up" any of them without reading the reasoning first. + +1. **The namespace is flat.** Every type is in `QuickProxyNet` regardless of its + folder. This is load-bearing: it lets files move between folders without a + breaking API change. `IDE0130` (namespace must match folder) is suppressed in + `.editorconfig` on purpose. Do **not** "fix" it by renaming namespaces. + +2. **VMess *and VLESS* read their response header lazily.** + `VmessResponseStream` decodes the sealed response header, and + `VlessResponseStream` validates `ver + addonsLen`, on the first `Read` — not in + `ConnectAsync`. Reading either eagerly deadlocks every protocol where the + client speaks first (HTTP, TLS, Minecraft): the server only flushes its header + once the target has replied. + + VLESS was originally eager, and the byte-exact vectors could not see it — + `FakeProxyStream` always has the response already buffered. `DockerProtocolTests` + caught it immediately: all five VLESS cases timed out against **both** Xray and + sing-box. A raw probe confirmed the cause: writing the VLESS request and then + reading two bytes hangs on both servers, while writing the request and an HTTP + GET together returns `00 00` followed by the HTTP response. This is why the + integration tests exist — a vector proves the bytes are right, not that a server + will talk to us. + + Consequence: a rejected VLESS handshake (wrong id — both servers just drop the + connection) surfaces as a `ProxyProtocolException` on the first `Read`, not from + `ConnectAsync`. That is inherent to the protocol, not a regression; the server + sends nothing at connect time either way. + +3. **The VMess option byte is `0x01`** (ChunkStream only), not `0x1D`. + `VmessStream` implements baseline framing. Announcing ChunkMasking / + GlobalPadding / AuthenticatedLength makes the server mask chunk lengths with + SHAKE128 and the stream desynchronizes immediately. + +4. **The address-type codes differ per protocol.** VLESS and VMess use + `01`=IPv4, `02`=domain, `03`=IPv6. Trojan and SOCKS5 use `01`=IPv4, + `03`=domain, `04`=IPv6. Field order differs too: VLESS and VMess write the + **port before the address**, Trojan writes it after. + +5. **UUIDs must use `Guid.TryWriteBytes(..., bigEndian: true)`.** + `Guid.ToByteArray()` emits the first three fields little-endian on every + platform, which is the wrong order for these wire formats. This is the + classic trap; `UuidCodec` exists to make it impossible to hit. + +6. **A clean VMess EOF is only an authenticated empty chunk** (`00 10` followed + by the tag). A truncated stream or a bad tag is a hard error and must never + be reported as EOF — otherwise a truncation attack looks like a normal close. + +7. **SIMD is already done where it can be.** AES-GCM, ChaCha20-Poly1305 and + SHA-256 in the BCL are hardware-accelerated. The SSE4.2 `crc32` instruction + computes CRC-32C (Castagnoli); VMess needs CRC-32/IEEE, a different + polynomial, which that instruction cannot produce. FNV-1a is inherently + sequential. This was measured — do not spend time on it again. + +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 + `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 + `common/uuid.ParseString` maps any id of length 1..30 to + `UUIDv5(nil-namespace, utf8(id))` — i.e. `SHA1(16 zero bytes || id)[0..16]` + with the version nibble set to 5 and the RFC 4122 variant bits set. Length 0 + or 31 is an error; 32..36 is parsed as canonical hex. `UuidCodec` mirrors + this exactly. About 0.3% of real-world VLESS links depend on it. + +10. **VMess `type` is header obfuscation, and only means anything for `net=tcp`.** + For `ws`/`httpupgrade`/`grpc` every real client ignores it, so rejecting a + junk `type` on those transports rejects otherwise-valid links. + +11. **`vmess://` has two grammars in the wild**: base64-JSON (v2rayN), optionally + with a `#remark` fragment appended *after* the base64; and the standard URI + form `vmess://uuid@host:port?encryption=..&type=..&security=..#remark`. + `VmessShareLink` handles both. Roughly 55% of real links use one of the two + shapes that pure base64-JSON parsing would reject. + +12. **HTML-escaped links silently downgrade REALITY to plaintext.** Some producers + publish links with `&` as the parameter separator. Splitting on `&` then + yields keys named `amp;security`, `amp;flow`, `amp;pbk`. Discarding them as + "unknown keys" leaves `security` at its default `None` and `flow` empty, so a + REALITY node passes `EnsureSupported()` and the client connects **in the + clear, sending the UUID unencrypted**. `ShareLinkQuery.StripHtmlAmpPrefix` + strips the prefix in all three query scanners. Stripping is unconditionally + safe: a literal `&` inside a value must be `%26`, so a bare `&` is always a + separator. 68 vless and 10 trojan corpus links arrive this way, 51 REALITY. + + Note how this was found: **not** by the parse-rate number, which cannot see it + — those links always "parsed successfully", just wrongly. It took connecting to + live nodes. A percentage is not a proof; treat a metric that cannot fail as a + metric that is not measuring. + +13. **`httpupgrade` must not send `Sec-WebSocket-Key`.** Both `ws` and `httpupgrade` + advertise `Upgrade: websocket` — that camouflage is the whole point of + `httpupgrade` — but sing-box routes any request carrying `Sec-WebSocket-Key` to + its WebSocket handler, which an httpupgrade inbound does not have, and answers + **404**. Xray accepts either form. Sending the key on both looked like free + camouflage and broke sing-box outright; only running both servers caught it. + Measured directly: the key alone triggers the 404, while `Sec-WebSocket-Version` + on its own still upgrades. + +14. **The WebSocket framing is the BCL's, on purpose.** `WebSocketStream` wraps + `WebSocket.CreateFromStream` rather than framing by hand. Masking, fragment + reassembly and interleaved control frames are a large surface of subtle, + security-relevant bugs, and that implementation is hardened and allocation-tuned. + Two things the adapter must keep doing: a zero-length binary frame is **not** EOF + (returning its `0` would silently truncate the tunnel — a `Read` must keep going + until real bytes or a close frame arrive), and message boundaries are deliberately + not preserved, because a proxy tunnel is a byte stream. + +15. **`vmess://` `security=` means two different things in the wild.** The URI + grammar defines it as the transport security (JSON `tls`), but 611 corpus links — + 27% of every vmess link — put the *body cipher* there (JSON `scy`). The value sets + are disjoint apart from `none`, so the reading is recovered from the value, not + guessed: `auto`/`aes-128-gcm`/`chacha20-poly1305` are a body cipher, + `tls`/`reality`/`none` are transport security. `none` keeps its documented meaning + — both readings agree there is no TLS, so nothing is downgraded. This is safe in a + way the VLESS case was not: VMessAEAD seals the request header under a key derived + from the id, so a wrong guess costs a failed handshake, never a cleartext id. + +16. **REALITY's auth key is the TLS `key_share` private key.** Not a second keypair + smuggled somewhere — the client computes `X25519(clientKeySharePrivate, pbk)`, and + the server recovers the public half straight out of `clientHello.keyShares` (the + X25519 entry, or the X25519 tail of an `X25519MLKEM768` one). The result is + `HKDF-SHA256(salt: clientRandom[0..20], info: "REALITY")`, and it keys an + AES-256-GCM whose ciphertext plus tag exactly fill the 32-byte `session_id`. The + additional data is the **raw ClientHello with `session_id` zeroed**, which is what + stops a censor lifting the blob out of a recorded handshake and replaying it inside + a hello of its own. `session_id` sits at a fixed offset 39, and only because a TLS + 1.3 hello always declares a full 32-byte session id. The server then proves itself + with `HMAC-SHA512(authKey, leafPublicKey)` placed in the leaf certificate's + *signature* field — a field `X509Certificate2` does not expose, so the certificate + has to be parsed by hand. Source: `XTLS/REALITY` `tls.go` and Xray-core + `transport/internet/reality/reality.go`. + +17. **REALITY runs only over raw TCP.** Xray refuses `security=reality` with a `ws` or + `httpupgrade` transport outright — *"REALITY only supports RAW, XHTTP and gRPC for + now"* — and does it at config-load time, so the failure reaches a caller as an + 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 + 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 + 100 000 bytes through `Socks5Client` against a plain relay and then stalling the + same request against Xray with no VLESS, TLS or REALITY anywhere in the path. + Disabling inbound sniffing and splitting the write into 4 KiB slices change + nothing. Verified against Xray-core 26.3.27 on Windows. + +## Development Rules + +- Keep hot protocol paths allocation-conscious: prefer `Span`, + `Memory`, `ArrayPool`, `stackalloc`, and `ValueTask`. +- Return rented buffers in `finally`, and clear them when they held credentials. +- Use `BinaryPrimitives` for network byte order. +- Avoid LINQ in protocol hot paths. +- Keep protocol helpers `internal` unless a public API is intentionally needed. +- Public API additions must have XML documentation. +- Add new proxy types through `ProxyType`, client implementation, factory + registration, protocol helper, error codes, and tests. +- Preserve multi-target compatibility for `net8.0`, `net9.0`, `net10.0` and + `net11.0`. `net11.0` is still a preview SDK, so building the repo needs a + preview .NET install; that is what emits `NETSDK1057` on every build. +- The test project multi-targets `net10.0;net11.0` so the newest target is + actually exercised. A TFM nothing runs against is a claim of support, not + support. +- **Never silently downgrade.** An unrecognized `security=`, a non-zero + `alterId`, or a transport we cannot speak must fail with a message naming what + was found and what is accepted. Defaulting an unknown TLS mode to plaintext + would send the user's UUID in the clear; that bug was caught in review once + already. + +## Testing + +Run the test project directly: + +```bash +dotnet test QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +``` + +The crypto is pinned by byte-exact vectors produced by an independent +implementation: `VmessCryptoTest`, `VmessRequestTest`, `VmessBodyTest`, +`Sha224Test`. A failure there means the code is wrong, not the test. Never relax +those vectors. + +Integration tests live in `QuickProxyNet.Tests/Integration/`: + +- `DockerProtocolTests` runs real Xray and sing-box servers from + `tests/docker/docker-compose.yml`. Enable with `QPN_DOCKER_TESTS=1`. +- `ConnectTest` uses external proxies via `HTTP_PROXY_URI` / `SOCKS5_PROXY_URI`. + +Both gate on environment variables through the attributes in +`QuickProxyNet.Tests/SkipGates.cs` (`[EnvFact]`, `[AnyEnvFact]`, `[DockerFact]`, +`[DockerTheory]`), so an unconfigured test reports as **skipped**, never as +passed. Do not replace that with an early `return` — it turns "did not run" into +"green", which is how a test suite starts lying about what it proves. + +**There is no runtime `Assert.Skip` on xunit 2.9.3.** This was checked, not +assumed. `Assert.Skip` / `Assert.SkipWhen` / `Assert.SkipUnless` are not in the +shipped xunit.assert 2.9.3 assembly at all — they sit behind the `XUNIT_SKIP` +compilation define that only xunit.v3 sets. `Xunit.Sdk.SkipException.ForSkip` *is* +public there, so `throw SkipException.ForSkip(...)` compiles — but the +`$XunitDynamicSkip$` token it encodes appears in **no** v2 assembly (verified +against xunit.core 2.9.3, xunit.execution.dotnet 2.9.3 and +xunit.runner.visualstudio 3.0.0), so v2 reports the throw as a plain **failure** +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. + +If a docker run is interrupted, clean up with: + +```bash +docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml down -v +``` + +## Diagnostics + +`tools/CorpusCheck` runs the share-link parsers over ~20k real-world links +(PypsCFG `merged_all.txt`) and groups failures by reason: + +```bash +dotnet run --project tools/CorpusCheck # parse the corpus +dotnet run --project tools/CorpusCheck -- --live 30 # connect to sampled live nodes +``` + +Rules: the corpus is downloaded to a temp directory and **never committed** — it +contains real IPs, UUIDs and passwords belonging to other people. Every example +in the report is redacted to a shape. Test fixtures stay synthetic. `--live` is +manual-only and must never run in CI. + +## Build + +NUKE build scripts are available from the repository root: + +```bash +./build.cmd # Windows +./build.sh # Linux/macOS +``` + +Useful targets include restore, compile, tests, pack, and push. Versioning is +derived from git tags through MinVer. + +## Working Process That Actually Worked + +- Do protocol work in **sequential** sub-agents. Parallel agents share the test + project and break each other's build. +- Verify every agent's claims yourself: `dotnet build -c Release` (0 warnings on + all four TFMs) and `dotnet test`. Do not trust a report. +- Ground truth for crypto is an **independent implementation** (a throwaway + Python one worked well) that first reproduces the already-committed vectors, + and only then is used to generate new ones. +- Follow implementation with an adversarial review round. + +That process is what caught the silent plaintext downgrade on an unknown +`security=`, the VMess response-header deadlock, the bracketed-IPv6 host bug, +and credential buffers that were returned to the pool unzeroed. + +## VPN-Style Protocol Research + +Notes for VLESS, VMess and Trojan live in `docs/` alongside the implementation. +`docs/quic-protocols-analysis.md` covers Hysteria2 and TUIC: those are **not +implemented**, and that document explains the architectural problem (one QUIC +connection multiplexes many streams, which does not fit "one `ConnectAsync`, one +socket") that has to be solved before they can be. + +## What Is Worth Implementing Next + +Measured by this library's own parsers over 20 228 real links (2026-08-21), counting +what can actually **connect**, not what parses. REALITY — 46% of the corpus — is done +and in-process as of 4.0.0; what remains: + +| Blocker | % of corpus | +| --- | ---: | +| gRPC transport (needs an HTTP/2 layer) | 6.0% | +| xhttp transport (HTTP/2/3, Xray-only) | 2.9% | +| Hysteria2 / TUIC (QUIC) | 2.9% | + +And one thing that is not a blocker but matters more than any of those: the REALITY +ClientHello is still not a browser fingerprint. It connects — verified against live +nodes — but a DPI that fingerprints hellos can tell it from Chrome. The staged plan is +in `docs/reality-fingerprint-plan.md`; it is the next REALITY work, ahead of any new +transport. + +The point of the table: **QUIC is the worst remaining investment**. It is the heaviest +architectural work — it breaks the "one `ConnectAsync`, one socket" model — and buys +under 3%. gRPC is the cheapest of the three and unlocks the most. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7938b54..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,189 +0,0 @@ -# QuickProxyNet — CLAUDE.md - -## Project Overview - -**QuickProxyNet** is a high-performance C# .NET library for connecting to servers through proxy protocols (HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5). It provides direct `Stream` access for low-level networking with minimal allocations and latency. - -- **NuGet package:** `QuickProxyNet` -- **Author:** Titlehhhh -- **License:** MIT -- **Targets:** net8.0, net9.0, net10.0 - -## Solution Structure - -``` -QuickProxyNet/ — Core library (public API + internal protocol logic) -QuickProxyNet.Tests/ — XUnit tests (net8.0) -QuickProxyNet.Benchmarks/— BenchmarkDotNet benchmarks (net8.0) -QuickProxyNet.Pipelines/ — Experimental System.IO.Pipelines rewrite (net8.0) -Sample/ — Usage example console app -build/ — Nuke build automation -``` - -## Core Architecture - -**Namespace:** `QuickProxyNet` (all public types) - -### Public API - -- **`IProxyClient`** — main interface: `ConnectAsync(host, port, ...)` returns `ValueTask` -- **`ProxyClient`** — abstract base with socket creation, timeout, and error handling -- **`ProxyClientFactory`** — singleton factory: creates clients from `Uri` or explicit parameters -- **`ProxyType`** — enum: `Http`, `Https`, `Socks4`, `Socks4a`, `Socks5` -- **`ProxyProtocolException`** — custom exception with `ProxyErrorCode` enum - -### Client Implementations (`QuickProxyNet/Clients/`) - -| Class | Protocol | -|---|---| -| `HttpProxyClient` | HTTP CONNECT tunnel | -| `HttpsProxyClient` | HTTPS CONNECT + SSL/TLS | -| `Socks4Client` | SOCKS4 (IP only) | -| `Socks4aClient` | SOCKS4a (domain names) | -| `Socks5Client` | SOCKS5 (full, with auth) | - -### Internal Helpers (`QuickProxyNet/Internal/`) - -- **`SocksHelper.cs`** — SOCKS4/4a/5 binary protocol (RFC-compliant, 313 lines) -- **`HttpHelper.cs`** — HTTP CONNECT with `PreallocatedStream` for header reuse (314 lines) -- **`ProxyConnector.cs`** — routes to the right tunnel method -- **`HttpResponseParser.cs`** — HTTP response parsing -- **`ConnectHelper.cs`** — SSL/TLS helpers with cert validation mapping -- **`CancellationHelper.cs`** — cancellation + exception utilities - -## Key Dependencies - -| Package | Purpose | -|---|---| -| `MinVer` 6.0.0 | Versioning from git tags (no config needed) | -| `ConfigureAwait.Fody` 3.3.2 | IL weaving — ConfigureAwait on all awaits | -| `DotNet.ReproducibleBuilds` 1.2.4 | Deterministic builds | -| `System.IO.Pipelines` | Pipelines project only | - -BCL-only for protocol logic — no external runtime dependencies. - -## Code Style & Patterns - -### C# Settings (all projects) -- `ImplicitUsings`, `Nullable`, `LangVersion: latest` -- `AllowUnsafeBlocks: true` (performance-critical paths) - -### Performance Patterns (follow these in all changes) -- **`ValueTask`** everywhere for async — no unnecessary `Task` allocations -- **`ArrayPool.Shared.Rent/Return`** for temporary buffers -- **`stackalloc`** for small stack buffers (`stackalloc char[256]`) -- **`ReadOnlySpan` / `Memory`** for buffer slices -- **`Utf8Formatter.TryFormat`** for int→UTF-8 without alloc -- **`Base64.EncodeToUtf8`** for base64 directly to byte span -- **`PreallocatedStream`** pattern to recycle response buffers without allocation -- **`BinaryPrimitives.WriteUInt16BigEndian`** for big-endian network byte order - -### Architecture Patterns -- Factory pattern (`ProxyClientFactory`) -- Template method / abstract base (`ProxyClient`) -- Strategy pattern (proxy type selection) -- Internal implementation hidden behind `internal` keyword - -### Error Handling -- All proxy errors → `ProxyProtocolException` with specific `ProxyErrorCode` -- Socket exceptions translated to protocol exceptions in `ProxyClient` -- Timeout via `TimeProvider.System.CreateTimer()` - -## Build System - -**NUKE** build automation (`build/Build.cs`). - -```bash -# Run via scripts in repo root: -./build.sh # Linux/macOS -./build.cmd # Windows - -# Key targets: -Restore # Restore NuGet packages -Compile # Build all projects -Tests # Run xUnit tests -Pack # Create NuGet package (Release mode) -Push # Publish to NuGet / GitHub Packages -``` - -Versioning: **MinVer** 6.0.0 — version is derived from git tags automatically. - -## CI/CD (GitHub Actions) - -Two workflows in `.github/workflows/`: - -### `build.yaml` — Build & Test -- **Triggers:** push to `master`, PRs to `master` -- **Steps:** restore → build → test -- Runs on `ubuntu-latest` with .NET 8.x + 9.x - -### `publish.yaml` — Publish to NuGet -- **Triggers:** push tag `v*` (e.g. `v1.2.3`) -- **Steps:** restore → build → test → pack → push to nuget.org -- Uses `fetch-depth: 0` so MinVer can read tag history -- **Required secret:** `NUGET_API_KEY` (repo Settings → Secrets → Actions) - -### Release workflow -```bash -git tag v1.2.3 -git push origin v1.2.3 -# GitHub Actions automatically builds, tests, packs, and publishes to NuGet -``` - -## Testing - -**Framework:** xUnit 2.5.3, coverlet for coverage - -```bash -dotnet test QuickProxyNet.Tests/ -``` - -Test files: -- `FactoryTest.cs` — URI parsing, credentials, unsupported protocols -- `InternalTest.cs` — HTTP response parser (in progress) -- `ConnectTest.cs` — connection tests (in progress) - -Tests are sparse — prefer adding integration tests for new protocol behavior. - -## Experimental Branch: `experimental/pipelines-lib` - -The `QuickProxyNet.Pipelines/` project rewrites the internals using `System.IO.Pipelines`: -- Uses `IDuplexPipe` instead of `Stream` -- `IBufferWriter` + `SpanWriter` for writing -- Sequence-based reading (eliminates manual `ArrayPool` management) -- Currently covers SOCKS4/4a protocol; work in progress - -## Important Notes for Development - -1. **No LINQ** in hot paths — allocates enumerators. -2. **No `async void`** — always use `async Task` or `async ValueTask`. -3. **Always release `ArrayPool` rentals** in `finally` blocks. -4. **Public API must be XML-documented** — `GenerateDocumentationFile` is enabled. -5. **ConfigureAwait** is handled by Fody weaving — do not add manually. -6. **Multi-targeting** — changes in `QuickProxyNet/` must be compatible with net8.0, net9.0, and net10.0. -7. **`ProxyErrorCode`** — add new codes there before throwing new exception types. -8. When editing protocol logic, validate against the relevant RFC: - - SOCKS4/4a: no official RFC, de-facto standard - - SOCKS5: RFC 1928 + RFC 1929 (auth) - - HTTP CONNECT: RFC 9110 - -## Common Tasks - -### Add a new proxy type -1. Add value to `ProxyType` enum -2. Create `NewProxyClient.cs` in `Clients/` extending `ProxyClient` -3. Register in `ProxyClientFactory` switch -4. Add `ProxyErrorCode` values as needed -5. Write tests in `FactoryTest.cs` and a connection test - -### Add/modify protocol helper -- Edit `Internal/SocksHelper.cs` or `Internal/HttpHelper.cs` -- Keep all types `internal` -- Prefer `SpanWriter` over manual array indexing -- Use `ReadExactlyAsync()` (from `Ext.cs`) for exact-length reads - -### Run benchmarks -```bash -cd QuickProxyNet.Benchmarks -dotnet run -c Release -``` diff --git a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj index 56c57e0..8a4ff39 100644 --- a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj +++ b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj @@ -1,6 +1,6 @@ - net10.0 + net10.0;net11.0 Exe diff --git a/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs new file mode 100644 index 0000000..904bdcb --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs @@ -0,0 +1,187 @@ +using System; +using System.Security.Cryptography; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Everything the managed REALITY client does once per connection: the X25519 key +/// exchange, the REALITY auth-key derivation and session-id seal, the TLS 1.3 key schedule, and +/// building the ClientHello. +/// +/// +/// +/// None of this is a throughput number and it must not be read as one. A connection runs each of +/// these a handful of times and then never again, so the unit that matters is latency added +/// to a single connect. Sum the means, do not divide bytes by them. +/// +/// +/// The X25519 category exists to answer one question: on net11.0 the BCL finally +/// ships , so is the hand-written +/// curve in still worth carrying there? The managed one has to stay for +/// net8.0net10.0 regardless — the BCL has no X25519 at all on those — so this only +/// decides whether net11.0 should branch to the platform implementation. +/// +/// +/// Managed_Agree and Platform_Agree are the like-for-like pair: both are one +/// variable-base scalar multiplication over an already-prepared key. Platform_GetPublicKey +/// additionally pays an ImportPrivateKey, because that is the only way the platform API +/// lets a caller move from a private scalar to its public half; that overhead is real for any +/// caller that keeps its own key bytes, which this client does. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class RealityHandshakeBenchmark +{ + private class Config : ManualConfig + { + public Config() => + AddJob(Job.ShortRun.WithIterationCount(5).WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const string ServerName = "www.microsoft.com"; + + private readonly byte[] _clientPrivate = new byte[X25519.KeySize]; + private readonly byte[] _clientPublic = new byte[X25519.KeySize]; + private readonly byte[] _serverPrivate = new byte[X25519.KeySize]; + private readonly byte[] _serverPublic = new byte[X25519.KeySize]; + private readonly byte[] _shared = new byte[X25519.KeySize]; + + private readonly byte[] _clientRandom = new byte[32]; + private readonly byte[] _authKey = new byte[RealityAuth.AuthKeySize]; + private readonly byte[] _shortId = new byte[RealityAuth.ShortIdSize]; + private readonly byte[] _clientVersion = [1, 8, 4]; + + /// A real ClientHello, rebuilt from a template every iteration so the seal is in place. + private byte[] _helloTemplate = null!; + private byte[] _hello = null!; + + private readonly byte[] _trafficSecret = new byte[32]; + private readonly byte[] _key = new byte[16]; + private readonly byte[] _iv = new byte[12]; + private readonly byte[] _expanded = new byte[32]; + + private static readonly string[] Alpn = ["h2", "http/1.1"]; + +#if NET11_0_OR_GREATER + private X25519DiffieHellman _platformClient = null!; + private X25519DiffieHellman _platformServer = null!; + private readonly byte[] _platformShared = new byte[32]; + private readonly byte[] _platformPublic = new byte[32]; +#endif + + [GlobalSetup] + public void Setup() + { + X25519.GenerateKeyPair(_clientPrivate, _clientPublic); + X25519.GenerateKeyPair(_serverPrivate, _serverPublic); + + RandomNumberGenerator.Fill(_clientRandom); + RandomNumberGenerator.Fill(_shortId); + RandomNumberGenerator.Fill(_trafficSecret); + + RealityAuth.DeriveAuthKey(_authKey, _clientPrivate, _serverPublic, _clientRandom); + + _helloTemplate = TlsClientHello.Build(ServerName, Alpn).Handshake; + _hello = new byte[_helloTemplate.Length]; + +#if NET11_0_OR_GREATER + _platformClient = X25519DiffieHellman.ImportPrivateKey(_clientPrivate); + _platformServer = X25519DiffieHellman.ImportPrivateKey(_serverPrivate); +#endif + } + + [GlobalCleanup] + public void Cleanup() + { +#if NET11_0_OR_GREATER + _platformClient.Dispose(); + _platformServer.Dispose(); +#endif + } + + // ============================== X25519 ============================== + + /// One variable-base scalar multiplication: the REALITY shared secret. + [Benchmark] + [BenchmarkCategory("X25519")] + public void Managed_Agree() => X25519.Agree(_shared, _clientPrivate, _serverPublic); + + /// One fixed-base (u = 9) scalar multiplication: private scalar to public key. + [Benchmark] + [BenchmarkCategory("X25519")] + public void Managed_GetPublicKey() => X25519.GetPublicKey(_clientPublic, _clientPrivate); + +#if NET11_0_OR_GREATER + /// The .NET 11 platform equivalent of . + [Benchmark] + [BenchmarkCategory("X25519")] + public void Platform_Agree() => _platformClient.DeriveRawSecretAgreement(_serverPublic, _platformShared); + + /// + /// The .NET 11 equivalent of , including the + /// ImportPrivateKey a caller holding raw key bytes cannot avoid. + /// + [Benchmark] + [BenchmarkCategory("X25519")] + public void Platform_GetPublicKey() + { + using var key = X25519DiffieHellman.ImportPrivateKey(_clientPrivate); + key.ExportPublicKey(_platformPublic); + } +#endif + + // ============================= REALITY ============================= + + /// X25519 agreement plus HKDF-SHA256 — the REALITY auth key. + [Benchmark] + [BenchmarkCategory("Reality")] + public void DeriveAuthKey() => + RealityAuth.DeriveAuthKey(_authKey, _clientPrivate, _serverPublic, _clientRandom); + + /// + /// AES-256-GCM over the whole ClientHello as additional data, output into session_id. + /// + /// + /// The hello is copied from a template first because the seal is in-place and destroys its own + /// additional data. That copy is a few hundred bytes and is part of what the caller pays + /// anyway, since the hello is rebuilt per connection. + /// + [Benchmark] + [BenchmarkCategory("Reality")] + public void SealSessionId() + { + _helloTemplate.AsSpan().CopyTo(_hello); + RealityAuth.SealSessionId(_hello, _authKey, _shortId, 1_800_000_000u, _clientVersion); + } + + // =========================== key schedule =========================== + + /// One HKDF-Expand-Label producing 32 bytes. + [Benchmark] + [BenchmarkCategory("KeySchedule")] + public void ExpandLabel() => + TlsKeySchedule.ExpandLabel(HashAlgorithmName.SHA256, _trafficSecret, "derived"u8, default, _expanded); + + /// The two expands that turn a traffic secret into an AEAD key and static IV. + [Benchmark] + [BenchmarkCategory("KeySchedule")] + public void TrafficKeys() => + TlsKeySchedule.TrafficKeys(HashAlgorithmName.SHA256, _trafficSecret, _key, _iv); + + // ============================ ClientHello ============================ + + /// + /// The whole ClientHello: a fresh key pair (one fixed-base scalar multiplication), the random, + /// and every extension serialised. + /// + [Benchmark] + [BenchmarkCategory("ClientHello")] + public byte[] BuildClientHello() => TlsClientHello.Build(ServerName, Alpn).Handshake; +} diff --git a/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs new file mode 100644 index 0000000..a87e9c1 --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// The managed REALITY hot path: sealing and opening one TLS 1.3 +/// record. Everything else in the client runs once per connection; this runs for every record for +/// the life of the tunnel, so it is the number that sets the ceiling on throughput. +/// +/// +/// +/// Sizes are 64 B (an interactive write), 1 KiB, 8 KiB and 16 384 B — the last being +/// , the size every bulk transfer actually uses. +/// +/// +/// Why Unprotect is measured by subtraction. A TLS record's nonce is the static IV +/// xored with a sequence number that advances on every call and cannot be rewound, so a +/// pre-computed ciphertext decrypts exactly once — there is no way to call Unprotect in a +/// loop against a fixed input without the tag check failing. RoundTrip therefore drives a +/// matched writer/reader pair that stay in lockstep for the whole run, and the open cost is +/// (RoundTrip − Protect). This is the same construction uses, +/// for the same reason. +/// +/// +/// The MB/s column is plaintext bytes per second; for RoundTrip the payload is +/// processed twice, so that column understates the AEAD's raw rate by design — it is the rate the +/// tunnel sustains, which is what a caller cares about. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class RealityRecordBenchmark +{ + private class Config : ManualConfig + { + public Config() + { + AddJob(Job.ShortRun.WithIterationCount(5).WithToolchain(InProcessNoEmitToolchain.Instance)); + AddColumn(new ThroughputColumn()); + } + } + + /// Plaintext bytes per second, derived from the Size parameter and the mean. + private sealed class ThroughputColumn : IColumn + { + public string Id => nameof(ThroughputColumn); + public string ColumnName => "MB/s"; + public bool AlwaysShow => true; + public ColumnCategory Category => ColumnCategory.Custom; + public int PriorityInCategory => 0; + public bool IsNumeric => true; + public UnitType UnitType => UnitType.Dimensionless; + + public string Legend => + "Plaintext megabytes (10^6 B) per second: Size / Mean. RoundTrip moves the payload twice."; + + public bool IsAvailable(Summary summary) => true; + + public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => false; + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase) + { + double? mean = summary[benchmarkCase]?.ResultStatistics?.Mean; + object size = benchmarkCase.Parameters[nameof(Size)]; + + if (mean is not > 0 || size is not int bytes) + return "?"; + + // Mean is nanoseconds, so bytes / (mean * 1e-9) / 1e6 == bytes * 1000 / mean. + return (bytes * 1000.0 / mean.Value).ToString("N1", CultureInfo.InvariantCulture); + } + + public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) => + GetValue(summary, benchmarkCase); + } + + private const ushort Aes128Gcm = 0x1301; + private const ushort ChaCha20Poly1305Suite = 0x1303; + + /// + /// The suites to measure. ChaCha20-Poly1305 is only offered where the platform has it — on a + /// machine without it, returns null and the client never + /// negotiates it, so benchmarking it would measure nothing the client can reach. + /// + public static IEnumerable Suites() + { + yield return "AES-128-GCM"; + + if (ChaCha20Poly1305.IsSupported) + yield return "ChaCha20-Poly1305"; + } + + [ParamsSource(nameof(Suites))] + public string Suite { get; set; } = "AES-128-GCM"; + + [Params(64, 1024, 8192, TlsRecordStream.MaxPlaintext)] + public int Size { get; set; } + + private TlsRecordProtection _protect = null!; + private TlsRecordProtection _pairWrite = null!; + private TlsRecordProtection _pairRead = null!; + + private byte[] _plaintext = null!; + private byte[] _ciphertext = null!; + private byte[] _opened = null!; + private readonly byte[] _tag = new byte[TlsCipherSuite.TagLength]; + private readonly byte[] _header = new byte[5]; + + [GlobalSetup] + public void Setup() + { + ushort id = Suite == "ChaCha20-Poly1305" ? ChaCha20Poly1305Suite : Aes128Gcm; + TlsCipherSuite suite = TlsCipherSuite.FromId(id) + ?? throw new NotSupportedException($"{Suite} is not available on this platform."); + + Span trafficSecret = stackalloc byte[suite.HashLength]; + RandomNumberGenerator.Fill(trafficSecret); + + _protect = new TlsRecordProtection(suite, trafficSecret); + _pairWrite = new TlsRecordProtection(suite, trafficSecret); + _pairRead = new TlsRecordProtection(suite, trafficSecret); + + _plaintext = new byte[Size]; + _ciphertext = new byte[Size]; + _opened = new byte[Size]; + RandomNumberGenerator.Fill(_plaintext); + + // A real outer record header: application_data, TLS 1.2 legacy version, ciphertext length. + int recordLength = Size + TlsCipherSuite.TagLength; + _header[0] = (byte)TlsContentType.ApplicationData; + _header[1] = 3; + _header[2] = 3; + _header[3] = (byte)(recordLength >> 8); + _header[4] = (byte)recordLength; + } + + [GlobalCleanup] + public void Cleanup() + { + _protect.Dispose(); + _pairWrite.Dispose(); + _pairRead.Dispose(); + } + + /// Seal one record. No allocation: every buffer is preallocated. + [Benchmark] + [BenchmarkCategory("Protect")] + public void Protect() => _protect.Protect(_plaintext, _ciphertext, _tag, _header); + + /// Seal and open one record with a matched pair, so the sequence numbers stay aligned. + [Benchmark] + [BenchmarkCategory("RoundTrip")] + public void RoundTrip() + { + _pairWrite.Protect(_plaintext, _ciphertext, _tag, _header); + _pairRead.Unprotect(_ciphertext, _tag, _opened, _header); + } +} diff --git a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs new file mode 100644 index 0000000..d3f5c06 --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs @@ -0,0 +1,231 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Steady-state throughput of over a real loopback TCP socket. +/// +/// +/// +/// runs the same traffic over memory, which measures the +/// AEAD and the framing and nothing else. What it cannot see is the cost this layer actually +/// controls: how many times it goes to the transport. A record is a 5-byte header and a body, and +/// a layer that asks for those separately pays two reads per record; a 64 KiB write that becomes +/// four records and four writes pays four times to satisfy one caller. Against memory those are +/// virtual calls and cost nothing. Against a socket they are syscalls. +/// +/// +/// So: a real connected socket pair on loopback, 1 MiB per operation, which makes the +/// Allocated column read as bytes allocated per MiB transferred. It is still loopback and +/// not a network — no bandwidth-delay product, no loss — so it measures the syscall and copy +/// cost, which is the part this code decides. +/// +/// +/// Write_1MiB writes to a peer that does nothing but drain. RoundTrip_1MiB uses a +/// peer that echoes the bytes back verbatim: the client's read protection is a second instance +/// built from the same traffic secret, so it opens the very records it sealed, in order, with the +/// sequence numbers staying in lockstep. Reading runs concurrently with writing because it has +/// to — a megabyte does not fit in a socket buffer, and a write-then-read client deadlocks +/// against an echo peer that is blocked writing back. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class RealityTlsSocketBenchmark +{ + private class Config : ManualConfig + { + public Config() => + AddJob(Job.ShortRun.WithIterationCount(5).WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const int Payload = 1024 * 1024; + private const ushort Aes128Gcm = 0x1301; + + private byte[] _data = null!; + private byte[] _readBuffer = null!; + + private Socket _listener = null!; + private Peer _drain = null!; + private Peer _echo = null!; + + private RealityTlsStream _sink = null!; + private RealityTlsStream _client = null!; + + /// One end of a loopback pair, plus the task servicing it. + private sealed class Peer : IDisposable + { + private readonly CancellationTokenSource _stopping = new(); + + public Peer(Socket client, Socket server, bool echo) + { + Client = client; + Server = server; + Service = ServiceAsync(server, echo, _stopping.Token); + } + + public Socket Client { get; } + public Socket Server { get; } + public Task Service { get; } + + private static async Task ServiceAsync(Socket server, bool echo, CancellationToken stopping) + { + byte[] buffer = new byte[64 * 1024]; + + try + { + while (!stopping.IsCancellationRequested) + { + int read = await server.ReceiveAsync(buffer, SocketFlags.None, stopping); + if (read == 0) + return; + + if (echo) + await SendAllAsync(server, buffer.AsMemory(0, read), stopping); + } + } + catch (OperationCanceledException) + { + // The benchmark is over; the peer is meant to stop here. + } + catch (SocketException) + { + // The client end was closed first, which is how this ends in practice. + } + } + + private static async Task SendAllAsync(Socket socket, ReadOnlyMemory data, CancellationToken stopping) + { + while (!data.IsEmpty) + { + int sent = await socket.SendAsync(data, SocketFlags.None, stopping); + data = data[sent..]; + } + } + + public void Dispose() + { + _stopping.Cancel(); + + try + { + Client.Dispose(); + Server.Dispose(); + } + catch (SocketException) + { + // Already torn down. + } + + _stopping.Dispose(); + } + } + + private Peer Connect(bool echo) + { + var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + client.Connect((IPEndPoint)_listener.LocalEndPoint!); + Socket server = _listener.Accept(); + + // Nagle would coalesce our writes for us and measure the kernel's batching instead of + // ours, which is the opposite of the point. + client.NoDelay = true; + server.NoDelay = true; + + return new Peer(client, server, echo); + } + + [GlobalSetup] + public void Setup() + { + TlsCipherSuite suite = TlsCipherSuite.FromId(Aes128Gcm)!; + + Span sinkSecret = stackalloc byte[suite.HashLength]; + Span pairSecret = stackalloc byte[suite.HashLength]; + RandomNumberGenerator.Fill(sinkSecret); + RandomNumberGenerator.Fill(pairSecret); + + _data = new byte[Payload]; + RandomNumberGenerator.Fill(_data); + _readBuffer = new byte[TlsRecordStream.MaxPlaintext]; + + _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + _listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + _listener.Listen(2); + + _drain = Connect(echo: false); + _echo = Connect(echo: true); + + // One NetworkStream per socket, shared by the record layer and the stream above it: two + // instances over one socket would be two buffers and two disposal paths for one endpoint. + var drainTransport = new NetworkStream(_drain.Client, ownsSocket: false); + var sinkRecords = new TlsRecordStream(drainTransport) + { + Write = new TlsRecordProtection(suite, sinkSecret) + }; + _sink = new RealityTlsStream(drainTransport, sinkRecords, []); + + var echoTransport = new NetworkStream(_echo.Client, ownsSocket: false); + var clientRecords = new TlsRecordStream(echoTransport) + { + Write = new TlsRecordProtection(suite, pairSecret), + Read = new TlsRecordProtection(suite, pairSecret) + }; + _client = new RealityTlsStream(echoTransport, clientRecords, []); + } + + [GlobalCleanup] + public void Cleanup() + { + _sink.Dispose(); + _client.Dispose(); + _drain.Dispose(); + _echo.Dispose(); + _listener.Dispose(); + } + + /// Seal, frame and send 1 MiB — 64 full-size records — to a peer that only drains. + [Benchmark] + [BenchmarkCategory("Write")] + public async Task Write_1MiB() => + await _sink.WriteAsync(_data.AsMemory(), CancellationToken.None); + + /// Send 1 MiB and read the same megabyte back off the wire. + [Benchmark] + [BenchmarkCategory("RoundTrip")] + public async Task RoundTrip_1MiB() + { + Task reading = ReadAsync(); + + await _client.WriteAsync(_data.AsMemory(), CancellationToken.None); + + return await reading; + + async Task ReadAsync() + { + int total = 0; + while (total < Payload) + { + int read = await _client.ReadAsync(_readBuffer.AsMemory(), CancellationToken.None); + if (read == 0) + throw new InvalidOperationException("The peer closed the connection early."); + + total += read; + } + + return total; + } + } +} diff --git a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs new file mode 100644 index 0000000..edf91e2 --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// End-to-end steady-state throughput of over an in-memory +/// transport, so what is measured is our framing and our allocations — no socket, no kernel, no +/// second process. +/// +/// +/// +/// Every operation moves exactly 1 MiB, which makes the Allocated column read directly as +/// bytes allocated per MiB transferred. That is the point of this benchmark: +/// TlsRecordStream.WriteAsync allocates a fresh record buffer and a fresh scratch +/// buffer for every record, and RealityTlsStream.FillAsync copies every inbound record out +/// of the record layer's buffer with ToArray. At 16 KiB per record that is three +/// per-record heap allocations that a pooled implementation would not make, and this is the number +/// that says whether removing them is worth the change. +/// +/// +/// Write_1MiB writes into and isolates the seal-and-frame cost. +/// RoundTrip_1MiB writes into a recycled and reads the same +/// megabyte back out, so (RoundTrip − Write) approximates the read path. The writer and reader +/// hold a matched pair of record protections and are never rebuilt, so their record sequence +/// numbers stay in lockstep across the whole run — which is what makes a persistent, allocation- +/// free harness possible at all. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class RealityTlsStreamBenchmark +{ + private class Config : ManualConfig + { + public Config() => + AddJob(Job.ShortRun.WithIterationCount(5).WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const int Payload = 1024 * 1024; + private const ushort Aes128Gcm = 0x1301; + + private byte[] _data = null!; + private byte[] _readBuffer = null!; + + private MemoryStream _wire = null!; + private RealityTlsStream _sink = null!; + private RealityTlsStream _writer = null!; + private RealityTlsStream _reader = null!; + + [GlobalSetup] + public void Setup() + { + TlsCipherSuite suite = TlsCipherSuite.FromId(Aes128Gcm)!; + + Span sinkSecret = stackalloc byte[suite.HashLength]; + Span pairSecret = stackalloc byte[suite.HashLength]; + RandomNumberGenerator.Fill(sinkSecret); + RandomNumberGenerator.Fill(pairSecret); + + _data = new byte[Payload]; + RandomNumberGenerator.Fill(_data); + _readBuffer = new byte[TlsRecordStream.MaxPlaintext]; + + var sinkRecords = new TlsRecordStream(Stream.Null) + { + Write = new TlsRecordProtection(suite, sinkSecret) + }; + _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. + _wire = new MemoryStream(Payload + (128 * 1024)); + + var writerRecords = new TlsRecordStream(_wire) + { + Write = new TlsRecordProtection(suite, pairSecret) + }; + var readerRecords = new TlsRecordStream(_wire) + { + Read = new TlsRecordProtection(suite, pairSecret) + }; + + _writer = new RealityTlsStream(_wire, writerRecords, []); + _reader = new RealityTlsStream(_wire, readerRecords, []); + } + + [GlobalCleanup] + public void Cleanup() + { + _sink.Dispose(); + _writer.Dispose(); + _reader.Dispose(); + _wire.Dispose(); + } + + /// Seal and frame 1 MiB — 64 full-size records — into a discarding transport. + [Benchmark] + [BenchmarkCategory("Write")] + public ValueTask Write_1MiB() => _sink.WriteAsync(_data.AsMemory(), CancellationToken.None); + + /// Seal 1 MiB into memory and read the same megabyte back out. + [Benchmark] + [BenchmarkCategory("RoundTrip")] + public async Task RoundTrip_1MiB() + { + _wire.Position = 0; + _wire.SetLength(0); + + await _writer.WriteAsync(_data.AsMemory(), CancellationToken.None); + + _wire.Position = 0; + int total = 0; + while (total < Payload) + { + int read = await _reader.ReadAsync(_readBuffer.AsMemory(), CancellationToken.None); + if (read == 0) + throw new InvalidOperationException("The in-memory transport ended early."); + + total += read; + } + + return total; + } +} diff --git a/QuickProxyNet.Benchmarks/Sha224Benchmark.cs b/QuickProxyNet.Benchmarks/Sha224Benchmark.cs new file mode 100644 index 0000000..ba2b298 --- /dev/null +++ b/QuickProxyNet.Benchmarks/Sha224Benchmark.cs @@ -0,0 +1,99 @@ +using System; +using System.Security.Cryptography; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Scalar vs Vector128-schedule SHA-224 on the two shapes that matter: a password-sized +/// input (the actual Trojan use case — hashed once per connection) and a 64 KB bulk +/// input where per-block wins would compound. BCL SHA-256 (OS crypto, SHA-NI capable) +/// is included as a hardware-acceleration reference point. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class Sha224Benchmark +{ + /// + /// by default; set QPN_BENCH_LONG=1 for a multi-launch + /// job whose reported error is small enough to defend a per-block timing claim. + /// + private class Config : ManualConfig + { + public Config() + { + Job job = Environment.GetEnvironmentVariable("QPN_BENCH_LONG") == "1" + ? Job.Default.WithLaunchCount(3).WithWarmupCount(5).WithIterationCount(20) + : Job.ShortRun; + + AddJob(job.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + } + + private readonly byte[] _password = "correct-horse-battery-staple"u8.ToArray(); // 28 bytes + private readonly byte[] _large = new byte[64 * 1024]; + private readonly byte[] _digest = new byte[32]; + + public Sha224Benchmark() => new Random(42).NextBytes(_large); + + // === Password-sized input (~28 bytes) — the real Trojan call shape === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Password")] + public byte Password_Scalar() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHashScalar(_password, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("Password")] + public byte Password_Vector128Schedule() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHash(_password, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("Password")] + public byte Password_BclSha256_Reference() + { + SHA256.HashData(_password, _digest); + return _digest[0]; + } + + // === 64 KB bulk input — where SIMD/hardware SHA is supposed to pay off === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Bulk64K")] + public byte Bulk_Scalar() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHashScalar(_large, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("Bulk64K")] + public byte Bulk_Vector128Schedule() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHash(_large, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("Bulk64K")] + public byte Bulk_BclSha256_Reference() + { + SHA256.HashData(_large, _digest); + return _digest[0]; + } +} diff --git a/QuickProxyNet.Benchmarks/Sha256CompressionBenchmark.cs b/QuickProxyNet.Benchmarks/Sha256CompressionBenchmark.cs new file mode 100644 index 0000000..83c12c7 --- /dev/null +++ b/QuickProxyNet.Benchmarks/Sha256CompressionBenchmark.cs @@ -0,0 +1,242 @@ +using System; +using System.Buffers.Binary; +using System.Runtime.Intrinsics; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// A/B for the SHA-2/32 compression function: the previous implementation, whose round loop +/// shifted the eight state words with explicit assignments and indexed the schedule and the +/// round constants through bounds-checked spans, against the current +/// , which unrolls eight rounds so the shift becomes a renaming and +/// reaches both arrays through Unsafe.Add. +/// +/// +/// Interleaved in one process, for the same reason as : run to +/// run, this machine moves untouched benchmarks by ~10%, so cross-run deltas of this size +/// cannot be read. The BCL is included only as a reference point for how far a managed +/// implementation is from OS crypto — it is not a baseline anything is expected to beat. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class Sha256CompressionBenchmark +{ + private class Config : ManualConfig + { + public Config() + { + Job job = Environment.GetEnvironmentVariable("QPN_BENCH_LONG") == "1" + ? Job.Default.WithLaunchCount(3).WithWarmupCount(5).WithIterationCount(20) + : Job.ShortRun; + + AddJob(job.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + } + + private readonly byte[] _password = "correct-horse-battery-staple"u8.ToArray(); // 28 bytes, 1 block + private readonly byte[] _large = new byte[64 * 1024]; // 1024 blocks + + public Sha256CompressionBenchmark() => new Random(42).NextBytes(_large); + + [GlobalSetup] + public void Setup() + { + Span mine = stackalloc byte[Sha224.HashSize]; + Span legacy = stackalloc byte[Sha224.HashSize]; + + foreach (byte[] input in new[] { _password, _large }) + { + Sha224.ComputeHash(input, mine); + LegacySha224.ComputeHash(input, legacy); + if (!mine.SequenceEqual(legacy)) + throw new InvalidOperationException("SHA-224 implementations disagree."); + } + } + + // === One block: the real Trojan call shape, where fixed overhead shows up === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("OneBlock")] + public byte OneBlock_LegacyRoundLoop() + { + Span digest = stackalloc byte[Sha224.HashSize]; + LegacySha224.ComputeHash(_password, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("OneBlock")] + public byte OneBlock_UnrolledRounds() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHash(_password, digest); + return digest[0]; + } + + // === 1024 blocks: isolates the per-block compression cost from fixed overhead === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Bulk64K")] + public byte Bulk_LegacyRoundLoop() + { + Span digest = stackalloc byte[Sha224.HashSize]; + LegacySha224.ComputeHash(_large, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("Bulk64K")] + public byte Bulk_UnrolledRounds() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHash(_large, digest); + return digest[0]; + } + + [Benchmark] + [BenchmarkCategory("Bulk64K")] + public byte Bulk_BclSha256_Reference() + { + Span digest = stackalloc byte[32]; + System.Security.Cryptography.SHA256.HashData(_large, digest); + return digest[0]; + } +} + +/// +/// The superseded SHA-224, verbatim. Benchmark-only reference implementation. +/// +internal static class LegacySha224 +{ + private const int HashSize = 28; + private const int BlockSize = 64; + + private static ReadOnlySpan K => + [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + + public static void ComputeHash(ReadOnlySpan data, Span destination) + => ComputeHashCore(data, destination, Vector128.IsHardwareAccelerated); + + private static void ComputeHashCore(ReadOnlySpan data, Span destination, bool vectorize) + { + Span h = + [ + 0xc1059ed8, 0x367cd507, 0x3070dd17, 0xf70e5939, + 0xffc00b31, 0x68581511, 0x64f98fa7, 0xbefa4fa4 + ]; + + Span w = stackalloc uint[64]; + + ReadOnlySpan remaining = data; + while (remaining.Length >= BlockSize) + { + ProcessBlock(remaining, h, w, vectorize); + remaining = remaining.Slice(BlockSize); + } + + Span pad = stackalloc byte[2 * BlockSize]; + pad.Clear(); + remaining.CopyTo(pad); + pad[remaining.Length] = 0x80; + + int padded = remaining.Length + 1 + 8 <= BlockSize ? BlockSize : 2 * BlockSize; + BinaryPrimitives.WriteUInt64BigEndian(pad.Slice(padded - 8), (ulong)data.Length * 8); + + ProcessBlock(pad, h, w, vectorize); + if (padded == 2 * BlockSize) + ProcessBlock(pad.Slice(BlockSize), h, w, vectorize); + + for (int i = 0; i < HashSize / 4; i++) + BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(i * 4), h[i]); + } + + private static void ProcessBlock(ReadOnlySpan block, Span h, Span w, bool vectorize) + { + for (int i = 0; i < 16; i++) + w[i] = BinaryPrimitives.ReadUInt32BigEndian(block.Slice(i * 4)); + + if (vectorize && Vector128.IsHardwareAccelerated) + ExpandScheduleVector128(w); + else + ExpandScheduleScalar(w); + + uint a = h[0], b = h[1], c = h[2], d = h[3]; + uint e = h[4], f = h[5], g = h[6], hh = h[7]; + + for (int i = 0; i < 64; i++) + { + uint s1 = uint.RotateRight(e, 6) ^ uint.RotateRight(e, 11) ^ uint.RotateRight(e, 25); + uint ch = (e & f) ^ (~e & g); + uint t1 = hh + s1 + ch + K[i] + w[i]; + uint s0 = uint.RotateRight(a, 2) ^ uint.RotateRight(a, 13) ^ uint.RotateRight(a, 22); + uint maj = (a & b) ^ (a & c) ^ (b & c); + uint t2 = s0 + maj; + + hh = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + + h[0] += a; + h[1] += b; + h[2] += c; + h[3] += d; + h[4] += e; + h[5] += f; + h[6] += g; + h[7] += hh; + } + + private static void ExpandScheduleScalar(Span w) + { + for (int i = 16; i < 64; i++) + { + uint s0 = uint.RotateRight(w[i - 15], 7) ^ uint.RotateRight(w[i - 15], 18) ^ (w[i - 15] >> 3); + w[i] = w[i - 16] + s0 + w[i - 7] + Sigma1(w[i - 2]); + } + } + + private static void ExpandScheduleVector128(Span w) + { + for (int i = 16; i < 64; i += 4) + { + var wm15 = Vector128.Create(w.Slice(i - 15, 4)); + var s0 = RotateRight(wm15, 7) ^ RotateRight(wm15, 18) ^ (wm15 >>> 3); + var partial = Vector128.Create(w.Slice(i - 16, 4)) + s0 + + Vector128.Create(w.Slice(i - 7, 4)); + + uint w0 = partial.GetElement(0) + Sigma1(w[i - 2]); + uint w1 = partial.GetElement(1) + Sigma1(w[i - 1]); + w[i] = w0; + w[i + 1] = w1; + w[i + 2] = partial.GetElement(2) + Sigma1(w0); + w[i + 3] = partial.GetElement(3) + Sigma1(w1); + } + } + + private static Vector128 RotateRight(Vector128 v, int n) + => (v >>> n) | (v << (32 - n)); + + private static uint Sigma1(uint x) + => uint.RotateRight(x, 17) ^ uint.RotateRight(x, 19) ^ (x >> 10); +} diff --git a/QuickProxyNet.Benchmarks/TrojanBenchmark.cs b/QuickProxyNet.Benchmarks/TrojanBenchmark.cs new file mode 100644 index 0000000..d48b253 --- /dev/null +++ b/QuickProxyNet.Benchmarks/TrojanBenchmark.cs @@ -0,0 +1,94 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Perf comparisons for the Trojan hot paths: request-header build (including the +/// mandatory SHA-224 password hash) and share-link parsing. Each category compares a +/// naive baseline against the implementation used by the library. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class TrojanBenchmark +{ + private class Config : ManualConfig + { + public Config() => AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const string Password = "mysecretpassword"; + private const string ShareLink = + "trojan://mysecretpassword@cdn.example.com:8443?type=tcp&sni=real.example.com&alpn=h2%2Chttp%2F1.1&allowInsecure=1#my-node"; + + private readonly byte[] _buffer = new byte[512]; + + // === Request header build (SHA-224 hash + request layout) === + + // Baseline: hash to a byte[], format a hex string, assemble in a MemoryStream. + [Benchmark(Baseline = true)] + [BenchmarkCategory("Build")] + public int Build_MemoryStream() + { + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHash(Encoding.UTF8.GetBytes(Password), digest); + string hex = Convert.ToHexStringLower(digest); + + using var ms = new MemoryStream(96); + ms.Write(Encoding.ASCII.GetBytes(hex)); + ms.WriteByte(0x0D); + ms.WriteByte(0x0A); + ms.WriteByte(0x01); // CMD + ms.WriteByte(0x03); // ATYP domain + byte[] host = Encoding.UTF8.GetBytes("mc.example.com"); + ms.WriteByte((byte)host.Length); + ms.Write(host); + Span port = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(port, 25565); + ms.Write(port); + ms.WriteByte(0x0D); + ms.WriteByte(0x0A); + return ms.ToArray().Length; + } + + // Library approach: single span write, SHA-224 straight into the buffer, zero allocation. + [Benchmark] + [BenchmarkCategory("Build")] + public int Build_SpanBuild() => TrojanHelper.BuildRequest(_buffer, Password, "mc.example.com", 25565); + + // === Share-link parse === + + // Baseline: Uri + Dictionary from a naive Split of the query. + [Benchmark(Baseline = true)] + [BenchmarkCategory("Parse")] + public int Parse_UriPlusDictionary() + { + var uri = new Uri(ShareLink); + var q = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + int eq = pair.IndexOf('='); + if (eq > 0) + q[pair[..eq]] = Uri.UnescapeDataString(pair[(eq + 1)..]); + } + return q.Count + uri.Host.Length; + } + + // Library approach: single-pass span query scan. + [Benchmark] + [BenchmarkCategory("Parse")] + public int Parse_ShareLink() + { + var o = TrojanShareLink.Parse(ShareLink); + return o.Host.Length + (o.Alpn?.Count ?? 0); + } +} diff --git a/QuickProxyNet.Benchmarks/VlessBenchmark.cs b/QuickProxyNet.Benchmarks/VlessBenchmark.cs new file mode 100644 index 0000000..4715347 --- /dev/null +++ b/QuickProxyNet.Benchmarks/VlessBenchmark.cs @@ -0,0 +1,140 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Perf comparisons for the VLESS hot paths: UUID big-endian encoding, request-header +/// build, and share-link parsing. Each category compares a naive baseline against the +/// implementation used by the library. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class VlessBenchmark +{ + private class Config : ManualConfig + { + public Config() => AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + private const string ShareLink = + "vless://11223344-5566-7788-99aa-bbccddeeff00@cdn.example.com:8443?type=tcp&security=tls&sni=cdn.example.com&alpn=h2%2Chttp%2F1.1&fp=chrome#my-node"; + + private readonly byte[] _buffer = new byte[512]; + + // === UUID: canonical string -> 16 big-endian bytes === + + // Baseline: the common WRONG approach — ToByteArray() is mixed-endian AND allocates. + // Included to show both the correctness trap and the allocation cost. + [Benchmark(Baseline = true)] + [BenchmarkCategory("Uuid")] + public byte Uuid_GuidToByteArray_Buggy() + { + byte[] bytes = Guid.Parse(Uuid).ToByteArray(); + return bytes[0]; + } + + // Library approach: Guid.TryParse + TryWriteBytes(bigEndian) — correct, zero-alloc. + [Benchmark] + [BenchmarkCategory("Uuid")] + public byte Uuid_TryWriteBigEndian() + { + Span dest = stackalloc byte[16]; + UuidCodec.WriteBigEndian(Uuid, dest); + return dest[0]; + } + + // Hand-rolled hex parse — no Guid machinery at all. + [Benchmark] + [BenchmarkCategory("Uuid")] + public byte Uuid_ManualHex() + { + Span dest = stackalloc byte[16]; + ParseUuidHex(Uuid, dest); + return dest[0]; + } + + private static void ParseUuidHex(ReadOnlySpan id, Span dest) + { + int di = 0; + for (int i = 0; i < id.Length && di < 16; i++) + { + if (id[i] == '-') continue; + int hi = FromHex(id[i]); + int lo = FromHex(id[++i]); + dest[di++] = (byte)((hi << 4) | lo); + } + } + + private static int FromHex(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => 0 + }; + + // === Request header build === + + // Baseline: MemoryStream-based build, allocates the stream + ToArray(). + [Benchmark(Baseline = true)] + [BenchmarkCategory("Header")] + public int Header_MemoryStream() + { + using var ms = new MemoryStream(64); + ms.WriteByte(0x00); + ms.Write(Guid.Parse(Uuid).ToByteArray()); // (also the buggy order, but this is the naive baseline) + ms.WriteByte(0x00); + ms.WriteByte(0x01); + Span port = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(port, 25565); + ms.Write(port); + ms.WriteByte(0x02); + byte[] host = Encoding.UTF8.GetBytes("mc.example.com"); + ms.WriteByte((byte)host.Length); + ms.Write(host); + return ms.ToArray().Length; + } + + // Library approach: single span write, zero allocation. + [Benchmark] + [BenchmarkCategory("Header")] + public int Header_SpanBuild() => VlessHelper.BuildRequest(_buffer, Uuid, "mc.example.com", 25565); + + // === Share-link parse === + + // Baseline: Uri + Dictionary from a naive Split of the query. + [Benchmark(Baseline = true)] + [BenchmarkCategory("Parse")] + public int Parse_UriPlusDictionary() + { + var uri = new Uri(ShareLink); + var q = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + int eq = pair.IndexOf('='); + if (eq > 0) + q[pair[..eq]] = Uri.UnescapeDataString(pair[(eq + 1)..]); + } + return q.Count + uri.Host.Length; + } + + // Library approach: single-pass span query scan. + [Benchmark] + [BenchmarkCategory("Parse")] + public int Parse_ShareLink() + { + var o = VlessShareLink.Parse(ShareLink); + return o.Host.Length + (o.Alpn?.Count ?? 0); + } +} diff --git a/QuickProxyNet.Benchmarks/VmessBenchmark.cs b/QuickProxyNet.Benchmarks/VmessBenchmark.cs new file mode 100644 index 0000000..c3cb200 --- /dev/null +++ b/QuickProxyNet.Benchmarks/VmessBenchmark.cs @@ -0,0 +1,317 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// Perf comparisons for the VMess hot paths: share-link parsing and the full VMessAEAD +/// request-header build (cmdKey derivation, AuthID, command section and both AEAD seals). +/// Each category compares a naive baseline against the implementation used by the library. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class VmessBenchmark +{ + /// + /// by default (fast, but too noisy to defend a small delta). + /// Set QPN_BENCH_LONG=1 to switch to a multi-launch job with enough iterations + /// that the reported error/StdDev is meaningful — use that when a timing change has to + /// be claimed, not just observed. + /// + private class Config : ManualConfig + { + public Config() + { + Job job = Environment.GetEnvironmentVariable("QPN_BENCH_LONG") == "1" + ? Job.Default.WithLaunchCount(3).WithWarmupCount(5).WithIterationCount(20) + : Job.ShortRun; + + AddJob(job.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + } + + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + private const string TargetHost = "mc.example.com"; + private const int TargetPort = 25565; + + private const string Json = + """ + {"v":"2","ps":"my node","add":"cdn.example.com","port":"8443", + "id":"11223344-5566-7788-99aa-bbccddeeff00","aid":"0","scy":"aes-128-gcm", + "net":"tcp","type":"none","host":"","path":"","tls":"tls","sni":"real.example.com"} + """; + + private static readonly string ShareLink = + "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(Json)); + + private readonly byte[] _buffer = new byte[VmessRequest.MaxRequestSize]; + + // ============================ share-link parse ============================ + + // Baseline: decode to a string, hand the JSON to JsonDocument, then copy every + // property into a Dictionary before reading the handful of fields that matter. + [Benchmark(Baseline = true)] + [BenchmarkCategory("Parse")] + public int Parse_JsonDocumentPlusDictionary() + { + string payload = ShareLink["vmess://".Length..]; + byte[] json = Convert.FromBase64String(payload); + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + using (var document = JsonDocument.Parse(json)) + { + foreach (JsonProperty property in document.RootElement.EnumerateObject()) + map[property.Name] = property.Value.ToString(); + } + + string host = map["add"]; + int port = int.Parse(map["port"]); + string id = map["id"]; + int alterId = int.Parse(map["aid"]); + + return host.Length + port + id.Length + alterId; + } + + // Library approach: pooled base64 decode, a single EnumerateObject pass, and no + // intermediate dictionary. + [Benchmark] + [BenchmarkCategory("Parse")] + public int Parse_ShareLink() + { + VmessOptions options = VmessShareLink.Parse(ShareLink); + return options.Host.Length + options.Port + options.Id.Length + options.AlterId; + } + + // ---- cost attribution for the parse path ---- + + // Base64 decode alone, into a pooled buffer. + [Benchmark] + [BenchmarkCategory("Parse")] + public int Parse_Base64Only() + { + ReadOnlySpan payload = ShareLink.AsSpan()["vmess://".Length..]; + byte[] buffer = System.Buffers.ArrayPool.Shared.Rent((payload.Length + 3) / 4 * 3); + try + { + Convert.TryFromBase64Chars(payload, buffer, out int written); + return written; + } + finally + { + System.Buffers.ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + + // Decode plus JsonDocument construction, with no field access at all: the floor both + // the baseline and the library implementation are built on. + [Benchmark] + [BenchmarkCategory("Parse")] + public int Parse_JsonDocumentOnly() + { + ReadOnlySpan payload = ShareLink.AsSpan()["vmess://".Length..]; + byte[] buffer = System.Buffers.ArrayPool.Shared.Rent((payload.Length + 3) / 4 * 3); + try + { + Convert.TryFromBase64Chars(payload, buffer, out int written); + using var document = JsonDocument.Parse(buffer.AsMemory(0, written)); + return document.RootElement.GetPropertyCount(); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + + // ============================ request header build ============================ + + // Baseline: the same protocol steps written the obvious allocating way — a byte[] per + // intermediate value and a MemoryStream to assemble the envelope. + [Benchmark(Baseline = true)] + [BenchmarkCategory("Header")] + public int Header_NaiveAllocating() + { + byte[] cmdKey = new byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(Uuid, cmdKey); + + byte[] bodyKey = RandomNumberGenerator.GetBytes(16); + byte[] bodyIv = RandomNumberGenerator.GetBytes(16); + byte[] connectionNonce = RandomNumberGenerator.GetBytes(8); + byte[] random4 = RandomNumberGenerator.GetBytes(4); + byte[] padding = RandomNumberGenerator.GetBytes(RandomNumberGenerator.GetInt32(0, 16)); + + byte[] authId = new byte[VmessAuthId.Size]; + VmessAuthId.Create(cmdKey, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), random4, authId); + + // --- command section --- + using var command = new MemoryStream(96); + command.WriteByte(VmessRequest.Version); + command.Write(bodyIv); + command.Write(bodyKey); + command.WriteByte(0x2A); + command.WriteByte(VmessRequest.OptionChunkStream); + command.WriteByte((byte)((padding.Length << 4) | VmessRequest.SecurityAes128Gcm)); + command.WriteByte(0x00); + command.WriteByte(VmessRequest.CommandTcp); + + byte[] port = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(port, TargetPort); + command.Write(port); + + byte[] host = Encoding.UTF8.GetBytes(TargetHost); + command.WriteByte(0x02); + command.WriteByte((byte)host.Length); + command.Write(host); + command.Write(padding); + + byte[] data = command.ToArray(); + byte[] checksum = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(checksum, Fnv1a32.Compute(data)); + + byte[] plaintext = new byte[data.Length + 4]; + data.CopyTo(plaintext, 0); + checksum.CopyTo(plaintext, data.Length); + + // --- AEAD envelope --- + byte[] lengthKey = new byte[16]; + byte[] lengthNonce = new byte[12]; + byte[] payloadKey = new byte[16]; + byte[] payloadNonce = new byte[12]; + VmessKdf.Kdf16(cmdKey, "VMess Header AEAD Key_Length"u8, authId, connectionNonce, lengthKey); + VmessKdf.Kdf12(cmdKey, "VMess Header AEAD Nonce_Length"u8, authId, connectionNonce, lengthNonce); + VmessKdf.Kdf16(cmdKey, "VMess Header AEAD Key"u8, authId, connectionNonce, payloadKey); + VmessKdf.Kdf12(cmdKey, "VMess Header AEAD Nonce"u8, authId, connectionNonce, payloadNonce); + + byte[] lengthPlaintext = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(lengthPlaintext, (ushort)plaintext.Length); + + byte[] encryptedLength = new byte[2]; + byte[] lengthTag = new byte[16]; + using (var gcm = new AesGcm(lengthKey, 16)) + gcm.Encrypt(lengthNonce, lengthPlaintext, encryptedLength, lengthTag, authId); + + byte[] encryptedHeader = new byte[plaintext.Length]; + byte[] headerTag = new byte[16]; + using (var gcm = new AesGcm(payloadKey, 16)) + gcm.Encrypt(payloadNonce, plaintext, encryptedHeader, headerTag, authId); + + using var wire = new MemoryStream(VmessRequest.MaxRequestSize); + wire.Write(authId); + wire.Write(encryptedLength); + wire.Write(lengthTag); + wire.Write(connectionNonce); + wire.Write(encryptedHeader); + wire.Write(headerTag); + + return wire.ToArray().Length; + } + + // Library approach: stackalloc'd cmdKey and material, one pooled scratch buffer inside + // Build, and the sealed header written straight into the caller's span. + [Benchmark] + [BenchmarkCategory("Header")] + public int Header_Build() + { + Span cmdKey = stackalloc byte[VmessCmdKey.Size]; + Span scratch = stackalloc byte[VmessRequest.MaterialScratchSize]; + try + { + VmessCmdKey.Derive(Uuid, cmdKey); + VmessRequestMaterial material = VmessRequest.CreateMaterial(scratch); + + return VmessRequest.Build( + _buffer, + cmdKey, + material, + VmessRequest.OptionChunkStream, + VmessRequest.SecurityAes128Gcm, + VmessRequest.CommandTcp, + TargetHost, + TargetPort); + } + finally + { + CryptographicOperations.ZeroMemory(cmdKey); + CryptographicOperations.ZeroMemory(scratch); + } + } + + // ---- cost attribution for the header build ---- + // Header_Build is dominated by two pieces neither implementation can avoid; these + // isolate them so the ~1 µs of actual framing is not mistaken for the bottleneck. + + // One MD5 over 52 stack bytes. + [Benchmark] + [BenchmarkCategory("Header")] + public byte Header_CmdKeyOnly() + { + Span cmdKey = stackalloc byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(Uuid, cmdKey); + return cmdKey[0]; + } + + // AuthID: one KDF16 plus a single-block AES-ECB through Aes.Create(). + [Benchmark] + [BenchmarkCategory("Header")] + public byte Header_AuthIdOnly() + { + Span cmdKey = stackalloc byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(Uuid, cmdKey); + + Span authId = stackalloc byte[VmessAuthId.Size]; + VmessAuthId.Create(cmdKey, authId); + return authId[0]; + } + + // The AES-128-ECB single-block encrypt inside the AuthID: Aes.Create(), key set, + // one block — isolates what a cached/reused Aes instance could save. + [Benchmark] + [BenchmarkCategory("Header")] + public byte Header_AesEcbOnly() + { + Span block = stackalloc byte[16]; + byte[] key = new byte[16]; + try + { + using var aes = System.Security.Cryptography.Aes.Create(); + aes.Key = key; + aes.EncryptEcb(block, block, PaddingMode.None); + return block[0]; + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + // The four request-header KDF derivations: each is a 4-deep nested HMAC-SHA256. + [Benchmark] + [BenchmarkCategory("Header")] + public byte Header_KdfOnly() + { + Span cmdKey = stackalloc byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(Uuid, cmdKey); + + Span authId = stackalloc byte[16]; + Span nonce = stackalloc byte[8]; + Span key = stackalloc byte[16]; + Span iv = stackalloc byte[12]; + + VmessKdf.Kdf16(cmdKey, "VMess Header AEAD Key_Length"u8, authId, nonce, key); + VmessKdf.Kdf12(cmdKey, "VMess Header AEAD Nonce_Length"u8, authId, nonce, iv); + VmessKdf.Kdf16(cmdKey, "VMess Header AEAD Key"u8, authId, nonce, key); + VmessKdf.Kdf12(cmdKey, "VMess Header AEAD Nonce"u8, authId, nonce, iv); + + return (byte)(key[0] ^ iv[0]); + } +} diff --git a/QuickProxyNet.Benchmarks/VmessKdfBenchmark.cs b/QuickProxyNet.Benchmarks/VmessKdfBenchmark.cs new file mode 100644 index 0000000..0a10bc5 --- /dev/null +++ b/QuickProxyNet.Benchmarks/VmessKdfBenchmark.cs @@ -0,0 +1,361 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.Intrinsics; +using System.Security.Cryptography; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// A/B for the VMessAEAD KDF: the shipping , which delegates the +/// innermost (seed-keyed) HMAC to the platform's , against +/// , which runs the managed and resumes +/// from precomputed ipad/opad midstates. +/// +/// +/// +/// The midstate variant is not in the library: it was implemented, measured, and lost. +/// It lives here so the comparison can be re-run — in particular on a CPU with SHA-NI, where +/// the platform side gets faster still and the gap should widen. See the remarks on +/// for the full finding. +/// +/// +/// Both variants run in the same process and the same BenchmarkDotNet run, alternating. That +/// matters more than usual here: measured across separate runs this machine moves +/// untouched benchmarks by ~10%, which is larger than the effect being measured. A ratio from +/// one interleaved run is not exposed to that drift. +/// +/// +/// The two categories are weighted differently by real traffic. One VMess connection performs +/// five one-element derivations (one in VmessAuthId, four in VmessResponse) and +/// four three-element ones, so SingleElementKdf must be multiplied by five and +/// RequestHeaderKdf counted once when adding them up. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class VmessKdfBenchmark +{ + /// + /// by default; set QPN_BENCH_LONG=1 for a multi-launch + /// job whose reported error is small enough to defend the ratio. + /// + private class Config : ManualConfig + { + public Config() + { + Job job = Environment.GetEnvironmentVariable("QPN_BENCH_LONG") == "1" + ? Job.Default.WithLaunchCount(3).WithWarmupCount(5).WithIterationCount(20) + : Job.ShortRun; + + AddJob(job.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + } + + private readonly byte[] _cmdKey = new byte[16]; + private readonly byte[] _authId = new byte[16]; + private readonly byte[] _nonce = new byte[8]; + private readonly byte[] _key = new byte[16]; + private readonly byte[] _iv = new byte[12]; + + private static ReadOnlySpan KeyLengthLabel => "VMess Header AEAD Key_Length"u8; + private static ReadOnlySpan NonceLengthLabel => "VMess Header AEAD Nonce_Length"u8; + private static ReadOnlySpan KeyLabel => "VMess Header AEAD Key"u8; + private static ReadOnlySpan NonceLabel => "VMess Header AEAD Nonce"u8; + private static ReadOnlySpan AuthIdLabel => "AES Auth ID Encryption"u8; + + [GlobalSetup] + public void Setup() + { + new Random(1234).NextBytes(_cmdKey); + new Random(5678).NextBytes(_authId); + new Random(9012).NextBytes(_nonce); + + // A benchmark that measures two implementations of different functions is worthless. + Span shipping = stackalloc byte[16]; + Span midstate = stackalloc byte[16]; + + VmessKdf.Kdf16(_cmdKey, KeyLabel, _authId, _nonce, shipping); + MidstateVmessKdf.Kdf16(_cmdKey, KeyLabel, _authId, _nonce, midstate); + if (!shipping.SequenceEqual(midstate)) + throw new InvalidOperationException("KDF variants disagree on the 3-element path."); + + VmessKdf.Kdf16(_cmdKey, AuthIdLabel, shipping); + MidstateVmessKdf.Kdf16(_cmdKey, AuthIdLabel, midstate); + if (!shipping.SequenceEqual(midstate)) + throw new InvalidOperationException("KDF variants disagree on the 1-element path."); + } + + // === The four request-header derivations: once per connection === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("RequestHeaderKdf")] + public byte RequestHeader_PlatformIncrementalHash() + { + VmessKdf.Kdf16(_cmdKey, KeyLengthLabel, _authId, _nonce, _key); + VmessKdf.Kdf12(_cmdKey, NonceLengthLabel, _authId, _nonce, _iv); + VmessKdf.Kdf16(_cmdKey, KeyLabel, _authId, _nonce, _key); + VmessKdf.Kdf12(_cmdKey, NonceLabel, _authId, _nonce, _iv); + return (byte)(_key[0] ^ _iv[0]); + } + + [Benchmark] + [BenchmarkCategory("RequestHeaderKdf")] + public byte RequestHeader_MidstateSeedHmac() + { + MidstateVmessKdf.Kdf16(_cmdKey, KeyLengthLabel, _authId, _nonce, _key); + MidstateVmessKdf.Kdf12(_cmdKey, NonceLengthLabel, _authId, _nonce, _iv); + MidstateVmessKdf.Kdf16(_cmdKey, KeyLabel, _authId, _nonce, _key); + MidstateVmessKdf.Kdf12(_cmdKey, NonceLabel, _authId, _nonce, _iv); + return (byte)(_key[0] ^ _iv[0]); + } + + // === A single one-element derivation: five per connection === + + [Benchmark(Baseline = true)] + [BenchmarkCategory("SingleElementKdf")] + public byte Single_PlatformIncrementalHash() + { + VmessKdf.Kdf16(_cmdKey, AuthIdLabel, _key); + return _key[0]; + } + + [Benchmark] + [BenchmarkCategory("SingleElementKdf")] + public byte Single_MidstateSeedHmac() + { + MidstateVmessKdf.Kdf16(_cmdKey, AuthIdLabel, _key); + return _key[0]; + } +} + +/// +/// HMAC-SHA256 keyed by the constant VMessAEAD KDF seed, with the ipad and opad blocks folded +/// into precomputed SHA-256 midstates. Benchmark-only: see . +/// +internal static class MidstateSeedHmac +{ + internal const int DigestSize = Sha256Core.DigestSize; + internal const int BlockSize = Sha256Core.BlockSize; + + private static ReadOnlySpan Seed => "VMess AEAD KDF"u8; + + // SHA-256 state after compressing (seed ‖ zeros) ⊕ ipad and ⊕ opad respectively. Derived + // from the seed rather than pasted in as literals, so a typo cannot change the protocol. + private static readonly uint[] InnerMidstate = PadMidstate(0x36); + private static readonly uint[] OuterMidstate = PadMidstate(0x5C); + + private static uint[] PadMidstate(byte pad) + { + Span block = stackalloc byte[BlockSize]; + block.Clear(); + Seed.CopyTo(block); + for (int i = 0; i < BlockSize; i++) + block[i] ^= pad; + + uint[] state = new uint[Sha256Core.StateWords]; + Sha256Core.Sha256Iv.CopyTo(state); + + Span schedule = stackalloc uint[Sha256Core.ScheduleWords]; + Sha256Core.Absorb(state, block, schedule, Vector128.IsHardwareAccelerated); + return state; + } + + /// + /// Loads the inner (ipad) midstate and absorbs — whole + /// 64-byte blocks the caller knows will lead every message it is about to hash. + /// + internal static void BeginInner(Span state, ReadOnlySpan prefixBlocks, Span schedule) + { + InnerMidstate.CopyTo(state); + Sha256Core.Absorb(state, prefixBlocks, schedule, Vector128.IsHardwareAccelerated); + } + + /// + /// Completes HMAC(seed, prefix ‖ tail) from a state produced by + /// that has already absorbed . + /// + internal static void FinishFromPrefix( + ReadOnlySpan innerState, ulong absorbedBytes, ReadOnlySpan tail, + Span destination, Span schedule) + { + bool vectorize = Vector128.IsHardwareAccelerated; + + Span state = stackalloc uint[Sha256Core.StateWords]; + innerState.CopyTo(state); + Sha256Core.Finish(state, tail, absorbedBytes + (ulong)tail.Length, schedule, vectorize); + + Span innerDigest = stackalloc byte[DigestSize]; + Sha256Core.WriteDigest(state, innerDigest, DigestSize); + + // Outer pass: resume from the opad midstate and absorb only the 32-byte inner digest, + // which pads into a single block. + OuterMidstate.CopyTo(state); + Sha256Core.Finish(state, innerDigest, BlockSize + (ulong)DigestSize, schedule, vectorize); + Sha256Core.WriteDigest(state, destination, DigestSize); + } + + internal static void ComputeHash(ReadOnlySpan message, Span destination) + { + Span schedule = stackalloc uint[Sha256Core.ScheduleWords]; + Span state = stackalloc uint[Sha256Core.StateWords]; + InnerMidstate.CopyTo(state); + FinishFromPrefix(state, BlockSize, message, destination, schedule); + } +} + +/// +/// The VMessAEAD KDF with every constant prefix folded into a resumable SHA-256 midstate: +/// the seed's ipad/opad blocks (static) and the innermost path element's ipad/opad blocks +/// (constant for one derivation). Cuts a four-element derivation from 46 block compressions +/// to 24. Benchmark-only reference implementation — see for +/// why it is not the shipping one. +/// +internal static class MidstateVmessKdf +{ + private const int BlockSize = Sha256Core.BlockSize; + private const int DigestSize = Sha256Core.DigestSize; + private const int StateWords = Sha256Core.StateWords; + private const int PadPairSize = 2 * BlockSize; + private const int MaxLevels = 3; + private const int MaxStackInput = 256; + + public static void Kdf16(ReadOnlySpan key, ReadOnlySpan label, Span destination) + => Derive(key, label, default, default, levels: 1, destination, 16); + + public static void Kdf16( + ReadOnlySpan key, ReadOnlySpan label, + ReadOnlySpan arg1, ReadOnlySpan arg2, Span destination) + => Derive(key, label, arg1, arg2, levels: 3, destination, 16); + + public static void Kdf12( + ReadOnlySpan key, ReadOnlySpan label, + ReadOnlySpan arg1, ReadOnlySpan arg2, Span destination) + => Derive(key, label, arg1, arg2, levels: 3, destination, 12); + + private static void Derive( + ReadOnlySpan key, + ReadOnlySpan label, ReadOnlySpan arg1, ReadOnlySpan arg2, + int levels, Span destination, int length) + { + // One message-schedule scratch buffer for the whole derivation: a derivation drives + // ~20 block-compression entry points, and a stackalloc each would pay 256 bytes of + // implicit zeroing every time. + Span schedule = stackalloc uint[Sha256Core.ScheduleWords]; + + Span pads = stackalloc byte[MaxLevels * PadPairSize]; + InitLevel(pads, default, 0, label, schedule); + + // The innermost level folded into two resumable seed-HMAC midstates: one that has + // absorbed ipadSeed ‖ labelIpad, one that has absorbed ipadSeed ‖ labelOpad. + Span labelStates = stackalloc uint[2 * StateWords]; + MidstateSeedHmac.BeginInner(labelStates[..StateWords], pads[..BlockSize], schedule); + MidstateSeedHmac.BeginInner(labelStates[StateWords..], pads.Slice(BlockSize, BlockSize), schedule); + + if (levels == 3) + { + InitLevel(pads, labelStates, 1, arg1, schedule); + InitLevel(pads, labelStates, 2, arg2, schedule); + } + + Span full = stackalloc byte[DigestSize]; + Compute(pads, labelStates, levels, key, full, schedule); + full[..length].CopyTo(destination[..length]); + + CryptographicOperations.ZeroMemory(full); + CryptographicOperations.ZeroMemory(pads); + labelStates.Clear(); + } + + private static void InitLevel( + Span pads, ReadOnlySpan labelStates, int level, ReadOnlySpan key, + Span schedule) + { + Span normalizedKey = stackalloc byte[BlockSize]; + normalizedKey.Clear(); + + if (key.Length > BlockSize) + Compute(pads, labelStates, level, key, normalizedKey[..DigestSize], schedule); + else + key.CopyTo(normalizedKey); + + Xor(normalizedKey, 0x36, pads.Slice(level * PadPairSize, BlockSize)); + Xor(normalizedKey, 0x5C, pads.Slice(level * PadPairSize + BlockSize, BlockSize)); + + CryptographicOperations.ZeroMemory(normalizedKey); + } + + // XORs a whole 64-byte HMAC pad in eight 64-bit chunks instead of byte by byte. + private static void Xor(ReadOnlySpan source, byte pad, Span destination) + { + ulong mask = pad * 0x0101010101010101UL; + for (int i = 0; i < BlockSize; i += 8) + { + BinaryPrimitives.WriteUInt64LittleEndian( + destination.Slice(i, 8), + BinaryPrimitives.ReadUInt64LittleEndian(source.Slice(i, 8)) ^ mask); + } + } + + private static void Compute( + ReadOnlySpan pads, ReadOnlySpan labelStates, int levels, + ReadOnlySpan message, Span destination, Span schedule) + { + if (levels <= 1) + { + if (levels == 0) + { + // Bare seed HMAC: only reached from InitLevel's oversized-key fallback for + // level 0, i.e. before the label midstates exist. + MidstateSeedHmac.ComputeHash(message, destination); + return; + } + + // Level 0's pads are already folded into labelStates, so both passes resume from + // a midstate that has absorbed 2 blocks and need no concatenation buffer at all. + Span level0Digest = stackalloc byte[DigestSize]; + MidstateSeedHmac.FinishFromPrefix( + labelStates[..StateWords], 2 * BlockSize, message, level0Digest, schedule); + MidstateSeedHmac.FinishFromPrefix( + labelStates[StateWords..], 2 * BlockSize, level0Digest, destination, schedule); + + CryptographicOperations.ZeroMemory(level0Digest); + return; + } + + int top = levels - 1; + ReadOnlySpan innerPad = pads.Slice(top * PadPairSize, BlockSize); + ReadOnlySpan outerPad = pads.Slice(top * PadPairSize + BlockSize, BlockSize); + + Span innerDigest = stackalloc byte[DigestSize]; + int innerLength = BlockSize + message.Length; + byte[] rented = innerLength > MaxStackInput ? ArrayPool.Shared.Rent(innerLength) : Array.Empty(); + Span innerInput = rented.Length != 0 ? rented : stackalloc byte[MaxStackInput]; + try + { + innerPad.CopyTo(innerInput); + message.CopyTo(innerInput[BlockSize..]); + Compute(pads, labelStates, top, innerInput[..innerLength], innerDigest, schedule); + } + finally + { + CryptographicOperations.ZeroMemory(innerInput[..innerLength]); + if (rented.Length != 0) + ArrayPool.Shared.Return(rented); + } + + Span outerInput = stackalloc byte[BlockSize + DigestSize]; + outerPad.CopyTo(outerInput); + innerDigest.CopyTo(outerInput[BlockSize..]); + Compute(pads, labelStates, top, outerInput, destination, schedule); + + CryptographicOperations.ZeroMemory(innerDigest); + CryptographicOperations.ZeroMemory(outerInput); + } +} diff --git a/QuickProxyNet.Benchmarks/VmessStreamBenchmark.cs b/QuickProxyNet.Benchmarks/VmessStreamBenchmark.cs new file mode 100644 index 0000000..b6ce204 --- /dev/null +++ b/QuickProxyNet.Benchmarks/VmessStreamBenchmark.cs @@ -0,0 +1,124 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; + +namespace QuickProxyNet.Benchmarks; + +/// +/// The VMess steady-state hot path: sealing and opening body chunks with +/// . The header runs once per connection; this path runs for +/// every data chunk for the life of the connection, so it is the number that matters +/// for throughput. +/// +/// +/// Seal_* writes to and isolates the encrypt + frame +/// cost. RoundTrip_* seals into a recycled and opens +/// the chunk back out, so (RoundTrip − Seal) approximates the open cost. Payloads are +/// 64 B (small interactive write) and 8174 B (the largest single-chunk plaintext this +/// implementation emits, i.e. one full 8 KB send buffer). +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[Config(typeof(Config))] +public class VmessStreamBenchmark +{ + private class Config : ManualConfig + { + public Config() => AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + } + + private const int SmallSize = 64; + private const int LargeSize = VmessStream.MaxSendPlaintextSize; // 8174 + + private static readonly byte[] ClientKey = MakePattern(0xC1, 16); + private static readonly byte[] ClientIv = MakePattern(0xC2, 16); + private static readonly byte[] ServerKey = MakePattern(0x51, 16); + private static readonly byte[] ServerIv = MakePattern(0x52, 16); + + private VmessStream _sealer = null!; + private VmessStream _loopWriter = null!; + private VmessStream _loopReader = null!; + private MemoryStream _wire = null!; + + private readonly byte[] _small = MakePattern(0xAB, SmallSize); + private readonly byte[] _large = MakePattern(0xCD, LargeSize); + private readonly byte[] _readBuffer = new byte[VmessStream.SendBufferSize]; + + 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; + } + + [GlobalSetup] + public void Setup() + { + _sealer = new VmessStream( + Stream.Null, ClientKey, ClientIv, ServerKey, ServerIv, + VmessSecurity.Aes128Gcm, leaveInnerOpen: true); + + _wire = new MemoryStream(64 * 1024); + _loopWriter = new VmessStream( + _wire, ClientKey, ClientIv, ServerKey, ServerIv, + VmessSecurity.Aes128Gcm, leaveInnerOpen: true); + // The reader's read direction mirrors the writer's write direction. + _loopReader = new VmessStream( + _wire, ServerKey, ServerIv, ClientKey, ClientIv, + VmessSecurity.Aes128Gcm, leaveInnerOpen: true); + } + + [GlobalCleanup] + public void Cleanup() + { + _sealer.Dispose(); + _loopWriter.Dispose(); + _loopReader.Dispose(); + _wire.Dispose(); + } + + // ============================ seal only ============================ + + [Benchmark] + [BenchmarkCategory("Seal")] + public ValueTask Seal_64B() => _sealer.WriteAsync(_small.AsMemory()); + + [Benchmark] + [BenchmarkCategory("Seal")] + public ValueTask Seal_8K() => _sealer.WriteAsync(_large.AsMemory()); + + // ======================== seal + open loopback ======================== + + [Benchmark] + [BenchmarkCategory("RoundTrip")] + public Task RoundTrip_64B() => RoundTrip(_small); + + [Benchmark] + [BenchmarkCategory("RoundTrip")] + public Task RoundTrip_8K() => RoundTrip(_large); + + private async Task RoundTrip(byte[] payload) + { + _wire.Position = 0; + _wire.SetLength(0); + await _loopWriter.WriteAsync(payload.AsMemory()); + + _wire.Position = 0; + int total = 0; + while (total < payload.Length) + { + int read = await _loopReader.ReadAsync(_readBuffer.AsMemory()); + if (read == 0) + throw new InvalidOperationException("Unexpected end of stream."); + total += read; + } + + return total; + } +} diff --git a/QuickProxyNet.Tests/ConnectTest.cs b/QuickProxyNet.Tests/ConnectTest.cs index b0f7bfa..039b89a 100644 --- a/QuickProxyNet.Tests/ConnectTest.cs +++ b/QuickProxyNet.Tests/ConnectTest.cs @@ -7,23 +7,19 @@ namespace QuickProxyNet.Tests; /// Set environment variables to run: /// HTTP_PROXY_URI = http://[user:pass@]host:port /// SOCKS5_PROXY_URI = socks5://[user:pass@]host:port -/// Tests are skipped if the variable is not set. +/// A test whose variable is not set reports as skipped — never as passed. /// public class ConnectTest { private const string TargetHost = "example.com"; private const int TargetPort = 80; - private static string? GetEnv(string name) => - Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : null; + private static string Env(string name) => Environment.GetEnvironmentVariable(name)!; - [Fact] + [EnvFact("HTTP_PROXY_URI")] public async Task HttpProxy_ConnectAndSendRequest() { - var proxyUrl = GetEnv("HTTP_PROXY_URI"); - if (proxyUrl is null) return; // skip: "HTTP_PROXY_URI not set"); - - var uri = new Uri(proxyUrl); + var uri = new Uri(Env("HTTP_PROXY_URI")); await using var stream = await Proxy.ConnectAsync(uri, TargetHost, TargetPort, TimeSpan.FromSeconds(10)); @@ -39,13 +35,10 @@ public async Task HttpProxy_ConnectAndSendRequest() Assert.StartsWith("HTTP/1.", response); } - [Fact] + [EnvFact("SOCKS5_PROXY_URI")] public async Task Socks5Proxy_ConnectAndSendRequest() { - var proxyUrl = GetEnv("SOCKS5_PROXY_URI"); - if (proxyUrl is null) return; // skip: "SOCKS5_PROXY_URI not set"); - - var uri = new Uri(proxyUrl); + var uri = new Uri(Env("SOCKS5_PROXY_URI")); await using var stream = await Proxy.ConnectAsync(uri, TargetHost, TargetPort, TimeSpan.FromSeconds(10)); @@ -60,11 +53,10 @@ public async Task Socks5Proxy_ConnectAndSendRequest() Assert.StartsWith("HTTP/1.", response); } - [Fact] + [AnyEnvFact("HTTP_PROXY_URI", "SOCKS5_PROXY_URI")] public async Task ExtensionMethod_ConnectThroughProxy() { - var proxyUrl = GetEnv("HTTP_PROXY_URI") ?? GetEnv("SOCKS5_PROXY_URI"); - if (proxyUrl is null) return; // skip: "No proxy URI set"); + 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, diff --git a/QuickProxyNet.Tests/Helpers/DuplexTestStream.cs b/QuickProxyNet.Tests/Helpers/DuplexTestStream.cs new file mode 100644 index 0000000..cee3d3f --- /dev/null +++ b/QuickProxyNet.Tests/Helpers/DuplexTestStream.cs @@ -0,0 +1,87 @@ +namespace QuickProxyNet.Tests.Helpers; + +/// +/// A bidirectional in-memory transport: reads drain a scripted inbound buffer, writes +/// accumulate in a list that stays readable after disposal. +/// +/// +/// caps how many bytes a single read may return, which +/// forces callers to loop and exercises ReadExactlyAsync-style reassembly. +/// +internal sealed class DuplexTestStream(byte[] inbound, int maxReadSize = int.MaxValue) : Stream +{ + private readonly List _outbound = []; + private int _position; + + /// Every byte written so far, in order. + public byte[] Written => [.. _outbound]; + + /// Bytes of the scripted inbound buffer not yet read. + public byte[] Unread => inbound[_position..]; + + /// How many times / ran. + public int DisposeCount { 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 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(Span buffer) + { + int count = Math.Min(Math.Min(buffer.Length, maxReadSize), inbound.Length - _position); + if (count <= 0) + return 0; + + 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(); + + public override void Write(ReadOnlySpan buffer) + { + foreach (byte b in buffer) + _outbound.Add(b); + } + + 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(); + + protected override void Dispose(bool disposing) + { + if (disposing) + DisposeCount++; + base.Dispose(disposing); + } +} diff --git a/QuickProxyNet.Tests/Helpers/FakeWebSocketServer.cs b/QuickProxyNet.Tests/Helpers/FakeWebSocketServer.cs new file mode 100644 index 0000000..d1e68a8 --- /dev/null +++ b/QuickProxyNet.Tests/Helpers/FakeWebSocketServer.cs @@ -0,0 +1,307 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet.Tests.Helpers; + +/// +/// An in-memory server end for the ws and httpupgrade transports: it answers the +/// HTTP upgrade and then speaks RFC 6455 frames. +/// +/// +/// The handshake response cannot be scripted up front the way +/// allows, because Sec-WebSocket-Accept is derived +/// from a key the client generates randomly inside ConnectAsync. So the response is +/// built lazily, on the first read after a complete request has been written — which is also +/// what a real server does. +/// +/// Server-to-client frames are unmasked and client-to-server frames must be masked; RFC 6455 +/// requires exactly that asymmetry, so decoding here doubles as an assertion that the client +/// masks its frames. +/// +/// +internal sealed class FakeWebSocketServer : Stream +{ + private const string WebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + private readonly List _clientBytes = []; + private readonly List _toClient = []; + private readonly List _payloadFromClient = []; + private readonly bool _framed; + + private int _position; + private bool _upgraded; + + /// Length of the client's HTTP request, fixed once the header block is complete. + private int _handshakeLength; + + /// How far into the client's bytes the frame decoder has consumed. + private int _decodeCursor; + + /// + /// True for ws (RFC 6455 framing after the 101); false for httpupgrade, where + /// the upgraded connection carries raw bytes. + /// + public FakeWebSocketServer(bool framed = true) => _framed = framed; + + /// Status line to answer the upgrade with. Override to test a refusal. + public string StatusLine { get; init; } = "HTTP/1.1 101 Switching Protocols"; + + /// When set, sent instead of the correct Sec-WebSocket-Accept. + public string? AcceptOverride { get; init; } + + /// When true, no Sec-WebSocket-Accept header is sent at all. + public bool OmitAccept { get; init; } + + /// + /// Bytes appended to the 101 response, simulating a server that pipelines tunnel data + /// immediately behind the header block. + /// + public byte[] PipelinedAfterHandshake { get; init; } = []; + + /// The raw HTTP upgrade request the client sent. + public string Request => Encoding.UTF8.GetString(_clientBytes.ToArray(), 0, _handshakeLength); + + /// Application payload received from the client, with framing removed. + public byte[] PayloadFromClient => [.. _payloadFromClient]; + + /// True once the upgrade response has been produced. + public bool Upgraded => _upgraded; + + /// Queues an application message for the client to read. + public void SendToClient(ReadOnlySpan payload) + { + if (_framed) + _toClient.AddRange(EncodeServerFrame(payload)); + else + _toClient.AddRange(payload); + } + + /// Queues a WebSocket close frame. + public void SendClose() + { + // 0x88 = FIN + close opcode, with a 2-byte status code payload (1000 = normal). + _toClient.AddRange([0x88, 0x02, 0x03, 0xE8]); + } + + public static byte[] EncodeServerFrame(ReadOnlySpan payload) + { + var frame = new List { 0x82 }; // FIN + binary opcode + + if (payload.Length < 126) + { + frame.Add((byte)payload.Length); + } + else if (payload.Length <= ushort.MaxValue) + { + frame.Add(126); + Span len = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(len, (ushort)payload.Length); + frame.AddRange(len); + } + else + { + frame.Add(127); + Span len = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64BigEndian(len, (ulong)payload.Length); + frame.AddRange(len); + } + + frame.AddRange(payload); + return [.. frame]; + } + + // ---- Stream ---- + + 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 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(Span buffer) + { + TryCompleteHandshake(); + + // Nothing is readable before the upgrade — a server does not leak tunnel bytes ahead + // of its own response, and neither should the queue a test filled in advance. + if (!_upgraded) + return 0; + + int count = Math.Min(buffer.Length, _toClient.Count - _position); + if (count <= 0) + return 0; + + for (int i = 0; i < count; i++) + buffer[i] = _toClient[_position + i]; + + _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(); + + public override void Write(ReadOnlySpan buffer) + { + _clientBytes.AddRange(buffer); + + if (_upgraded) + DecodeClientBytes(); + } + + 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(); + + // ---- handshake ---- + + private void TryCompleteHandshake() + { + if (_upgraded) + return; + + byte[] request = [.. _clientBytes]; + int end = IndexOf(request, "\r\n\r\n"u8); + if (end < 0) + return; + + _handshakeLength = end + 4; + _decodeCursor = _handshakeLength; + + var response = new StringBuilder(); + response.Append(StatusLine).Append("\r\n"); + + if (StatusLine.Contains("101")) + { + response.Append("Upgrade: websocket\r\nConnection: Upgrade\r\n"); + if (!OmitAccept) + response.Append("Sec-WebSocket-Accept: ") + .Append(AcceptOverride ?? ComputeAccept(Request)) + .Append("\r\n"); + } + + response.Append("\r\n"); + + // Inserted at the front: a test queues its application frames before ConnectAsync runs, + // but on the wire the header block necessarily comes first. + byte[] header = Encoding.UTF8.GetBytes(response.ToString()); + _toClient.InsertRange(0, PipelinedAfterHandshake); + _toClient.InsertRange(0, header); + _upgraded = true; + + // Anything written past the header block is already tunnel traffic. + if (_clientBytes.Count > _handshakeLength) + DecodeClientBytes(); + } + + private static string ComputeAccept(string request) + { + const string header = "Sec-WebSocket-Key:"; + int start = request.IndexOf(header, StringComparison.OrdinalIgnoreCase); + if (start < 0) + return "missing-key"; + + start += header.Length; + int end = request.IndexOf("\r\n", start, StringComparison.Ordinal); + string key = request[start..end].Trim(); + +#pragma warning disable CA5350 // RFC 6455 fixes SHA-1 as the handshake token. + byte[] digest = SHA1.HashData(Encoding.ASCII.GetBytes(key + WebSocketGuid)); +#pragma warning restore CA5350 + return Convert.ToBase64String(digest); + } + + /// + /// Consumes whole frames (or, for httpupgrade, raw bytes) from the client buffer into + /// , leaving any partial frame for the next write. + /// + private void DecodeClientBytes() + { + if (!_framed) + { + for (int i = _decodeCursor; i < _clientBytes.Count; i++) + _payloadFromClient.Add(_clientBytes[i]); + _decodeCursor = _clientBytes.Count; + return; + } + + while (true) + { + int available = _clientBytes.Count - _decodeCursor; + if (available < 2) + return; + + byte[] frame = [.. _clientBytes.GetRange(_decodeCursor, available)]; + + byte second = frame[1]; + bool masked = (second & 0x80) != 0; + long length = second & 0x7F; + int offset = 2; + + if (length == 126) + { + if (available < offset + 2) return; + length = BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(offset)); + offset += 2; + } + else if (length == 127) + { + if (available < offset + 8) return; + length = (long)BinaryPrimitives.ReadUInt64BigEndian(frame.AsSpan(offset)); + offset += 8; + } + + // A client that does not mask is a protocol violation; surface it as a test failure + // rather than silently decoding it. + if (!masked) + throw new InvalidOperationException("The client sent an unmasked frame, which RFC 6455 forbids."); + + if (available < offset + 4 + length) + return; + + ReadOnlySpan mask = frame.AsSpan(offset, 4); + offset += 4; + + byte opcode = (byte)(frame[0] & 0x0F); + for (long i = 0; i < length; i++) + { + byte unmasked = (byte)(frame[offset + i] ^ mask[(int)(i % 4)]); + // Opcode 1/2 are text/binary data; 8/9/10 are control frames carrying no payload + // the tunnel cares about. + if (opcode is 0x00 or 0x01 or 0x02) + _payloadFromClient.Add(unmasked); + } + + _decodeCursor += offset + (int)length; + } + } + + private static int IndexOf(ReadOnlySpan haystack, ReadOnlySpan needle) => haystack.IndexOf(needle); +} diff --git a/QuickProxyNet.Tests/Helpers/ScriptedDuplexStream.cs b/QuickProxyNet.Tests/Helpers/ScriptedDuplexStream.cs new file mode 100644 index 0000000..52ac7fc --- /dev/null +++ b/QuickProxyNet.Tests/Helpers/ScriptedDuplexStream.cs @@ -0,0 +1,107 @@ +namespace QuickProxyNet.Tests.Helpers; + +/// +/// A bidirectional in-memory transport whose inbound script can be extended after +/// construction, so a test can act as the server: observe what the client wrote, derive +/// the session keys from it, and only then queue the matching response bytes. +/// +/// +/// +/// fixes its inbound buffer up front, which is enough when +/// the response does not depend on the request. The VMess handshake is the opposite case: +/// the response header is sealed with keys the client generated randomly inside +/// ConnectAsync. +/// +/// +/// Reads return 0 when the queue is drained rather than blocking, so a client that +/// reads earlier than expected fails deterministically (as truncation) instead of hanging +/// the test run. +/// +/// +internal sealed class ScriptedDuplexStream(int maxReadSize = int.MaxValue) : Stream +{ + private readonly List _inbound = []; + private readonly List _outbound = []; + private int _position; + + /// Every byte written by the client so far, in order. + public byte[] Written => [.. _outbound]; + + /// Bytes of the inbound script not yet consumed. + public int Unread => _inbound.Count - _position; + + /// How many times a read was attempted (0 proves nothing was read yet). + public int ReadCount { get; private set; } + + /// How many times / ran. + public int DisposeCount { get; private set; } + + /// Appends bytes the client will see on subsequent reads. + public void Enqueue(ReadOnlySpan data) => _inbound.AddRange(data); + + /// Discards everything written so far, to isolate a later exchange. + public void ClearWritten() => _outbound.Clear(); + + 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 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(Span buffer) + { + ReadCount++; + + int count = Math.Min(Math.Min(buffer.Length, maxReadSize), _inbound.Count - _position); + if (count <= 0) + return 0; + + for (int i = 0; i < count; i++) + buffer[i] = _inbound[_position + i]; + + _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(); + + public override void Write(ReadOnlySpan buffer) => _outbound.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(); + + protected override void Dispose(bool disposing) + { + if (disposing) + DisposeCount++; + base.Dispose(disposing); + } +} diff --git a/QuickProxyNet.Tests/HostilePeerTest.cs b/QuickProxyNet.Tests/HostilePeerTest.cs new file mode 100644 index 0000000..6110ac9 --- /dev/null +++ b/QuickProxyNet.Tests/HostilePeerTest.cs @@ -0,0 +1,392 @@ + +namespace QuickProxyNet.Tests; + +/// +/// Drives the managed REALITY client against a peer that is malformed or actively hostile. +/// +/// +/// +/// Every other test of this code talks to a real, well-behaved Xray server. That proves +/// interoperability and proves nothing about what happens when the bytes on the wire are chosen +/// by an adversary — which is the only situation REALITY exists for. These tests fill that gap; +/// the record-injection case below is one that a cooperating server can never produce. +/// +/// +/// Everything runs in memory. No process, no socket, no timing. +/// +/// +public class HostilePeerTest +{ + private static readonly byte[] PublicKey = new byte[32]; + + private static RealityTlsOptions Options() => new() + { + ServerName = "qpn.test", + PublicKey = ServerPublicKey(), + ShortId = "ab12" + }; + + /// A syntactically valid X25519 public key; the tests never get far enough to use it. + private static byte[] ServerPublicKey() + { + byte[] key = new byte[32]; + key[0] = 9; + return key; + } + + private static byte[] Record(TlsContentTypeForTests type, ReadOnlySpan payload) + { + byte[] record = new byte[5 + payload.Length]; + record[0] = (byte)type; + record[1] = 3; + record[2] = 3; + record[3] = (byte)(payload.Length >> 8); + record[4] = (byte)payload.Length; + payload.CopyTo(record.AsSpan(5)); + return record; + } + + private enum TlsContentTypeForTests : byte + { + ChangeCipherSpec = 20, + Handshake = 22, + ApplicationData = 23 + } + + /// + /// Builds a ServerHello handshake message, with every field overridable so a test can bend + /// exactly one of them. + /// + private static byte[] ServerHello( + ReadOnlySpan sessionIdEcho, + ushort suite = 0x1301, + byte compression = 0, + bool supportedVersions = true, + bool keyShare = true, + ReadOnlySpan random = default) + { + var body = new List { 0x03, 0x03 }; + + if (random.IsEmpty) + body.AddRange(new byte[32]); + else + body.AddRange(random.ToArray()); + + body.Add((byte)sessionIdEcho.Length); + body.AddRange(sessionIdEcho.ToArray()); + body.Add((byte)(suite >> 8)); + body.Add((byte)suite); + body.Add(compression); + + var extensions = new List(); + if (supportedVersions) + extensions.AddRange(new byte[] { 0, 43, 0, 2, 0x03, 0x04 }); + + if (keyShare) + { + extensions.AddRange(new byte[] { 0, 51, 0, 36, 0x00, 0x1D, 0x00, 0x20 }); + extensions.AddRange(new byte[32]); + } + + body.Add((byte)(extensions.Count >> 8)); + body.Add((byte)extensions.Count); + body.AddRange(extensions); + + byte[] message = new byte[4 + body.Count]; + message[0] = 2; // server_hello + message[1] = (byte)(body.Count >> 16); + message[2] = (byte)(body.Count >> 8); + message[3] = (byte)body.Count; + body.CopyTo(message, 4); + + return message; + } + + private static async Task ExpectRefusalAsync(Func respond) + { + 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)); + + // 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. + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + return ex; + } + + /// + /// The one a cooperating server cannot produce: application data injected before the server + /// has any keys. + /// + /// + /// Such a record is returned by the record layer verbatim, because there is nothing to + /// decrypt it with. Buffering it would place bytes chosen by whoever is on the path at the + /// head of the tunnel, and the handshake would still complete — injected records never enter + /// the transcript, so the server's Finished and the REALITY certificate HMAC both still + /// verify. It must be refused outright. + /// + [Fact] + public async Task ApplicationDataBeforeKeys_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => + { + byte[] injected = Record(TlsContentTypeForTests.ApplicationData, "attacker-chosen"u8); + byte[] hello = Record( + TlsContentTypeForTests.Handshake, + ServerHello(clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize))); + + return [.. injected, .. hello]; + }); + + Assert.Contains("application data", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Unencrypted handshake bytes trailing the ServerHello would be handed out later as though + /// they had been decrypted. + /// + [Fact] + public async Task PlaintextHandshakeAfterServerHello_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => + { + byte[] hello = ServerHello(clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize)); + + // A second, forged handshake message riding in the same plaintext record. + byte[] forged = [8, 0, 0, 2, 0, 0]; // EncryptedExtensions, empty + return Record(TlsContentTypeForTests.Handshake, [.. hello, .. forged]); + }); + + Assert.Contains("unencrypted", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// RFC 8446 §4.1.3 requires checking the echo. For REALITY it also detects a ClientHello + /// that was altered in flight, since the session id is where the sealed blob lives. + /// + [Fact] + public async Task WrongSessionIdEcho_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(_ => + Record(TlsContentTypeForTests.Handshake, ServerHello(new byte[32]))); + + Assert.Contains("session id", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// A truncated ServerHello must name the problem, not escape as an index error. + [Theory] + [InlineData(4)] // header only + [InlineData(20)] // mid-random + [InlineData(38)] // exactly the version and random, nothing after + [InlineData(39)] // a session id length with no session id + public async Task TruncatedServerHello_IsRefusedWithAReason(int keep) + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => + { + byte[] hello = ServerHello(clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize)); + byte[] truncated = hello.AsSpan(0, Math.Min(keep, hello.Length)).ToArray(); + + // Keep the declared length consistent so the reader accepts the message and then has + // to cope with the body being short. + if (truncated.Length >= 4) + { + int bodyLength = truncated.Length - 4; + truncated[1] = (byte)(bodyLength >> 16); + truncated[2] = (byte)(bodyLength >> 8); + truncated[3] = (byte)bodyLength; + } + + return Record(TlsContentTypeForTests.Handshake, truncated); + }); + + Assert.False(string.IsNullOrWhiteSpace(ex.Message)); + } + + [Fact] + public async Task HelloRetryRequest_IsRefusedByName() + { + byte[] helloRetryRandom = + [ + 0xCF, 0x21, 0xAD, 0x74, 0xE5, 0x9A, 0x61, 0x11, 0xBE, 0x1D, 0x8C, 0x02, 0x1E, 0x65, 0xB8, 0x91, + 0xC2, 0xA2, 0x11, 0x16, 0x7A, 0xBB, 0x8C, 0x5E, 0x07, 0x9E, 0x09, 0xE2, 0xC8, 0xA8, 0x33, 0x9C + ]; + + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => Record( + TlsContentTypeForTests.Handshake, + ServerHello( + clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize), + random: helloRetryRandom))); + + Assert.Contains("HelloRetryRequest", ex.Message); + } + + [Fact] + public async Task CompressionMethod_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => Record( + TlsContentTypeForTests.Handshake, + ServerHello(clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize), compression: 1))); + + Assert.Contains("compression", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task UnofferedCipherSuite_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => Record( + TlsContentTypeForTests.Handshake, + ServerHello(clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize), suite: 0x009C))); + + Assert.Contains("cipher suite", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task MissingSupportedVersions_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => Record( + TlsContentTypeForTests.Handshake, + ServerHello( + clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize), + supportedVersions: false))); + + Assert.Contains("TLS 1.3", ex.Message); + } + + [Fact] + public async Task MissingKeyShare_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(clientHello => Record( + TlsContentTypeForTests.Handshake, + ServerHello( + clientHello.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize), + keyShare: false))); + + Assert.Contains("key_share", ex.Message); + } + + /// + /// A peer that sends nothing but ChangeCipherSpec must not hold the handshake open forever. + /// + [Fact] + public async Task ChangeCipherSpecFlood_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(_ => + { + var flood = new List(); + for (int i = 0; i < 64; i++) + flood.AddRange(Record(TlsContentTypeForTests.ChangeCipherSpec, [1])); + + return flood.ToArray(); + }); + + Assert.Contains("ChangeCipherSpec", ex.Message); + } + + /// + /// A peer that hangs up mid-handshake is reported as a proxy error with the connection-failed + /// code and a hint — this is exactly what a REALITY server with no fallback does to a client + /// it does not recognise, so "EndOfStreamException" would be the least useful possible answer. + /// + [Fact] + public async Task PeerThatHangsUp_IsConnectionFailedWithAHint() + { + await using var peer = new ScriptedPeer(_ => []); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var ex = await Assert.ThrowsAsync( + async () => await RealityTlsClient.HandshakeAsync(peer, Options(), timeout.Token)); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + Assert.Contains("pbk", ex.Message); + Assert.IsType(ex.InnerException); + } + + /// + /// RFC 8446 §5.1 forbids zero-length handshake records. One is harmless; a peer can send them + /// forever, and each used to cost the client a loop iteration and nothing else. + /// + [Fact] + public async Task EmptyHandshakeRecord_IsRefused() + { + RealityHandshakeException ex = await ExpectRefusalAsync(_ => + Record(TlsContentTypeForTests.Handshake, ReadOnlySpan.Empty)); + + Assert.Contains("zero-length", ex.Message); + } + + /// + /// A transport that captures what the client writes and replays a scripted answer. + /// + /// + /// The answer is a function of the captured ClientHello so a test can echo the session id + /// the client actually generated — it is random per connection, so it cannot be hard-coded. + /// + private sealed class ScriptedPeer(Func respond) : Stream + { + private readonly MemoryStream _written = new(); + private byte[]? _response; + private int _offset; + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _written.Write(buffer.Span); + return ValueTask.CompletedTask; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_response is null) + { + // The client's first write is one record: five bytes of header, then the hello. + byte[] written = _written.ToArray(); + _response = written.Length > 5 ? respond(written.AsSpan(5).ToArray()) : []; + } + + int count = Math.Min(buffer.Length, _response.Length - _offset); + if (count <= 0) + return ValueTask.FromResult(0); + + _response.AsSpan(_offset, count).CopyTo(buffer.Span); + _offset += count; + + return ValueTask.FromResult(count); + } + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) => + _written.Write(buffer, offset, count); + + 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(); + + protected override void Dispose(bool disposing) + { + if (disposing) + _written.Dispose(); + + base.Dispose(disposing); + } + } +} diff --git a/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs b/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs new file mode 100644 index 0000000..e931d99 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// Owns the lifetime of tests/docker/docker-compose.yml for the docker integration tests. +/// +/// +/// +/// The compose project name is pinned to so an interrupted run can +/// always be cleaned up by hand with the exact same command +/// (docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml down -v), +/// and so two runs never collide with generated names. +/// +/// +/// runs down -v unconditionally in a finally: if +/// up got far enough to create anything at all, teardown happens even when startup +/// failed halfway. After a run docker ps must be empty. +/// +/// +/// Startup failures are captured rather than thrown. A throwing InitializeAsync surfaces +/// in xUnit as an opaque collection-level error; storing the reason lets every test fail with +/// the actual docker output. +/// +/// +/// The stack is a machine-global singleton: one fixed project name, one fixed set of host +/// ports. Two test processes therefore cannot own it at once — and dotnet test on a +/// multi-targeted project runs the TFMs concurrently, so that is the normal case, not an exotic +/// one. serializes them; each run brings the stack up, uses +/// it, and tears it down before the next acquires the lock. +/// +/// +public sealed class DockerComposeFixture : IAsyncLifetime +{ + /// The fixed compose project name. Never generate this. + public const string ProjectName = "quickproxynet-test"; + + /// + /// How long to wait for another test process to finish with the stack. Generous on purpose: + /// the holder keeps the lock for its whole docker run, not just for startup. + /// + private static readonly TimeSpan LockTimeout = TimeSpan.FromMinutes(10); + + private bool _composeTouched; + private FileStream? _globalLock; + + /// Absolute path of tests/docker/docker-compose.yml. + public string ComposeFile { get; private set; } = ""; + + /// Non-null when the stack failed to come up; the reason to fail tests with. + public string? StartupError { get; private set; } + + /// Throws with the captured docker output when the stack is not usable. + public void EnsureUp() + { + if (StartupError is not null) + throw new InvalidOperationException(StartupError); + } + + public async Task InitializeAsync() + { + // The fixture is constructed even when every test in the class is skipped at + // discovery time, so the gate has to be re-checked here or an unconfigured run + // would still start containers. + if (!SkipGates.DockerEnabled) + return; + + try + { + ComposeFile = LocateComposeFile(); + + // Must be held before touching compose: the 'down -v' below would otherwise rip the + // stack out from under a concurrently running test process. + _globalLock = await AcquireGlobalLockAsync(LockTimeout); + + // Clear anything a previously interrupted run left behind before starting. + _composeTouched = true; + await ComposeAsync("down -v --remove-orphans", TimeSpan.FromMinutes(2)); + + var up = await ComposeAsync("up -d --wait", TimeSpan.FromMinutes(5)); + if (up.ExitCode != 0) + { + StartupError = $"'docker compose up -d --wait' failed with exit code {up.ExitCode}.\n{up.Output}"; + return; + } + + await WaitForListenersAsync(TimeSpan.FromSeconds(90)); + } + catch (Exception ex) + { + StartupError = $"Docker compose stack failed to start: {ex}"; + } + } + + public async Task DisposeAsync() + { + try + { + if (_composeTouched) + await ComposeAsync("down -v --remove-orphans", TimeSpan.FromMinutes(2)); + } + finally + { + _composeTouched = false; + + // Released last, so the next test process only sees a torn-down stack. + _globalLock?.Dispose(); + _globalLock = null; + } + } + + /// + /// Takes an exclusive cross-process lock on the compose stack, waiting for whoever holds it. + /// + /// + /// A lock file rather than a named on purpose: a mutex has thread + /// affinity and must be released by the thread that took it, which async test lifecycle + /// methods do not guarantee — the release would throw. A opened with + /// has no such affinity and is released by disposal. + /// + private static async Task AcquireGlobalLockAsync(TimeSpan timeout) + { + string path = Path.Combine(Path.GetTempPath(), "quickproxynet-docker-compose.lock"); + long deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + + while (true) + { + try + { + return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException) + { + if (Environment.TickCount64 > deadline) + throw new TimeoutException( + $"Another test process has held the docker compose stack for over {timeout}. " + + $"If none is running, delete '{path}'."); + + await Task.Delay(250); + } + } + } + + /// + /// Polls every mapped host port until it accepts a TCP connection. This is a real + /// readiness probe: xray and sing-box bind their listeners only once the whole config + /// has been accepted, so an accepted connection means the inbound exists. A fixed sleep + /// would be both slower and a lie. + /// + private static async Task WaitForListenersAsync(TimeSpan timeout) + { + long deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + + foreach (var endpoint in DockerEndpoints.All) + { + while (true) + { + if (await TryConnectAsync(endpoint.Port)) + break; + + if (Environment.TickCount64 > deadline) + throw new TimeoutException( + $"Port {endpoint.Port} ({endpoint.Description}) never started accepting connections."); + + await Task.Delay(200); + } + } + } + + private static async Task TryConnectAsync(int port) + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + try + { + await socket.ConnectAsync("127.0.0.1", port, cts.Token); + return true; + } + catch (Exception ex) when (ex is SocketException or OperationCanceledException) + { + return false; + } + } + + private Task<(int ExitCode, string Output)> ComposeAsync(string arguments, TimeSpan timeout) => + RunAsync("docker", $"compose -p {ProjectName} -f \"{ComposeFile}\" {arguments}", timeout); + + private static async Task<(int ExitCode, string Output)> RunAsync( + string fileName, string arguments, TimeSpan timeout) + { + var psi = new ProcessStartInfo(fileName, arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException($"Could not start '{fileName} {arguments}'."); + + var output = new StringBuilder(); + var stdout = ReadAllAsync(process.StandardOutput, output); + var stderr = ReadAllAsync(process.StandardError, output); + + using var cts = new CancellationTokenSource(timeout); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } catch { /* already gone */ } + throw new TimeoutException($"'{fileName} {arguments}' did not finish within {timeout}."); + } + + await Task.WhenAll(stdout, stderr); + return (process.ExitCode, output.ToString()); + + static async Task ReadAllAsync(StreamReader reader, StringBuilder sink) + { + string text = await reader.ReadToEndAsync(); + lock (sink) + sink.Append(text); + } + } + + /// + /// Walks up from the test binary to the repository root (identified by + /// QuickProxyNet.slnx) and returns the compose file beneath it. + /// + private static string LocateComposeFile() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + string candidate = Path.Combine(dir.FullName, "tests", "docker", "docker-compose.yml"); + if (File.Exists(Path.Combine(dir.FullName, "QuickProxyNet.slnx")) && File.Exists(candidate)) + return candidate; + + dir = dir.Parent; + } + + throw new FileNotFoundException( + $"Could not find tests/docker/docker-compose.yml above '{AppContext.BaseDirectory}'."); + } +} diff --git a/QuickProxyNet.Tests/Integration/DockerEndpoints.cs b/QuickProxyNet.Tests/Integration/DockerEndpoints.cs new file mode 100644 index 0000000..79c691f --- /dev/null +++ b/QuickProxyNet.Tests/Integration/DockerEndpoints.cs @@ -0,0 +1,121 @@ +namespace QuickProxyNet.Tests.Integration; + +/// +/// The host-port map and the synthetic credentials of tests/docker/docker-compose.yml. +/// +/// +/// Every value here is test data committed on purpose: the UUIDs are repdigit placeholders and +/// the Trojan password is a literal string. Nothing here is or ever was a real credential. +/// Keep this in sync with tests/docker/README.md and the two server configs. +/// +public static class DockerEndpoints +{ + /// Server implementation under test. + public enum Server + { + /// ghcr.io/xtls/xray-core + Xray, + + /// ghcr.io/sagernet/sing-box + SingBox + } + + /// Host name of the HTTP target inside the compose network. + public const string EchoHost = "echo"; + + /// Port of the HTTP target inside the compose network. + public const int EchoPort = 8080; + + /// The exact body the echo target answers a GET / with. + public const string EchoBody = "QPN-ECHO-OK"; + + /// SNI presented to the TLS inbounds; a SAN of the committed test certificate. + public const string TlsSni = "qpn.test"; + + /// Subject CN of the committed self-signed test certificate. + public const string TestCertSubjectCn = "QuickProxyNet Test"; + + public const string VlessNoneId = "11111111-1111-4111-8111-111111111111"; + public const string VlessTlsId = "22222222-2222-4222-8222-222222222222"; + public const string VmessAesId = "33333333-3333-4333-8333-333333333333"; + public const string VmessChachaId = "44444444-4444-4444-8444-444444444444"; + public const string VlessWsId = "55555555-5555-4555-8555-555555555555"; + 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"; + + /// + /// Paths the ws/httpupgrade inbounds are configured with. A WebSocket server only upgrades + /// on its exact configured path, so these must match the two server configs verbatim. + /// + public const string VlessWsPath = "/qpn-ws"; + public const string VmessWsPath = "/qpn-vmess-ws"; + public const string TrojanWsPath = "/qpn-trojan-ws"; + public const string HttpUpgradePath = "/qpn-hu"; + + /// + /// The short non-UUID VLESS id configured on the Xray inbound at + /// . Xray maps it to UUIDv5(nil-namespace, utf8(id)); + /// UuidCodec must produce the same 16 bytes or the handshake is rejected. + /// + public const string NonUuidId = "not-a-uuid"; + + // Xray: container ports 10001..10010 -> host 24801..24810. + public const int XrayVlessNone = 24801; + public const int XrayVlessTls = 24802; + public const int XrayTrojan = 24803; + public const int XrayVmessAes = 24804; + public const int XrayVmessChacha = 24805; + public const int XrayVlessNonUuid = 24806; + public const int XrayVlessWs = 24807; + public const int XrayVmessWs = 24808; + public const int XrayTrojanWs = 24809; + public const int XrayVlessHttpUpgrade = 24810; + + // sing-box: container ports 10001..10010 -> host 24811..24820. + public const int SingBoxVlessNone = 24811; + public const int SingBoxVlessTls = 24812; + public const int SingBoxTrojan = 24813; + public const int SingBoxVmessAes = 24814; + public const int SingBoxVmessChacha = 24815; + public const int SingBoxVlessWs = 24817; + public const int SingBoxVmessWs = 24818; + public const int SingBoxTrojanWs = 24819; + public const int SingBoxVlessHttpUpgrade = 24820; + + /// Every mapped host port, used as the readiness probe list. + public static readonly (int Port, string Description)[] All = + [ + (XrayVlessNone, "xray vless security=none"), + (XrayVlessTls, "xray vless security=tls"), + (XrayTrojan, "xray trojan"), + (XrayVmessAes, "xray vmess (aes-128-gcm)"), + (XrayVmessChacha, "xray vmess (chacha20-poly1305)"), + (XrayVlessNonUuid, "xray vless with non-UUID id"), + (SingBoxVlessNone, "sing-box vless security=none"), + (SingBoxVlessTls, "sing-box vless security=tls"), + (SingBoxTrojan, "sing-box trojan"), + (SingBoxVmessAes, "sing-box vmess (aes-128-gcm)"), + (SingBoxVmessChacha, "sing-box vmess (chacha20-poly1305)"), + (XrayVlessWs, "xray vless over ws"), + (XrayVmessWs, "xray vmess over ws"), + (XrayTrojanWs, "xray trojan over ws"), + (XrayVlessHttpUpgrade, "xray vless over httpupgrade"), + (SingBoxVlessWs, "sing-box vless over ws"), + (SingBoxVmessWs, "sing-box vmess over ws"), + (SingBoxTrojanWs, "sing-box trojan over ws"), + (SingBoxVlessHttpUpgrade, "sing-box vless over httpupgrade") + ]; + + public static int VlessNonePort(Server server) => server is Server.Xray ? XrayVlessNone : SingBoxVlessNone; + public static int VlessTlsPort(Server server) => server is Server.Xray ? XrayVlessTls : SingBoxVlessTls; + public static int TrojanPort(Server server) => server is Server.Xray ? XrayTrojan : SingBoxTrojan; + public static int VmessAesPort(Server server) => server is Server.Xray ? XrayVmessAes : SingBoxVmessAes; + public static int VmessChachaPort(Server server) => server is Server.Xray ? XrayVmessChacha : SingBoxVmessChacha; + public static int VlessWsPort(Server server) => server is Server.Xray ? XrayVlessWs : SingBoxVlessWs; + public static int VmessWsPort(Server server) => server is Server.Xray ? XrayVmessWs : SingBoxVmessWs; + public static int TrojanWsPort(Server server) => server is Server.Xray ? XrayTrojanWs : SingBoxTrojanWs; + + public static int VlessHttpUpgradePort(Server server) => + server is Server.Xray ? XrayVlessHttpUpgrade : SingBoxVlessHttpUpgrade; +} diff --git a/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs b/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs new file mode 100644 index 0000000..d2b9ecb --- /dev/null +++ b/QuickProxyNet.Tests/Integration/DockerProtocolTests.cs @@ -0,0 +1,383 @@ +using System.Net.Security; +using System.Text; +using static QuickProxyNet.Tests.Integration.DockerEndpoints; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// End-to-end protocol tests against real Xray and sing-box servers started from +/// tests/docker/docker-compose.yml. Enable with QPN_DOCKER_TESTS=1; otherwise +/// every case here reports as skipped. +/// +/// +/// +/// Byte-exact vectors prove the crypto is what an independent implementation computes. They +/// cannot prove that a server accepts the handshake — framing, field order, the option +/// byte, the padding rules and the id derivation all have to be right at once for that. These +/// tests are the only thing in the suite that proves it. +/// +/// +/// Two implementations are exercised because they disagree about what they tolerate: one +/// forgives mistakes the other rejects, so a single server would silently bless a bug. +/// +/// +/// Every case performs a full request/response round trip, never a bare connect. That is +/// mandatory for VMess: VmessResponseStream decodes the sealed response header lazily on +/// the first Read, so ConnectAsync returning successfully proves nothing about the +/// server's reply. Only reading the body validates the response header, the derived response +/// keys and the AEAD chunk framing. +/// +/// +public sealed class DockerProtocolTests : IClassFixture +{ + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan RoundTripTimeout = TimeSpan.FromSeconds(30); + + private readonly DockerComposeFixture _docker; + + public DockerProtocolTests(DockerComposeFixture docker) => _docker = docker; + + // ---------------------------------------------------------------- VLESS, security=none + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vless_None_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = VlessNoneId, + Host = "127.0.0.1", + Port = VlessNonePort(server), + Security = VlessSecurity.None + }); + + await AssertEchoRoundTripAsync(client); + } + + // ----------------------------------------------------------------- VLESS, security=tls + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vless_Tls_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = VlessTlsId, + Host = "127.0.0.1", + Port = VlessTlsPort(server), + Security = VlessSecurity.Tls, + Sni = TlsSni + }) + { + ServerCertificateValidationCallback = AcceptTestCertificate + }; + + await AssertEchoRoundTripAsync(client); + } + + // ------------------------------------------------------------------------ Trojan (TLS) + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Trojan_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new TrojanClient(new TrojanOptions + { + Password = TrojanPassword, + Host = "127.0.0.1", + Port = TrojanPort(server), + Sni = TlsSni + // AllowInsecure stays false on purpose: the callback below still requires the + // handshake to present *our* test certificate, so TLS is genuinely verified. + }) + { + ServerCertificateValidationCallback = AcceptTestCertificate + }; + + await AssertEchoRoundTripAsync(client); + } + + // ---------------------------------------------------------------------------- VMess + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vmess_Aes128Gcm_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VmessClient(new VmessOptions + { + Id = VmessAesId, + Host = "127.0.0.1", + Port = VmessAesPort(server), + Security = VmessSecurityKind.Aes128Gcm, + AlterId = 0 + }); + + await AssertEchoRoundTripAsync(client); + } + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vmess_ChaCha20Poly1305_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VmessClient(new VmessOptions + { + Id = VmessChachaId, + Host = "127.0.0.1", + Port = VmessChachaPort(server), + Security = VmessSecurityKind.ChaCha20Poly1305, + AlterId = 0 + }); + + await AssertEchoRoundTripAsync(client); + } + + // ------------------------------------------------------------ ws / httpupgrade transports + + /// + /// The transport layer is where a unit test is least trustworthy: a fake server answers the + /// handshake exactly as written, so it cannot catch a wrong path, a missing header a real + /// server insists on, or the framing/flush behaviour that decides whether the connection + /// deadlocks. Only Xray and sing-box can. + /// + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vless_WebSocket_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = VlessWsId, + Host = "127.0.0.1", + Port = VlessWsPort(server), + Security = VlessSecurity.None, + Transport = "ws", + Path = VlessWsPath + }); + + await AssertEchoRoundTripAsync(client); + } + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vmess_WebSocket_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VmessClient(new VmessOptions + { + Id = VmessWsId, + Host = "127.0.0.1", + Port = VmessWsPort(server), + Security = VmessSecurityKind.Aes128Gcm, + AlterId = 0, + Transport = "ws", + Path = VmessWsPath + }); + + await AssertEchoRoundTripAsync(client); + } + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Trojan_WebSocket_OverTls_RoundTrip(Server server) + { + _docker.EnsureUp(); + + // ws inside TLS: proves the layering order (TLS first, then the upgrade, then the + // protocol header) rather than just that each layer works alone. + var client = new TrojanClient(new TrojanOptions + { + Password = TrojanPassword, + Host = "127.0.0.1", + Port = TrojanWsPort(server), + Sni = TlsSni, + Transport = "ws", + Path = TrojanWsPath + }) + { + ServerCertificateValidationCallback = AcceptTestCertificate + }; + + await AssertEchoRoundTripAsync(client); + } + + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vless_HttpUpgrade_RoundTrip(Server server) + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = VlessHttpUpgradeId, + Host = "127.0.0.1", + Port = VlessHttpUpgradePort(server), + Security = VlessSecurity.None, + Transport = "httpupgrade", + Path = HttpUpgradePath + }); + + await AssertEchoRoundTripAsync(client); + } + + /// + /// A wrong path must fail, and fail as a refused upgrade. Without this the tests above + /// would still pass if the servers upgraded on any path at all. + /// + [DockerTheory] + [InlineData(Server.Xray)] + [InlineData(Server.SingBox)] + public async Task Vless_WebSocket_WrongPath_IsRefused(Server server) + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = VlessWsId, + Host = "127.0.0.1", + Port = VlessWsPort(server), + Security = VlessSecurity.None, + Transport = "ws", + Path = "/definitely-not-configured" + }); + + var ex = await Assert.ThrowsAsync(async () => await FetchEchoAsync(client)); + Assert.Equal(ProxyErrorCode.TransportUpgradeFailed, ex.ErrorCode); + } + + // ------------------------------------------------------- non-UUID id derivation vs Xray + + /// + /// The Xray inbound on is configured with the literal id + /// "not-a-uuid". Xray's common/uuid.ParseString maps any id of length 1..30 to + /// UUIDv5(nil-namespace, utf8(id)), and this client is given the same literal string. + /// + /// + /// A successful round trip means UuidCodec derived the identical 16 bytes Xray did: + /// the VLESS id is compared byte for byte on the server, so a single wrong bit is a rejected + /// handshake. This is the only test that pins that derivation against the implementation it + /// was reverse-engineered from — a unit vector could only pin it against ourselves. + /// + [DockerFact] + public async Task Vless_NonUuidId_DerivesSameIdAsXray() + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = NonUuidId, + Host = "127.0.0.1", + Port = XrayVlessNonUuid, + Security = VlessSecurity.None + }); + + await AssertEchoRoundTripAsync(client); + } + + /// + /// A wrong id on the same inbound must not round trip. Without this, the test above + /// would still pass if the server accepted anything at all. + /// + [DockerFact] + public async Task Vless_WrongNonUuidId_IsRejectedByXray() + { + _docker.EnsureUp(); + + var client = new VlessClient(new VlessOptions + { + Id = "not-a-uuid-either", + Host = "127.0.0.1", + Port = XrayVlessNonUuid, + Security = VlessSecurity.None + }); + + // Xray drops the connection on an unknown user rather than replying, so the failure + // surfaces on the first read. Asserting the specific exception (and not merely "some + // exception") keeps a 30 s timeout from being mistaken for a rejection. + var ex = await Assert.ThrowsAsync(async () => await FetchEchoAsync(client)); + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + + // ------------------------------------------------------------------------------ helpers + + private static async Task AssertEchoRoundTripAsync(ProxyClient client) + { + string response = await FetchEchoAsync(client); + + Assert.StartsWith("HTTP/1.1 200", response); + Assert.Contains(EchoBody, response); + } + + /// + /// Opens a tunnel to the compose network's echo target, writes an HTTP GET through it and + /// reads until the body arrives. + /// + /// + /// Reading stops as soon as the expected body is present instead of draining to EOF. That + /// keeps the assertion about the handshake rather than about how each server chooses to + /// terminate the stream — for VMess a clean close is an authenticated empty chunk, and + /// whether a server emits one after a plain socket close is its business, not this test's. + /// + private static async Task FetchEchoAsync(ProxyClient client) + { + using var cts = new CancellationTokenSource(RoundTripTimeout); + + // The target host is a docker-network DNS name, so this also exercises the domain + // address type of each protocol rather than the IPv4 one. + 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"); + + await stream.WriteAsync(request, cts.Token); + await stream.FlushAsync(cts.Token); + + var received = new StringBuilder(); + byte[] buffer = new byte[4096]; + + while (true) + { + int read = await stream.ReadAsync(buffer, cts.Token); + if (read == 0) + break; + + received.Append(Encoding.ASCII.GetString(buffer, 0, read)); + + if (received.ToString().Contains(EchoBody, StringComparison.Ordinal)) + break; + } + + return received.ToString(); + } + + /// + /// Accepts exactly the committed self-signed test certificate. Deliberately not + /// "accept anything": the TLS inbounds are supposed to prove a real TLS session with our + /// server happened. + /// + private static bool AcceptTestCertificate( + object sender, System.Security.Cryptography.X509Certificates.X509Certificate? certificate, + System.Security.Cryptography.X509Certificates.X509Chain? chain, SslPolicyErrors errors) => + certificate is not null && + certificate.Subject.Contains(TestCertSubjectCn, StringComparison.Ordinal); +} diff --git a/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs new file mode 100644 index 0000000..4a6ca00 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs @@ -0,0 +1,288 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// Isolates where a request larger than one TLS record stops arriving. +/// +/// +/// Written because Reality_CarriesLargePayload passes at 16 000 bytes and hangs at 16 500 — +/// a boundary suspiciously equal to the 16 KiB TLS record limit. The candidates are the test's own +/// echo server, QuickProxyNet's SOCKS5 stream, and the tunnel itself, and only one of them can be +/// blamed without evidence. +/// +public class LargeRequestDiagnosticTests +{ + private static byte[] Request(int padding) => Encoding.ASCII.GetBytes( + $"GET /{new string('a', padding)} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + + /// + /// The echo server on its own, over a plain socket. If this hangs, nothing else is at fault. + /// + [Theory] + [InlineData(1_000)] + [InlineData(20_000)] + [InlineData(100_000)] + public async Task Echo_HandlesLargeRequestsDirectly(int padding) + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", echo.Port, timeout.Token); + + NetworkStream stream = client.GetStream(); + await stream.WriteAsync(Request(padding), timeout.Token); + await stream.FlushAsync(timeout.Token); + + using var reader = new StreamReader(stream, Encoding.ASCII); + Assert.Contains(LoopbackEchoServer.Body, await reader.ReadToEndAsync(timeout.Token)); + } + + /// + /// The same request through QuickProxyNet's SOCKS5 client and a bare Xray SOCKS proxy — no + /// VLESS, no TLS, no REALITY. + /// + /// + /// + /// This is the test that decided whether the 16 KiB boundary belongs to the tunnel or to + /// . It belongs to neither: the sibling test below carries 100 000 + /// bytes through the same client against a plain relay, while this path — no VLESS, no TLS, + /// no REALITY — stops relaying somewhere between 16 000 and 16 500 bytes. + /// + /// + /// Verified against Xray-core 26.3.27 on Windows, with the inbound's sniffing explicitly + /// disabled and with the request written both as one call and in 4 KiB slices; neither + /// changes the outcome, and neither process logs an error. The sizes here stay under that + /// ceiling so the test measures the proxy working rather than the ceiling. + /// + /// + [EnvTheory(LocalRealityServer.ExecutablePathVariable)] + [InlineData(1_000)] + [InlineData(16_000)] + public async Task Socks5_HandlesLargeRequests(int padding) + { + string executable = Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; + + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + + int socksPort = FreePort(); + string config = + $$""" + { + "log": { "loglevel": "warning" }, + "inbounds": [ { + "listen": "127.0.0.1", "port": {{socksPort}}, "protocol": "socks", + "settings": { "auth": "noauth", "udp": false }, + "sniffing": { "enabled": false } } ], + "outbounds": [ { "protocol": "freedom" } ] + } + """; + + using Process xray = StartXray(executable, config); + try + { + await WaitForPortAsync(socksPort); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var socks = new Socks5Client("127.0.0.1", socksPort); + + await using Stream tunnel = await socks.ConnectAsync("127.0.0.1", echo.Port, timeout.Token); + await tunnel.WriteAsync(Request(padding), timeout.Token); + await tunnel.FlushAsync(timeout.Token); + + using var reader = new StreamReader(tunnel, Encoding.ASCII); + Assert.Contains(LoopbackEchoServer.Body, await reader.ReadToEndAsync(timeout.Token)); + } + finally + { + if (!xray.HasExited) + xray.Kill(entireProcessTree: true); + } + } + + /// + /// The same request through and a SOCKS5 server implemented here, + /// which does nothing but relay bytes. + /// + /// + /// The last isolation step. If this passes at 20 000 bytes, QuickProxyNet's SOCKS5 client is + /// clean and the boundary belongs to whatever proxy sits in the middle. + /// + [Theory] + [InlineData(1_000)] + [InlineData(20_000)] + [InlineData(100_000)] + public async Task Socks5_AgainstAPlainRelay_HandlesLargeRequests(int padding) + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + using var relay = new PlainSocks5Relay(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var socks = new Socks5Client("127.0.0.1", relay.Port); + + await using Stream tunnel = await socks.ConnectAsync("127.0.0.1", echo.Port, timeout.Token); + await tunnel.WriteAsync(Request(padding), timeout.Token); + await tunnel.FlushAsync(timeout.Token); + + using var reader = new StreamReader(tunnel, Encoding.ASCII); + Assert.Contains(LoopbackEchoServer.Body, await reader.ReadToEndAsync(timeout.Token)); + } + + /// A SOCKS5 server with no features beyond CONNECT and copying. + private sealed class PlainSocks5Relay : IDisposable + { + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + + public PlainSocks5Relay() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = AcceptAsync(); + } + + public int Port { get; } + + private async Task AcceptAsync() + { + while (!_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_cts.Token); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException) + { + return; + } + + _ = ServeAsync(client); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + try + { + NetworkStream stream = client.GetStream(); + var header = new byte[2]; + + await stream.ReadExactlyAsync(header, _cts.Token); + await stream.ReadExactlyAsync(new byte[header[1]], _cts.Token); + await stream.WriteAsync(new byte[] { 5, 0 }, _cts.Token); + + var request = new byte[4]; + await stream.ReadExactlyAsync(request, _cts.Token); + + string host; + switch (request[3]) + { + case 1: + var v4 = new byte[4]; + await stream.ReadExactlyAsync(v4, _cts.Token); + host = new IPAddress(v4).ToString(); + break; + + case 3: + var length = new byte[1]; + await stream.ReadExactlyAsync(length, _cts.Token); + var name = new byte[length[0]]; + await stream.ReadExactlyAsync(name, _cts.Token); + host = Encoding.ASCII.GetString(name); + break; + + default: + return; + } + + var portBytes = new byte[2]; + await stream.ReadExactlyAsync(portBytes, _cts.Token); + int port = (portBytes[0] << 8) | portBytes[1]; + + using var target = new TcpClient(); + await target.ConnectAsync(host, port, _cts.Token); + + await stream.WriteAsync(new byte[] { 5, 0, 0, 1, 0, 0, 0, 0, 0, 0 }, _cts.Token); + + NetworkStream targetStream = target.GetStream(); + Task up = stream.CopyToAsync(targetStream, _cts.Token); + Task down = targetStream.CopyToAsync(stream, _cts.Token); + await Task.WhenAny(up, down); + } + catch (Exception ex) when (ex is IOException or SocketException or OperationCanceledException or EndOfStreamException) + { + // The client went away; nothing to report from a test relay. + } + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + } + } + + private static Process StartXray(string executable, string config) + { + var psi = new ProcessStartInfo(executable) + { + ArgumentList = { "run", "-c", "stdin:" }, + WorkingDirectory = Path.GetDirectoryName(executable) ?? Environment.CurrentDirectory, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + Process process = Process.Start(psi)!; + process.StandardInput.Write(config); + process.StandardInput.Close(); + + return process; + } + + private static async Task WaitForPortAsync(int port) + { + long deadline = Environment.TickCount64 + 15_000; + while (Environment.TickCount64 < deadline) + { + using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + try + { + await probe.ConnectAsync("127.0.0.1", port); + return; + } + catch (SocketException) + { + await Task.Delay(50); + } + } + + throw new TimeoutException($"Nothing started listening on {port}."); + } + + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } +} diff --git a/QuickProxyNet.Tests/Integration/LocalRealityServer.cs b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs new file mode 100644 index 0000000..1b027c9 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs @@ -0,0 +1,487 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// A real Xray-core REALITY server, running on loopback, for the duration of one test class. +/// +/// +/// +/// Why a decoy inbound. A REALITY server does not answer the TLS handshake itself: it +/// relays the ClientHello to dest, a genuine TLS site, and serves that site's certificate +/// back — the stolen identity is the whole mechanism. So a REALITY server cannot be tested +/// without a reachable dest. Pointing it at a real site would make the test depend on the +/// internet and on that site's TLS configuration; instead this fixture gives the same Xray +/// process a second, ordinary TLS inbound and aims dest at it. The handshake is relayed +/// over loopback, and the test proves the same thing with nothing outside the machine. +/// +/// +/// The certificate is the repo's existing self-signed pair under tests/docker/certs. A +/// REALITY client never validates it — authentication is the X25519 exchange hidden in the +/// ClientHello's session_id, not the certificate chain — so a self-signed decoy is not a +/// weakening of the test. +/// +/// +/// The keypair below is synthetic test data, committed on purpose, exactly like the repdigit +/// UUIDs in . +/// +/// +public sealed class LocalRealityServer : IAsyncDisposable +{ + /// + /// Environment variable naming the Xray binary these tests run as a server. + /// + /// + /// Xray is no longer part of the library in any form — the client speaks REALITY itself. + /// It survives here as the reference implementation to test against, which is the only + /// role in which an outside binary is worth its weight: proof that our handshake is + /// accepted by the thing everyone else runs. Tests that need it skip when it is unset. + /// + public const string ExecutablePathVariable = "QPN_XRAY_PATH"; + + /// Generated with xray x25519 for this suite; never guarded anything real. + public const string PrivateKey = "iGNiP2EaAhjXIfaoiF34sn1_mKKSeO01YdhN46G7xn0"; + + /// The public half of , as a share link would carry it. + public const string PublicKey = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; + + /// Short id configured on the inbound. + public const string ShortId = "ab12"; + + /// VLESS id configured on the inbound. + public const string Id = "6643f196-ae07-420a-b173-d909d20807c1"; + + /// + /// SNI the client must present. It is a subject-alternative name on the committed test + /// certificate, so the decoy inbound serves it without complaint. + /// + public const string ServerName = "qpn.test"; + + private readonly Process _process; + private readonly List _log; + private readonly string? _flow; + private int _disposed; + + private LocalRealityServer(Process process, List log, int port, string? flow) + { + _process = process; + _log = log; + Port = port; + _flow = flow; + } + + /// Port of the REALITY inbound. + public int Port { get; } + + /// A vless:// link pointing at this server. + /// Extra query parameters, each starting with &. + /// + /// The flow the server was started with is included automatically. Xray rejects a client + /// whose flow disagrees with its user entry, in either direction, so the two must be set + /// from one place or the test would be measuring the mismatch instead of the tunnel. + /// + public string ShareLink(string extraQuery = "") => + $"vless://{Id}@127.0.0.1:{Port}?security=reality&pbk={PublicKey}&sid={ShortId}" + + $"&sni={ServerName}&fp=chrome" + + (_flow is null ? "" : $"&flow={_flow}") + + $"{extraQuery}#qpn-local"; + + /// The last lines Xray printed, for failure messages. + public string Log() + { + lock (_log) + return _log.Count == 0 ? "(no output)" : string.Join("\n", _log); + } + + /// Starts the server, waiting until the REALITY inbound accepts connections. + /// Path to the Xray-core binary. + /// XTLS flow to require, e.g. xtls-rprx-vision, or null for none. + /// + /// Turns on Xray's REALITY diagnostics. The server then prints, per connection, the auth key + /// prefix and the short id it decrypted out of the session id — which is how a test can prove + /// a hand-built ClientHello authenticated, without completing a handshake. + /// + /// Cancels startup. + public static async Task StartAsync( + string executable, string? flow = null, bool show = false, + CancellationToken cancellationToken = default) + { + int realityPort = FreePort(); + int decoyPort = FreePort(); + (string certificate, string key) = LocateCertificate(); + + byte[] config = BuildConfig(realityPort, decoyPort, certificate, key, flow, show); + + var psi = new ProcessStartInfo(executable) + { + ArgumentList = { "run", "-c", "stdin:" }, + WorkingDirectory = Path.GetDirectoryName(executable) ?? Environment.CurrentDirectory, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + Process process = Process.Start(psi) + ?? throw new InvalidOperationException($"Could not start '{executable}'."); + + var log = new List(); + var server = new LocalRealityServer(process, log, realityPort, flow); + + try + { + void Collect(object? _, DataReceivedEventArgs e) + { + if (e.Data is null) + return; + + lock (log) + { + log.Add(e.Data); + // Generous on purpose: with show=true the REALITY trace alone is ~40 lines + // per handshake, and the one line that explains a failure is easily the + // oldest one. + if (log.Count > 200) + log.RemoveAt(0); + } + } + + process.OutputDataReceived += Collect; + process.ErrorDataReceived += Collect; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.StandardInput.BaseStream.WriteAsync(config, cancellationToken); + process.StandardInput.Close(); + + await WaitForPortAsync(process, realityPort, server, cancellationToken); + await WaitForPortAsync(process, decoyPort, server, cancellationToken); + return server; + } + catch + { + await server.DisposeAsync(); + throw; + } + } + + private static byte[] BuildConfig( + int realityPort, int decoyPort, string certificate, string key, string? flow, bool show) + { + var buffer = new MemoryStream(1024); + using (var w = new Utf8JsonWriter(buffer)) + { + w.WriteStartObject(); + + w.WriteStartObject("log"); + w.WriteString("loglevel", "warning"); + w.WriteEndObject(); + + w.WriteStartArray("inbounds"); + + w.WriteStartObject(); + w.WriteString("listen", "127.0.0.1"); + w.WriteNumber("port", realityPort); + w.WriteString("protocol", "vless"); + w.WriteString("tag", "reality"); + w.WriteStartObject("settings"); + w.WriteStartArray("clients"); + w.WriteStartObject(); + w.WriteString("id", Id); + if (flow is not null) + w.WriteString("flow", flow); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteString("decryption", "none"); + w.WriteEndObject(); + w.WriteStartObject("streamSettings"); + w.WriteString("network", "tcp"); + w.WriteString("security", "reality"); + w.WriteStartObject("realitySettings"); + w.WriteString("dest", $"127.0.0.1:{decoyPort}"); + w.WriteStartArray("serverNames"); + w.WriteStringValue(ServerName); + w.WriteEndArray(); + w.WriteString("privateKey", PrivateKey); + if (show) + w.WriteBoolean("show", true); + w.WriteStartArray("shortIds"); + w.WriteStringValue(ShortId); + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndObject(); + + // The decoy: an ordinary TLS endpoint whose only job is to own a certificate for + // the REALITY inbound to relay. Nothing connects to it directly. + w.WriteStartObject(); + w.WriteString("listen", "127.0.0.1"); + w.WriteNumber("port", decoyPort); + w.WriteString("protocol", "vless"); + w.WriteString("tag", "decoy"); + w.WriteStartObject("settings"); + w.WriteStartArray("clients"); + w.WriteStartObject(); + w.WriteString("id", Id); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteString("decryption", "none"); + w.WriteEndObject(); + w.WriteStartObject("streamSettings"); + w.WriteString("network", "tcp"); + w.WriteString("security", "tls"); + w.WriteStartObject("tlsSettings"); + w.WriteString("serverName", ServerName); + w.WriteStartArray("certificates"); + w.WriteStartObject(); + w.WriteString("certificateFile", certificate); + w.WriteString("keyFile", key); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndObject(); + + w.WriteEndArray(); + + w.WriteStartArray("outbounds"); + w.WriteStartObject(); + w.WriteString("protocol", "freedom"); + // Xray 26.x gave freedom a default "finalRules" policy: traffic that arrived through + // a vless/vmess/trojan inbound and targets a private or reserved address is not + // refused but blackholed — the connection is held open and silent for up to a + // minute ("proxy/freedom: blocked target ..., blackholing connection"). Every target + // in these tests is on loopback, so without an explicit allow the REALITY handshake + // and the VLESS request both succeed and then nothing ever comes back. + w.WriteStartObject("settings"); + w.WriteStartArray("finalRules"); + w.WriteStartObject(); + w.WriteString("action", "allow"); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndArray(); + + w.WriteEndObject(); + } + + return buffer.ToArray(); + } + + private static async Task WaitForPortAsync( + Process process, int port, LocalRealityServer server, CancellationToken cancellationToken) + { + long deadline = Environment.TickCount64 + 15_000; + + while (true) + { + if (process.HasExited) + throw new InvalidOperationException( + $"The REALITY server exited with code {process.ExitCode}:\n{server.Log()}"); + + using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + try + { + await socket.ConnectAsync("127.0.0.1", port, cancellationToken); + return; + } + catch (SocketException) + { + // Not listening yet. + } + } + + if (Environment.TickCount64 > deadline) + throw new TimeoutException( + $"The REALITY server never listened on {port}:\n{server.Log()}"); + + await Task.Delay(50, cancellationToken); + } + } + + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static (string Certificate, string Key) LocateCertificate() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + string certificate = Path.Combine(dir.FullName, "tests", "docker", "certs", "server.crt"); + if (File.Exists(Path.Combine(dir.FullName, "QuickProxyNet.slnx")) && File.Exists(certificate)) + return (certificate, Path.ChangeExtension(certificate, ".key")); + + dir = dir.Parent; + } + + throw new FileNotFoundException( + $"Could not find tests/docker/certs/server.crt above '{AppContext.BaseDirectory}'."); + } + + /// Stops the server process this instance started — by handle, never by name. + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync(); + } + } + catch (InvalidOperationException) + { + // Already exited. + } + finally + { + _process.Dispose(); + } + } +} + +/// +/// A minimal HTTP server on loopback, used as the target the tunnel carries traffic to. +/// +/// +/// It reads the request before replying. Replying first lets the socket close while the client +/// is still sending, which surfaces on Windows as an RST that discards the queued response — +/// the same lesson the docker harness's serve.sh carries. +/// +public sealed class LoopbackEchoServer : IDisposable +{ + /// The body every request is answered with. + public const string Body = "QPN-ECHO-OK"; + + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + + private LoopbackEchoServer(TcpListener listener) + { + _listener = listener; + Port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = AcceptLoopAsync(); + } + + /// Port the server accepts on. + public int Port { get; } + + /// Starts accepting on an ephemeral loopback port. + public static LoopbackEchoServer Start() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return new LoopbackEchoServer(listener); + } + + private async Task AcceptLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(_cts.Token); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException) + { + return; + } + + _ = ServeAsync(client); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + { + try + { + NetworkStream stream = client.GetStream(); + + // The whole request head has to be drained before replying. Answering after the + // first read leaves the client still writing into a socket this side is about to + // close, which on Windows surfaces as an RST that discards the queued response — + // so a large request would fail while a small one passed. Same lesson as the + // docker harness's serve.sh. + if (!await DrainRequestAsync(stream)) + return; + + byte[] response = Encoding.ASCII.GetBytes( + "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + $"Content-Length: {Body.Length}\r\n" + + "Connection: close\r\n" + + "\r\n" + + Body); + + await stream.WriteAsync(response, _cts.Token); + await stream.FlushAsync(_cts.Token); + } + catch (Exception ex) when (ex is IOException or OperationCanceledException or SocketException) + { + // The tunnel went away; nothing to report. + } + } + } + + /// + /// Reads until the end of the HTTP request head, or the client stops sending. + /// + /// False when the client disconnected without sending a request. + private async Task DrainRequestAsync(NetworkStream stream) + { + var buffer = new byte[4096]; + int matched = 0; + bool sawAnything = false; + + while (true) + { + int read = await stream.ReadAsync(buffer, _cts.Token); + if (read <= 0) + return sawAnything; + + sawAnything = true; + + for (int i = 0; i < read; i++) + { + // Matching "\r\n\r\n" incrementally, since it can straddle two reads. + char expected = matched switch { 0 or 2 => '\r', _ => '\n' }; + matched = buffer[i] == expected ? matched + 1 : buffer[i] == '\r' ? 1 : 0; + + if (matched == 4) + return true; + } + } + } + + /// Stops accepting. + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + } +} diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs new file mode 100644 index 0000000..a0e4694 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs @@ -0,0 +1,147 @@ +using System.Net.Sockets; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// Proves the managed REALITY authentication against a real Xray-core server. +/// +/// +/// +/// The unit tests open our sealed session_id with the server algorithm transcribed by +/// hand. That catches layout mistakes, but a transcription can be wrong in the same way twice. +/// This test hands the bytes to the actual implementation and reads its verdict. +/// +/// +/// It deliberately stops after the ClientHello. Xray's REALITY server decrypts and validates the +/// session id the moment it has read the hello, long before any key schedule exists, and with +/// show on it says so. So the entire authentication half of REALITY can be proven while +/// the TLS 1.3 handshake is still unwritten — which is the only reason it is worth writing the +/// handshake at all. +/// +/// +public class ManagedRealityHandshakeTests +{ + private static string Executable => Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; + + /// Xray's own version triple; the server may gate on a minimum. + private static ReadOnlySpan ClientVersion => [26, 3, 27]; + + private static byte[] Base64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; + return Convert.FromBase64String(padded); + } + + /// Builds a sealed hello for and sends it. + private static async Task SendHelloAsync( + LocalRealityServer server, string? shortId = null, string? serverName = null) + { + TlsClientHello.Result hello = TlsClientHello.Build( + serverName ?? LocalRealityServer.ServerName, ["h2", "http/1.1"]); + + byte[] authKey = new byte[RealityAuth.AuthKeySize]; + RealityAuth.DeriveAuthKey( + authKey, + hello.PrivateKey, + Base64Url(LocalRealityServer.PublicKey), + hello.Handshake.AsSpan(6, 32)); + + byte[] parsedShortId = new byte[RealityAuth.ShortIdSize]; + RealityAuth.ParseShortId(parsedShortId, shortId ?? LocalRealityServer.ShortId); + + RealityAuth.SealSessionId( + hello.Handshake, + authKey, + parsedShortId, + (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ClientVersion); + + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", server.Port); + await client.GetStream().WriteAsync(TlsClientHello.ToRecord(hello.Handshake)); + await client.GetStream().FlushAsync(); + + // The server answers with the relayed ServerHello. We do not parse it yet; reading keeps + // the connection alive long enough for the server to finish logging its verdict. + byte[] scratch = new byte[4096]; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try + { + int read = await client.GetStream().ReadAsync(scratch, timeout.Token); + Assert.True(read >= 0); + } + catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException) + { + // Whether the relay answers is not what this test measures. + } + } + + /// Waits for a line to appear in the server's output. + private static async Task WaitForLogAsync(LocalRealityServer server, string marker) + { + long deadline = Environment.TickCount64 + 10_000; + while (Environment.TickCount64 < deadline) + { + if (server.Log().Contains(marker, StringComparison.Ordinal)) + return true; + + await Task.Delay(100); + } + + return false; + } + + /// + /// The milestone test: a ClientHello built entirely in managed code authenticates to Xray. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task HandBuiltClientHello_AuthenticatesToXray() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + + await SendHelloAsync(server); + + // Xray prints this once it has decrypted the session id and accepted the short id, the + // client version and the timestamp. 'true' means it treated us as a REALITY client rather + // than relaying us to the decoy as an unrecognised visitor. + Assert.True( + await WaitForLogAsync(server, "hs.c.conn == conn: true"), + $"Xray did not accept the hand-built ClientHello.\n{server.Log()}"); + } + + /// + /// The same hello with a short id the server does not know must be refused. + /// + /// + /// Without this, the test above could pass for the wrong reason: if the server logged + /// acceptance regardless of what we sent, it would prove nothing about our sealing. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task UnknownShortId_IsNotAccepted() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + + await SendHelloAsync(server, shortId: "cdef"); + + Assert.True( + await WaitForLogAsync(server, "hs.c.conn == conn: false"), + $"Xray never reported a verdict for the unknown short id.\n{server.Log()}"); + Assert.DoesNotContain("hs.c.conn == conn: true", server.Log()); + } + + /// + /// A hello for an SNI the server does not serve must not even reach the auth path. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task UnknownServerName_IsNotAccepted() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + + await SendHelloAsync(server, serverName: "not-configured.example"); + + Assert.False( + await WaitForLogAsync(server, "hs.c.conn == conn: true"), + $"Xray accepted a ClientHello for an SNI it does not serve.\n{server.Log()}"); + } +} diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs new file mode 100644 index 0000000..3be5afd --- /dev/null +++ b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs @@ -0,0 +1,234 @@ +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// The full managed stack: REALITY over a hand-written TLS 1.3, carrying VLESS, to a real target. +/// +/// +/// +/// The handshake tests prove Xray accepts us. These prove the connection is actually usable — +/// that the record layer survives real traffic in both directions, and that the bytes VLESS puts +/// on it arrive intact. +/// +/// +/// Everything is on loopback: the REALITY server, the decoy its handshake is borrowed from, and +/// the HTTP target. Nothing here depends on the internet. +/// +/// +public class ManagedRealityTunnelTests +{ + private static string Executable => Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; + + private static byte[] Base64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; + return Convert.FromBase64String(padded); + } + + /// Opens a VLESS tunnel to over managed REALITY. + private static async Task OpenTunnelAsync( + LocalRealityServer server, int targetPort, CancellationToken cancellationToken, string? flow = null) + { + var tcp = new TcpClient(); + await tcp.ConnectAsync("127.0.0.1", server.Port, cancellationToken); + + var tlsOptions = new RealityTlsOptions + { + ServerName = LocalRealityServer.ServerName, + PublicKey = Base64Url(LocalRealityServer.PublicKey), + ShortId = LocalRealityServer.ShortId, + Alpn = ["h2", "http/1.1"] + }; + + Stream tls = await RealityTlsClient.HandshakeAsync(tcp.GetStream(), tlsOptions, cancellationToken); + + var vless = new VlessOptions + { + Id = LocalRealityServer.Id, + Host = "127.0.0.1", + Port = server.Port, + Security = VlessSecurity.Reality, + Flow = flow + }; + + return await VlessHelper.EstablishVlessTunnelAsync(tls, vless, "127.0.0.1", targetPort, cancellationToken); + } + + /// + /// One request, whole response. On a timeout the failure carries Xray's own log, because + /// "the operation was canceled" after 30 seconds says nothing — the server's last lines + /// usually say everything (the 2026 finalRules blackhole took hours to find without them). + /// + private static async Task GetAsync( + LocalRealityServer server, Stream tunnel, string path, CancellationToken cancellationToken) + { + byte[] request = Encoding.ASCII.GetBytes( + $"GET {path} HTTP/1.1\r\nHost: qpn.test\r\nConnection: close\r\n\r\n"); + await tunnel.WriteAsync(request, cancellationToken); + await tunnel.FlushAsync(cancellationToken); + + try + { + using var reader = new StreamReader(tunnel, Encoding.ASCII); + return await reader.ReadToEndAsync(cancellationToken); + } + catch (OperationCanceledException) + { + Assert.Fail($"No response through the tunnel before the timeout. Xray said:\n{server.Log()}"); + throw; + } + } + + /// + /// The same tunnel, but opened the way a user opens it: a share link into + /// , 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. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task PublicApi_ShareLinkThroughFactory_CarriesVlessOverReality() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + IProxyClient client = ProxyClientFactory.Instance.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)); + } + + /// The one-liner, with Vision on — the configuration real nodes almost always have. + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task PublicApi_ProxyConnectAsyncString_CarriesVlessOverRealityWithVision() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, VisionStream.FlowName); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await using Stream tunnel = await Proxy.ConnectAsync( + server.ShareLink(), "127.0.0.1", echo.Port, TimeSpan.FromSeconds(30), timeout.Token); + + string response = await GetAsync(server, tunnel, "/" + new string('a', 40_000), timeout.Token); + + Assert.Contains(LoopbackEchoServer.Body, response); + } + + /// + /// A wrong public key through the public path must surface as the proxy error it is — + /// , the "check pbk/sid/sni" signal — not as some + /// other type the client's unwinding happened to wrap it in. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task PublicApi_WrongPublicKey_IsAuthFailed() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + // Valid base64url for 32 bytes, and not the server's key. + string link = server.ShareLink().Replace($"pbk={LocalRealityServer.PublicKey}", "pbk=LmsbBDEPXyy3PS0kYTdC55wlSCqteIEaw6trnKcMUeE"); + + var ex = await Assert.ThrowsAsync(async () => + await Proxy.ConnectAsync(link, "127.0.0.1", 80, timeout.Token)); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + /// + /// The end of the road: bytes go out through managed REALITY and the answer comes back. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task ManagedReality_CarriesVlessToATarget() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await using Stream tunnel = await OpenTunnelAsync(server, echo.Port, timeout.Token); + + Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); + } + + /// + /// A payload larger than one TLS record, to prove records are split and reassembled rather + /// than silently truncated at the 16 KiB boundary. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task ManagedReality_CarriesPayloadsAcrossRecordBoundaries() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await using Stream tunnel = await OpenTunnelAsync(server, echo.Port, timeout.Token); + + // Comfortably past TlsRecordStream.MaxPlaintext, so the write path has to emit several + // records and the server has to reassemble them. + string response = await GetAsync(server, tunnel, "/" + new string('a', 40_000), timeout.Token); + + Assert.Contains(LoopbackEchoServer.Body, response); + } + + /// + /// Several tunnels over separate connections, to catch state that leaks between handshakes. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task ManagedReality_SupportsSequentialTunnels() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + + for (int attempt = 0; attempt < 3; attempt++) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await using Stream tunnel = await OpenTunnelAsync(server, echo.Port, timeout.Token); + + Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); + } + } + + /// + /// The same tunnel against a server whose user requires xtls-rprx-vision — the + /// configuration almost every REALITY node in the wild uses. + /// + /// + /// Without the flow in the addons, Xray drops the connection outright; with the flow but no + /// unpadding, the response arrives wrapped in padding frames. Both failures are what this + /// asserts against, so the assertion has to be on the exact body, not on "some bytes came + /// back". + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task ManagedReality_WithVisionFlow_CarriesVlessToATarget() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, VisionStream.FlowName); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await using Stream tunnel = + await OpenTunnelAsync(server, echo.Port, timeout.Token, VisionStream.FlowName); + + Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); + } + + /// + /// Vision again, with a response too large to fit the padded frames — the part a client that + /// only unwraps the first frame gets wrong. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task ManagedReality_WithVisionFlow_CarriesPayloadsPastTheFramedPrefix() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, VisionStream.FlowName); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await using Stream tunnel = + await OpenTunnelAsync(server, echo.Port, timeout.Token, VisionStream.FlowName); + + string response = await GetAsync(server, tunnel, "/" + new string('a', 40_000), timeout.Token); + + Assert.Contains(LoopbackEchoServer.Body, response); + } +} diff --git a/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs new file mode 100644 index 0000000..6dba352 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs @@ -0,0 +1,124 @@ +using System.Net.Sockets; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// Drives the managed TLS 1.3 client through a complete REALITY handshake with real Xray-core. +/// +/// +/// This is the test the whole managed implementation exists to pass. Everything below the +/// handshake — the curve, the key schedule, the record layer — is verified against published +/// vectors elsewhere; only an actual server can show that they compose into something Xray will +/// talk to. +/// +public class ManagedTlsHandshakeTests +{ + private static string Executable => Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; + + private static byte[] Base64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; + return Convert.FromBase64String(padded); + } + + private static RealityTlsOptions Options(string? publicKey = null, string? shortId = null) => new() + { + ServerName = LocalRealityServer.ServerName, + PublicKey = Base64Url(publicKey ?? LocalRealityServer.PublicKey), + ShortId = shortId ?? LocalRealityServer.ShortId, + Alpn = ["h2", "http/1.1"] + }; + + private static async Task ConnectAsync(LocalRealityServer server) + { + var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", server.Port); + return client; + } + + /// + /// The milestone: a TLS 1.3 handshake written from scratch, authenticated by REALITY, + /// completed against the reference server. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task ManagedHandshake_CompletesAgainstXray() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + using TcpClient tcp = await ConnectAsync(server); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + await using Stream tls = await RealityTlsClient.HandshakeAsync( + tcp.GetStream(), Options(), timeout.Token); + + // Reaching here means: the server's Finished verified against our key schedule, and its + // certificate was bound to our REALITY shared secret by HMAC. Both are unforgeable + // without the server's private key. + Assert.True(tls.CanRead); + Assert.True(tls.CanWrite); + } + + /// + /// With the wrong public key the server relays us to the decoy, and the client must refuse + /// rather than tunnel. + /// + /// + /// This is the case that decides whether the implementation is safe to use at all. A client + /// that cannot tell the REALITY server from the borrowed site would send the VLESS id to + /// whatever answered. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task WrongPublicKey_IsRefusedRatherThanTunnelled() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + using TcpClient tcp = await ConnectAsync(server); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + // Valid base64url for 32 bytes, and not the server's key. + const string wrongKey = "LmsbBDEPXyy3PS0kYTdC55wlSCqteIEaw6trnKcMUeE"; + + var ex = await Assert.ThrowsAsync( + async () => await RealityTlsClient.HandshakeAsync(tcp.GetStream(), Options(wrongKey), timeout.Token)); + + Assert.Contains("REALITY", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + /// + /// A short id the server does not know must fail the same way: relayed to the decoy, refused. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task UnknownShortId_IsRefused() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + using TcpClient tcp = await ConnectAsync(server); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + var ex = await Assert.ThrowsAsync( + async () => await RealityTlsClient.HandshakeAsync( + tcp.GetStream(), Options(shortId: "cdef"), timeout.Token)); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + /// + /// Two handshakes in a row must both succeed: the ephemeral key, the client random and the + /// timestamp all change per connection, and a stale value anywhere would show up here. + /// + [EnvFact(LocalRealityServer.ExecutablePathVariable)] + public async Task RepeatedHandshakes_AllSucceed() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); + + for (int attempt = 0; attempt < 3; attempt++) + { + using TcpClient tcp = await ConnectAsync(server); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + await using Stream tls = await RealityTlsClient.HandshakeAsync( + tcp.GetStream(), Options(), timeout.Token); + + Assert.True(tls.CanRead); + } + } +} diff --git a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs new file mode 100644 index 0000000..0fae083 --- /dev/null +++ b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs @@ -0,0 +1,223 @@ +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/QuickProxyNet.Tests.csproj b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj index 511c421..506c132 100644 --- a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +++ b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj @@ -1,7 +1,9 @@ - net10.0 + + net10.0;net11.0 enable enable diff --git a/QuickProxyNet.Tests/RealityAuthTest.cs b/QuickProxyNet.Tests/RealityAuthTest.cs new file mode 100644 index 0000000..655f235 --- /dev/null +++ b/QuickProxyNet.Tests/RealityAuthTest.cs @@ -0,0 +1,240 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet.Tests; + +/// +/// Tests for the REALITY authentication construction. +/// +/// +/// +/// Each test seals with our client code and opens with the server algorithm transcribed +/// from XTLS/REALITY's tls.go, written out separately below. A round-trip against +/// our own sealing routine would prove only that the code agrees with itself; opening with the +/// server's steps is what pins the field layout, the nonce, and the AAD. +/// +/// +/// It is still not the same as talking to a real server — that is what the integration tests are +/// for — but it is the part that can be made exact without a network. +/// +/// +public class RealityAuthTest +{ + private static readonly byte[] ClientVersion = [26, 3, 27]; + + private static byte[] Base64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; + return Convert.FromBase64String(padded); + } + + /// + /// Builds a buffer shaped like a raw ClientHello: handshake header, legacy version, random, + /// and a 32-byte session id, followed by arbitrary trailing bytes that stand in for the rest + /// of the message and are covered by the AAD. + /// + private static byte[] FakeClientHello(int trailingBytes = 200) + { + byte[] hello = new byte[RealityAuth.SessionIdOffset + RealityAuth.SessionIdSize + trailingBytes]; + + hello[0] = 1; // handshake type: client_hello + BinaryPrimitives.WriteUInt32BigEndian(hello.AsSpan(0), (uint)(hello.Length - 4)); + hello[0] = 1; + hello[4] = 3; + hello[5] = 3; // legacy_version + + RandomNumberGenerator.Fill(hello.AsSpan(6, 32)); // random + hello[38] = RealityAuth.SessionIdSize; // session id length + RandomNumberGenerator.Fill(hello.AsSpan(RealityAuth.SessionIdOffset + RealityAuth.SessionIdSize)); + + return hello; + } + + /// + /// The server side, transcribed from XTLS/REALITY tls.go: derive the key from + /// the server's private key and the client's key_share, then open the session id with + /// the hello — session id zeroed — as additional data. + /// + private static bool TryOpenAsServer( + ReadOnlySpan hello, + ReadOnlySpan serverPrivateKey, + ReadOnlySpan clientPublicKey, + Span plaintext, + out byte[] authKey) + { + authKey = new byte[RealityAuth.AuthKeySize]; + Span shared = stackalloc byte[32]; + X25519.Agree(shared, serverPrivateKey, clientPublicKey); + + ReadOnlySpan clientRandom = hello.Slice(6, 32); + HKDF.DeriveKey(HashAlgorithmName.SHA256, shared, authKey, clientRandom[..20], "REALITY"u8); + + byte[] ciphertext = hello.Slice(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize).ToArray(); + + byte[] additionalData = hello.ToArray(); + additionalData.AsSpan(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize).Clear(); + + using var aes = new AesGcm(authKey, tagSizeInBytes: 16); + try + { + aes.Decrypt(clientRandom[20..], ciphertext.AsSpan(0, 16), ciphertext.AsSpan(16), plaintext, additionalData); + return true; + } + catch (AuthenticationTagMismatchException) + { + return false; + } + } + + [Fact] + public void SealedSessionId_OpensWithTheServerAlgorithm() + { + byte[] serverPrivate = Base64Url(Integration.LocalRealityServer.PrivateKey); + byte[] serverPublic = Base64Url(Integration.LocalRealityServer.PublicKey); + + byte[] clientPrivate = new byte[32], clientPublic = new byte[32]; + X25519.GenerateKeyPair(clientPrivate, clientPublic); + + byte[] hello = FakeClientHello(); + byte[] clientAuthKey = new byte[RealityAuth.AuthKeySize]; + RealityAuth.DeriveAuthKey(clientAuthKey, clientPrivate, serverPublic, hello.AsSpan(6, 32)); + + byte[] shortId = new byte[RealityAuth.ShortIdSize]; + RealityAuth.ParseShortId(shortId, Integration.LocalRealityServer.ShortId); + + uint timestamp = 1_760_000_000; + RealityAuth.SealSessionId(hello, clientAuthKey, shortId, timestamp, ClientVersion); + + byte[] plaintext = new byte[16]; + Assert.True(TryOpenAsServer(hello, serverPrivate, clientPublic, plaintext, out byte[] serverAuthKey)); + + // Both sides must land on the same key, or nothing downstream can work. + Assert.Equal(clientAuthKey, serverAuthKey); + + Assert.Equal(ClientVersion, plaintext[..3]); + Assert.Equal(0, plaintext[3]); + Assert.Equal(timestamp, BinaryPrimitives.ReadUInt32BigEndian(plaintext.AsSpan(4))); + Assert.Equal(shortId, plaintext[8..16]); + } + + /// + /// The AAD binds the blob to this exact ClientHello. Without it a censor could lift a sealed + /// session id out of a recorded handshake and replay it inside a hello of its own choosing. + /// + [Fact] + public void TamperingWithTheHello_BreaksTheTag() + { + byte[] serverPrivate = Base64Url(Integration.LocalRealityServer.PrivateKey); + byte[] serverPublic = Base64Url(Integration.LocalRealityServer.PublicKey); + + byte[] clientPrivate = new byte[32], clientPublic = new byte[32]; + X25519.GenerateKeyPair(clientPrivate, clientPublic); + + byte[] hello = FakeClientHello(); + byte[] authKey = new byte[RealityAuth.AuthKeySize]; + RealityAuth.DeriveAuthKey(authKey, clientPrivate, serverPublic, hello.AsSpan(6, 32)); + + byte[] shortId = new byte[RealityAuth.ShortIdSize]; + RealityAuth.ParseShortId(shortId, "ab12"); + RealityAuth.SealSessionId(hello, authKey, shortId, 1_760_000_000, ClientVersion); + + // A single flipped bit anywhere past the session id. + hello[^1] ^= 0x01; + + byte[] plaintext = new byte[16]; + Assert.False(TryOpenAsServer(hello, serverPrivate, clientPublic, plaintext, out _)); + } + + [Fact] + public void WrongServerKey_DoesNotOpen() + { + byte[] serverPrivate = Base64Url(Integration.LocalRealityServer.PrivateKey); + + // A different server key: the client seals for someone else, so this server cannot open it. + byte[] otherPrivate = new byte[32], otherPublic = new byte[32]; + X25519.GenerateKeyPair(otherPrivate, otherPublic); + + byte[] clientPrivate = new byte[32], clientPublic = new byte[32]; + X25519.GenerateKeyPair(clientPrivate, clientPublic); + + byte[] hello = FakeClientHello(); + byte[] authKey = new byte[RealityAuth.AuthKeySize]; + RealityAuth.DeriveAuthKey(authKey, clientPrivate, otherPublic, hello.AsSpan(6, 32)); + + byte[] shortId = new byte[RealityAuth.ShortIdSize]; + RealityAuth.ParseShortId(shortId, "ab12"); + RealityAuth.SealSessionId(hello, authKey, shortId, 1_760_000_000, ClientVersion); + + byte[] plaintext = new byte[16]; + Assert.False(TryOpenAsServer(hello, serverPrivate, clientPublic, plaintext, out _)); + } + + [Fact] + public void SealSessionId_RequiresAFullLengthSessionId() + { + byte[] hello = FakeClientHello(); + hello[38] = 0; // a client that sends no session id + + byte[] authKey = new byte[RealityAuth.AuthKeySize]; + byte[] shortId = new byte[RealityAuth.ShortIdSize]; + + var ex = Assert.Throws( + () => RealityAuth.SealSessionId(hello, authKey, shortId, 0, ClientVersion)); + + Assert.Contains("session id", ex.Message); + } + + [Fact] + public void VerifyCertificate_AcceptsTheServersHmac() + { + byte[] authKey = RandomNumberGenerator.GetBytes(32); + byte[] publicKey = RandomNumberGenerator.GetBytes(32); + byte[] signature = HMACSHA512.HashData(authKey, publicKey); + + Assert.True(RealityAuth.VerifyCertificate(authKey, publicKey, signature)); + } + + [Fact] + public void VerifyCertificate_RejectsAnyoneElse() + { + byte[] authKey = RandomNumberGenerator.GetBytes(32); + byte[] publicKey = RandomNumberGenerator.GetBytes(32); + byte[] signature = HMACSHA512.HashData(RandomNumberGenerator.GetBytes(32), publicKey); + + Assert.False(RealityAuth.VerifyCertificate(authKey, publicKey, signature)); + } + + // A real certificate from the decoy site carries an ordinary signature of the wrong length; + // that must be a plain "no", not an exception. + [Fact] + public void VerifyCertificate_RejectsAWrongLengthSignature() + { + byte[] authKey = RandomNumberGenerator.GetBytes(32); + + Assert.False(RealityAuth.VerifyCertificate(authKey, new byte[32], new byte[256])); + } + + [Theory] + [InlineData("", "0000000000000000")] + [InlineData("ab12", "ab12000000000000")] + [InlineData("0123456789abcdef", "0123456789abcdef")] + public void ParseShortId_PadsToEightBytes(string hex, string expected) + { + byte[] shortId = new byte[RealityAuth.ShortIdSize]; + RealityAuth.ParseShortId(shortId, hex); + + Assert.Equal(expected, Convert.ToHexString(shortId).ToLowerInvariant()); + } + + [Theory] + [InlineData("abc")] + [InlineData("zz")] + [InlineData("0123456789abcdef00")] + public void ParseShortId_RejectsMalformedInput(string hex) + { + byte[] shortId = new byte[RealityAuth.ShortIdSize]; + + Assert.Throws(() => RealityAuth.ParseShortId(shortId, hex)); + } +} diff --git a/QuickProxyNet.Tests/Sha224Test.cs b/QuickProxyNet.Tests/Sha224Test.cs new file mode 100644 index 0000000..ba0e766 --- /dev/null +++ b/QuickProxyNet.Tests/Sha224Test.cs @@ -0,0 +1,147 @@ +using System.Text; + +namespace QuickProxyNet.Tests; + +public class Sha224Test +{ + /// + /// Asserts the vector against BOTH code paths: the public dispatching entry (which + /// takes the Vector128 schedule path on accelerated hardware) and the forced scalar + /// fallback. On a machine without Vector128 acceleration both calls run scalar. + /// + private static void AssertDigest(ReadOnlySpan data, string expectedHex) + { + byte[] expected = Convert.FromHexString(expectedHex); + + Span digest = stackalloc byte[Sha224.HashSize]; + Sha224.ComputeHash(data, digest); + Assert.Equal(expected, digest.ToArray()); + + digest.Clear(); + Sha224.ComputeHashScalar(data, digest); + Assert.Equal(expected, digest.ToArray()); + } + + // === FIPS 180-4 / NIST CAVP vectors === + + [Fact] + public void ComputeHash_EmptyInput_MatchesNistVector() + => AssertDigest( + ReadOnlySpan.Empty, + "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f"); + + [Fact] + public void ComputeHash_Abc_MatchesNistVector() + => AssertDigest( + "abc"u8, + "23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7"); + + [Fact] + public void ComputeHash_56ByteInput_PaddingSpillsIntoSecondBlock() + // 56 bytes: tail + 0x80 + 8-byte length does not fit in one 64-byte block, + // so the padding must roll over into a second block. + => AssertDigest( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"u8, + "75388b16512776cc5dba5da1fd890150b0c6455cb4f58b1952522525"); + + [Fact] + public void ComputeHash_112ByteInput_MultiBlock_MatchesNistVector() + // Official NIST two-block message sample (112 bytes > 64 exercises the + // full-block loop before padding). + => AssertDigest( + "abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmno"u8 + + "ijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu"u8, + "c97ca9a559850ce97a04a96def6d99a9e0e0e2ab14e6b8df265fc0b3"); + + // === Independent oracle (OpenSSL) for block-boundary and long inputs === + + [Fact] + public void ComputeHash_63Bytes_LastSingleBlockBoundary() + { + Span data = stackalloc byte[63]; + data.Fill((byte)'x'); + AssertDigest(data, "57176f335e39202a5454db924c660af77ec98a91f35706d9f57d7398"); + } + + [Fact] + public void ComputeHash_64Bytes_ExactBlockThenPaddingOnlyBlock() + { + Span data = stackalloc byte[64]; + data.Fill((byte)'x'); + AssertDigest(data, "08c3050e95fe11eacb9dc7824bf6a92bcf2d59c21701321fba0e62c5"); + } + + [Fact] + public void ComputeHash_200Bytes_MultiBlock() + { + Span data = stackalloc byte[200]; + data.Fill((byte)'a'); + AssertDigest(data, "2559984fd15e055f0d84c346483508242f02653ab7956401e551511c"); + } + + // === Scalar vs vectorized schedule: exhaustive length sweep 0..192 === + + [Fact] + public void ComputeHash_VectorAndScalarPathsAgree_AllLengthsToThreeBlocks() + { + // On accelerated hardware this cross-checks the Vector128 schedule against the + // scalar one for every input length up to three blocks; without acceleration it + // degenerates to scalar == scalar and stays green. + Span data = stackalloc byte[192]; + for (int i = 0; i < data.Length; i++) + data[i] = (byte)(i * 31 + 7); + + Span viaDispatch = stackalloc byte[Sha224.HashSize]; + Span viaScalar = stackalloc byte[Sha224.HashSize]; + for (int len = 0; len <= data.Length; len++) + { + Sha224.ComputeHash(data.Slice(0, len), viaDispatch); + Sha224.ComputeHashScalar(data.Slice(0, len), viaScalar); + Assert.Equal(viaScalar.ToArray(), viaDispatch.ToArray()); + } + } + + // === WriteHexLower === + + [Fact] + public void WriteHexLower_Abc_Produces56LowercaseHexBytes() + { + Span hex = stackalloc byte[Sha224.HexSize]; + Sha224.WriteHexLower("abc"u8, hex); + Assert.Equal( + "23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7", + Encoding.ASCII.GetString(hex)); + } + + [Fact] + public void WriteHexLower_Empty_Produces56LowercaseHexBytes() + { + Span hex = stackalloc byte[Sha224.HexSize]; + Sha224.WriteHexLower(ReadOnlySpan.Empty, hex); + Assert.Equal( + "d14a028c2a3a2bc9476102bb288234c415a2b01f828ea62ac5b3e42f", + Encoding.ASCII.GetString(hex)); + } + + // === Destination validation === + + [Fact] + public void ComputeHash_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[Sha224.HashSize - 1]; + Sha224.ComputeHash("abc"u8, small); + }); + } + + [Fact] + public void WriteHexLower_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[Sha224.HexSize - 1]; + Sha224.WriteHexLower("abc"u8, small); + }); + } +} diff --git a/QuickProxyNet.Tests/Sha256CoreTest.cs b/QuickProxyNet.Tests/Sha256CoreTest.cs new file mode 100644 index 0000000..e76ba53 --- /dev/null +++ b/QuickProxyNet.Tests/Sha256CoreTest.cs @@ -0,0 +1,222 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet.Tests; + +/// +/// Covers the SHA-2/32 core shared by and , including +/// the midstate resume surface that MidstateVmessKdf in the benchmark project builds on. +/// +/// +/// The BCL is the independent oracle here: and +/// come from the OS crypto stack, so agreeing with them across a full length sweep pins both +/// the shared compression function and the resume logic. FIPS 180-4 vectors are asserted +/// separately so a hypothetical BCL change cannot make a wrong implementation look right. +/// +public class Sha256CoreTest +{ + private static byte[] Pattern(int length) + { + byte[] data = new byte[length]; + for (int i = 0; i < length; i++) + data[i] = (byte)(i * 37 + 11); + return data; + } + + // === SHA-256: FIPS 180-4 vectors === + + [Theory] + [InlineData("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [InlineData("abc", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")] + [InlineData("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1")] + public void ComputeHash_MatchesNistVector(string input, string expectedHex) + { + byte[] data = System.Text.Encoding.ASCII.GetBytes(input); + byte[] expected = Convert.FromHexString(expectedHex); + + Span digest = stackalloc byte[Sha256.HashSize]; + Sha256.ComputeHash(data, digest); + Assert.Equal(expected, digest.ToArray()); + + digest.Clear(); + Sha256.ComputeHashScalar(data, digest); + Assert.Equal(expected, digest.ToArray()); + } + + // === SHA-256: length sweep against the BCL, both code paths === + + [Fact] + public void ComputeHash_AgreesWithBcl_AllLengthsToThreeBlocks() + { + Span viaDispatch = stackalloc byte[Sha256.HashSize]; + Span viaScalar = stackalloc byte[Sha256.HashSize]; + + for (int length = 0; length <= 192; length++) + { + byte[] data = Pattern(length); + byte[] expected = SHA256.HashData(data); + + Sha256.ComputeHash(data, viaDispatch); + Sha256.ComputeHashScalar(data, viaScalar); + + Assert.Equal(expected, viaDispatch.ToArray()); + Assert.Equal(expected, viaScalar.ToArray()); + } + } + + [Fact] + public void ComputeHash_AgreesWithBcl_LargeInput() + { + byte[] data = Pattern(9_001); + Span digest = stackalloc byte[Sha256.HashSize]; + Sha256.ComputeHash(data, digest); + Assert.Equal(SHA256.HashData(data), digest.ToArray()); + } + + [Fact] + public void ComputeHash_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[Sha256.HashSize - 1]; + Sha256.ComputeHash("abc"u8, small); + }); + } + + // === Midstate resume: absorbing blocks then finishing must equal a one-shot hash === + + [Fact] + public void AbsorbThenFinish_EqualsOneShot_ForEveryBlockSplit() + { + // The whole point of exposing the state is that a caller may compress some leading + // blocks, keep the eight-word midstate, and resume later. Every split must reproduce + // the one-shot digest, including the total-length bookkeeping in the padding. + byte[] data = Pattern(300); + byte[] expected = SHA256.HashData(data); + + Span state = stackalloc uint[Sha256Core.StateWords]; + Span schedule = stackalloc uint[Sha256Core.ScheduleWords]; + Span digest = stackalloc byte[Sha256Core.DigestSize]; + + for (int prefixBlocks = 0; prefixBlocks * 64 <= data.Length; prefixBlocks++) + { + int prefix = prefixBlocks * 64; + + Sha256Core.Sha256Iv.CopyTo(state); + Sha256Core.Absorb(state, data.AsSpan(0, prefix), schedule, vectorize: true); + Sha256Core.Finish(state, data.AsSpan(prefix), (ulong)data.Length, schedule, vectorize: true); + Sha256Core.WriteDigest(state, digest, Sha256Core.DigestSize); + + Assert.Equal(expected, digest.ToArray()); + } + } + + // === Midstate-resumed HMAC === + + private static byte[] BclSeedHmac(byte[] message) + => HMACSHA256.HashData("VMess AEAD KDF"u8.ToArray(), message); + + /// + /// Builds HMAC(seed, prefix ‖ tail) the way a midstate consumer would: compress the pad + /// block once, keep the state, resume over the prefix, then finish over the tail. + /// + private static byte[] ResumedSeedHmac(byte[] prefix, byte[] tail) + { + const int blockSize = Sha256Core.BlockSize; + + Span schedule = stackalloc uint[Sha256Core.ScheduleWords]; + Span state = stackalloc uint[Sha256Core.StateWords]; + Span pad = stackalloc byte[blockSize]; + Span innerDigest = stackalloc byte[Sha256Core.DigestSize]; + byte[] result = new byte[Sha256Core.DigestSize]; + + // Inner pass: (seed ⊕ ipad) as a reusable midstate, then prefix, then tail. + pad.Clear(); + "VMess AEAD KDF"u8.CopyTo(pad); + for (int i = 0; i < blockSize; i++) + pad[i] ^= 0x36; + + Sha256Core.Sha256Iv.CopyTo(state); + Sha256Core.Absorb(state, pad, schedule, vectorize: true); + Sha256Core.Absorb(state, prefix, schedule, vectorize: true); + Sha256Core.Finish( + state, tail, (ulong)(blockSize + prefix.Length + tail.Length), schedule, vectorize: true); + Sha256Core.WriteDigest(state, innerDigest, Sha256Core.DigestSize); + + // Outer pass: (seed ⊕ opad) then the 32-byte inner digest. + pad.Clear(); + "VMess AEAD KDF"u8.CopyTo(pad); + for (int i = 0; i < blockSize; i++) + pad[i] ^= 0x5C; + + Sha256Core.Sha256Iv.CopyTo(state); + Sha256Core.Absorb(state, pad, schedule, vectorize: true); + Sha256Core.Finish( + state, innerDigest, (ulong)(blockSize + Sha256Core.DigestSize), schedule, vectorize: true); + Sha256Core.WriteDigest(state, result, Sha256Core.DigestSize); + + return result; + } + + [Fact] + public void ResumedHmac_AgreesWithBclHmac_AllMessageLengthsToThreeBlocks() + { + // If the resumed state or the total-length bookkeeping were wrong by a single bit, + // every one of these would fail. + for (int length = 0; length <= 200; length++) + { + byte[] message = Pattern(length); + Assert.Equal(BclSeedHmac(message), ResumedSeedHmac([], message)); + } + } + + [Fact] + public void ResumedHmac_ConstantPrefixSplit_EqualsHashOfConcatenation() + { + // Folding constant leading blocks into the midstate and resuming must equal hashing + // prefix ‖ tail in one go, for every split. + foreach (int prefixBlocks in new[] { 0, 1, 2, 3 }) + { + byte[] prefix = Pattern(prefixBlocks * Sha256Core.BlockSize); + + for (int tailLength = 0; tailLength <= 140; tailLength += 7) + { + byte[] tail = Pattern(tailLength); + byte[] concatenated = [.. prefix, .. tail]; + Assert.Equal(BclSeedHmac(concatenated), ResumedSeedHmac(prefix, tail)); + } + } + } + + // === The KDF built on top must still be the textbook nested construction === + + [Fact] + public void Kdf16_SinglePathElement_MatchesHandRolledNestedHmac() + { + // Reference: KDF(key, label) = HMAC_label(HMAC_seed)(key), expanded by hand with the + // BCL as the only hash primitive. Independent of every midstate in the library. + byte[] key = Pattern(16); + byte[] label = "VMess Header AEAD Key_Length"u8.ToArray(); + + byte[] expected = NestedHmac(label, key); + + Span actual = stackalloc byte[16]; + VmessKdf.Kdf16(key, label, actual); + Assert.Equal(expected.AsSpan(0, 16).ToArray(), actual.ToArray()); + } + + private static byte[] NestedHmac(byte[] pathElement, byte[] message) + { + byte[] ipad = new byte[64]; + byte[] opad = new byte[64]; + for (int i = 0; i < 64; i++) + { + byte k = i < pathElement.Length ? pathElement[i] : (byte)0; + ipad[i] = (byte)(k ^ 0x36); + opad[i] = (byte)(k ^ 0x5C); + } + + byte[] inner = BclSeedHmac([.. ipad, .. message]); + return BclSeedHmac([.. opad, .. inner]); + } +} diff --git a/QuickProxyNet.Tests/SkipGates.cs b/QuickProxyNet.Tests/SkipGates.cs new file mode 100644 index 0000000..675b43e --- /dev/null +++ b/QuickProxyNet.Tests/SkipGates.cs @@ -0,0 +1,133 @@ +namespace QuickProxyNet.Tests; + +/// +/// Environment switches that gate the integration tests, and the one place that reads them. +/// +/// +/// +/// Why attributes and not a runtime Assert.Skip. xunit.assert 2.9.3 does export +/// Xunit.Sdk.SkipException.ForSkip(string), so throw SkipException.ForSkip(...) +/// compiles — but it does not work. Dynamic skip is a v3 feature: the $XunitDynamicSkip$ +/// message token appears in no v2 assembly (verified by scanning xunit.core 2.9.3, +/// xunit.execution.dotnet 2.9.3 and xunit.runner.visualstudio 3.0.0), so v2 reports the throw as +/// a plain failure with the raw token in the message. Assert.Skip / +/// Assert.SkipWhen / Assert.SkipUnless are not even present in the 2.9.3 assembly — +/// they sit behind the XUNIT_SKIP compilation define that only v3 sets. +/// +/// +/// What v2 does support is decided at discovery time. +/// Environment variables do not change during a test run, so evaluating the gate in the +/// attribute constructor is exact, and it produces a real skipped result. +/// +/// +/// The rule this enforces: a test whose prerequisites are missing reports as skipped, +/// never as passed. An early return turns "did not run" into "green", which is how a +/// suite starts lying about what it proves. +/// +/// +internal static class SkipGates +{ + /// Set to 1 to run the docker-backed protocol integration tests. + public const string DockerSwitch = "QPN_DOCKER_TESTS"; + + /// True when the docker integration tests are enabled. + public static bool DockerEnabled => + string.Equals(Environment.GetEnvironmentVariable(DockerSwitch), "1", StringComparison.Ordinal); + + /// True when is set to a non-empty value. + public static bool IsSet(string name) => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name)); + + /// + /// The skip reason for a test that needs all of , or null + /// when they are all set. + /// + public static string? RequireAll(string[] names) + { + List? missing = null; + foreach (string name in names) + { + if (!IsSet(name)) + (missing ??= []).Add(name); + } + + return missing is null ? null : $"Not set: {string.Join(", ", missing)}."; + } + + /// + /// The skip reason for a test that needs any of , or null + /// when at least one is set. + /// + public static string? RequireAny(string[] names) + { + foreach (string name in names) + { + if (IsSet(name)) + return null; + } + + return $"None of these is set: {string.Join(", ", names)}."; + } +} + +/// +/// A that reports the test as skipped unless every named +/// environment variable is set to a non-empty value. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class EnvFactAttribute : FactAttribute +{ + /// Environment variables that must all be set. + public EnvFactAttribute(params string[] requiredVariables) => + Skip = SkipGates.RequireAll(requiredVariables); +} + +/// +/// A that reports the test as skipped unless every named +/// environment variable is set to a non-empty value. The theory counterpart of +/// : a [Theory] that returns early when the variable is +/// missing reports as passed, which is the one outcome a gate exists to prevent. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class EnvTheoryAttribute : TheoryAttribute +{ + /// Environment variables that must all be set. + public EnvTheoryAttribute(params string[] requiredVariables) => + Skip = SkipGates.RequireAll(requiredVariables); +} + +/// +/// A that reports the test as skipped unless at least one of +/// the named environment variables is set to a non-empty value. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class AnyEnvFactAttribute : FactAttribute +{ + /// Environment variables, any one of which enables the test. + public AnyEnvFactAttribute(params string[] acceptedVariables) => + Skip = SkipGates.RequireAny(acceptedVariables); +} + +/// +/// A gated on QPN_DOCKER_TESTS=1. Reports as skipped +/// when the docker integration tests are not enabled. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class DockerFactAttribute : FactAttribute +{ + /// Creates the attribute, deciding the skip state from the environment. + public DockerFactAttribute() => + Skip = SkipGates.DockerEnabled ? null : $"{SkipGates.DockerSwitch} is not set to 1."; +} + +/// +/// A gated on QPN_DOCKER_TESTS=1. Reports as skipped +/// when the docker integration tests are not enabled. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class DockerTheoryAttribute : TheoryAttribute +{ + /// Creates the attribute, deciding the skip state from the environment. + public DockerTheoryAttribute() => + Skip = SkipGates.DockerEnabled ? null : $"{SkipGates.DockerSwitch} is not set to 1."; +} diff --git a/QuickProxyNet.Tests/TlsKeyScheduleTest.cs b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs new file mode 100644 index 0000000..87e22cd --- /dev/null +++ b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs @@ -0,0 +1,138 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet.Tests; + +/// +/// Tests the TLS 1.3 key schedule against RFC 8448's published trace. +/// +/// +/// RFC 8448 prints every intermediate value of a real handshake, which makes the key schedule one +/// of the few parts of this work that can be verified completely offline. The values below are +/// from §3, "Simple 1-RTT Handshake", with TLS_AES_128_GCM_SHA256. +/// +public class TlsKeyScheduleTest +{ + private static readonly HashAlgorithmName Sha256 = HashAlgorithmName.SHA256; + + private static byte[] Hex(string hex) => + Convert.FromHexString(hex.Replace(" ", "").Replace("\n", "")); + + private static string Show(ReadOnlySpan bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); + + private const string EarlySecret = "33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a"; + private const string DerivedForHandshake = "6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba"; + private const string EcdheSharedSecret = "8bd4054fb55b9d63fdfbacf9f04b9f0d35e6d63f537563efd46272900f89492d"; + private const string HandshakeSecret = "1dc826e93606aa6fdc0aadc12f741b01046aa6b99f691ed221a9f0ca043fbeac"; + private const string DerivedForMaster = "43de77e0c77713859a944db9db2590b53190a65b3ee2e4f12dd7a0bb7ce254b4"; + private const string MasterSecret = "18df06843d13a08bf2a449844c5f8a478001bc4d4c627984d5a41da8d0402919"; + private const string ClientHandshakeTraffic = "b3eddb126e067f35a780b3abf45e2d8f3b1a950738f52e9600746a0e27a55a21"; + private const string ServerHandshakeTraffic = "b67b7d690cc16c4e75e54213cb2d37b4e9c912bcded9105d42befd59d391ad38"; + + /// SHA-256 of the empty string, the context for every "derived" step. + private const string EmptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + [Fact] + public void EarlySecret_MatchesRfc8448() + { + Span secret = stackalloc byte[32]; + TlsKeySchedule.Extract(Sha256, salt: new byte[32], inputKeyMaterial: new byte[32], secret); + + Assert.Equal(EarlySecret, Show(secret)); + } + + [Fact] + public void DerivedForHandshake_MatchesRfc8448() + { + Span derived = stackalloc byte[32]; + TlsKeySchedule.DeriveSecret(Sha256, Hex(EarlySecret), "derived"u8, Hex(EmptyHash), derived); + + Assert.Equal(DerivedForHandshake, Show(derived)); + } + + [Fact] + public void HandshakeSecret_MatchesRfc8448() + { + Span secret = stackalloc byte[32]; + TlsKeySchedule.Extract(Sha256, Hex(DerivedForHandshake), Hex(EcdheSharedSecret), secret); + + Assert.Equal(HandshakeSecret, Show(secret)); + } + + [Fact] + public void MasterSecret_MatchesRfc8448() + { + Span derived = stackalloc byte[32]; + TlsKeySchedule.DeriveSecret(Sha256, Hex(HandshakeSecret), "derived"u8, Hex(EmptyHash), derived); + Assert.Equal(DerivedForMaster, Show(derived)); + + Span master = stackalloc byte[32]; + TlsKeySchedule.Extract(Sha256, derived, new byte[32], master); + Assert.Equal(MasterSecret, Show(master)); + } + + [Fact] + public void HandshakeTrafficKeys_MatchRfc8448() + { + Span key = stackalloc byte[16]; + Span iv = stackalloc byte[12]; + TlsKeySchedule.TrafficKeys(Sha256, Hex(ClientHandshakeTraffic), key, iv); + + Assert.Equal("dbfaa693d1762c5b666af5d950258d01", Show(key)); + Assert.Equal("5bd3c71b836e0b76bb73265f", Show(iv)); + } + + [Fact] + public void ApplicationTrafficSecrets_MatchRfc8448() + { + const string transcript = "9608102a0f1ccc6db6250b7b7e417b1a000eaada3daae4777a7686c9ff83df13"; + + Span clientApplication = stackalloc byte[32]; + TlsKeySchedule.DeriveSecret(Sha256, Hex(MasterSecret), "c ap traffic"u8, Hex(transcript), clientApplication); + Assert.Equal("9e40646ce79a7f9dc05af8889bce6552875afa0b06df0087f792ebb7c17504a5", Show(clientApplication)); + + Span serverApplication = stackalloc byte[32]; + TlsKeySchedule.DeriveSecret(Sha256, Hex(MasterSecret), "s ap traffic"u8, Hex(transcript), serverApplication); + Assert.Equal("a11af9f05531f856ad47116b45a950328204b4f44bfb6b3a4b4f1f3fcb631643", Show(serverApplication)); + + Span key = stackalloc byte[16]; + Span iv = stackalloc byte[12]; + TlsKeySchedule.TrafficKeys(Sha256, serverApplication, key, iv); + Assert.Equal("9f02283b6c9c07efc26bb9f2ac92e356", Show(key)); + Assert.Equal("cf782b88dd83549aadf1e984", Show(iv)); + } + + /// + /// The finished key, from the same trace. RFC 8448 prints the intermediate expansion, so this + /// pins with an empty context independently. + /// + [Fact] + public void FinishedKey_MatchesRfc8448() + { + Span finishedKey = stackalloc byte[32]; + TlsKeySchedule.ExpandLabel(Sha256, Hex(ServerHandshakeTraffic), "finished"u8, default, finishedKey); + + Assert.Equal("008d3b66f816ea559f96b537e885c31fc068bf492c652f01f288a1d8cdc19fc8", Show(finishedKey)); + } + + /// + /// The sequence number is xored into the low eight bytes of the IV, so record zero uses the + /// IV unchanged. Getting this wrong produces a connection that decrypts exactly one record. + /// + [Fact] + public void Nonce_XorsTheSequenceNumberIntoTheTail() + { + byte[] iv = Hex("5bd3c71b836e0b76bb73265f"); + + Span nonce = stackalloc byte[12]; + TlsKeySchedule.BuildNonce(nonce, iv, 0); + Assert.Equal("5bd3c71b836e0b76bb73265f", Show(nonce)); + + TlsKeySchedule.BuildNonce(nonce, iv, 1); + Assert.Equal("5bd3c71b836e0b76bb73265e", Show(nonce)); + + // The eight sequence bytes line up with iv[4..], big-endian: 83^01, 6e^02, 0b^03, 76^04, + // bb^05, 73^06, 26^07, 5f^08. + TlsKeySchedule.BuildNonce(nonce, iv, 0x0102030405060708); + Assert.Equal("5bd3c71b826c0872be752157", Show(nonce)); + } +} diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs new file mode 100644 index 0000000..066e44f --- /dev/null +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -0,0 +1,588 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet.Tests; + +/// +/// Tests for the TLS 1.3 record layer: framing, buffering, and the in-place AEAD contract the +/// buffering depends on. +/// +/// +/// +/// The layer reads and writes through one buffer per direction, hands out records as slices of +/// that buffer, and seals and opens every record in place. None of that is visible from the +/// outside when the transport is friendly — which is exactly why the tests here are unfriendly: +/// a transport that returns one byte at a time, a transport that returns four records at once, +/// and a peer that lies about its lengths. +/// +/// +/// A paired writer and reader share a traffic secret and therefore a sequence number, so they +/// stay in lockstep for the length of a test — the same construction the benchmarks use, and for +/// the same reason: a record's nonce cannot be rewound. +/// +/// +public class TlsRecordStreamTest +{ + private const ushort Aes128Gcm = 0x1301; + private const ushort Aes256Gcm = 0x1302; + private const ushort ChaCha20 = 0x1303; + + /// A transport that yields at most bytes per read. + /// + /// The record layer's whole reason for existing is that a record does not arrive in one + /// piece. A one-byte drip is the extreme of that, and it is the case where an off-by-one in + /// the buffer bookkeeping shows up as a hang or as a record made of two other records. + /// + private sealed class DripStream(byte[] data, int chunk) : Stream + { + private int _position; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => data.Length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) => + Read(buffer.AsSpan(offset, count)); + + public override int Read(Span buffer) + { + int take = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _position); + data.AsSpan(_position, take).CopyTo(buffer); + _position += take; + + return take; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + new(Read(buffer.Span)); + + 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(); + } + + /// Counts how many times the transport was asked to write. + /// + /// Only the asynchronous overload is counted, and only that one: MemoryStream implements it + /// by calling its own synchronous Write, so counting both would count every write + /// twice. + /// + private sealed class CountingStream : MemoryStream + { + public int Writes { get; private set; } + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Writes++; + return base.WriteAsync(buffer, cancellationToken); + } + } + + private static TlsCipherSuite Suite(ushort id) => + TlsCipherSuite.FromId(id) ?? throw new NotSupportedException($"Suite {id:x4} is unavailable here."); + + private static byte[] Secret(TlsCipherSuite suite) + { + byte[] secret = new byte[suite.HashLength]; + RandomNumberGenerator.Fill(secret); + + return secret; + } + + private static byte[] Payload(int length) + { + byte[] payload = new byte[length]; + RandomNumberGenerator.Fill(payload); + + return payload; + } + + public static TheoryData Suites() + { + var suites = new TheoryData { Aes128Gcm, Aes256Gcm }; + + if (ChaCha20Poly1305.IsSupported) + suites.Add(ChaCha20); + + return suites; + } + + /// Sealing and opening a record returns exactly what went in, for every suite. + /// + /// A paired protection, because the sequence number advances on both sides: the opener has to + /// be a second instance built from the same traffic secret, at the same record number, which + /// is the same thing a real peer is. + /// + [Theory] + [MemberData(nameof(Suites))] + public void Protect_ThenUnprotect_ReturnsThePlaintext(ushort id) + { + TlsCipherSuite suite = Suite(id); + byte[] secret = Secret(suite); + byte[] plaintext = Payload(4096); + byte[] header = [23, 3, 3, 0x10, 0x10]; + + byte[] ciphertext = new byte[plaintext.Length]; + byte[] tag = new byte[TlsCipherSuite.TagLength]; + using (var sealer = new TlsRecordProtection(suite, secret)) + sealer.Protect(plaintext, ciphertext, tag, header); + + Assert.NotEqual(plaintext, ciphertext); + + byte[] opened = new byte[plaintext.Length]; + using (var opener = new TlsRecordProtection(suite, secret)) + opener.Unprotect(ciphertext, tag, opened, header); + + Assert.Equal(plaintext, opened); + } + + /// An encrypted record survives the round trip, whatever the suite. + [Theory] + [MemberData(nameof(Suites))] + public async Task EncryptedRecord_RoundTrips(ushort id) + { + TlsCipherSuite suite = Suite(id); + byte[] secret = Secret(suite); + byte[] payload = Payload(1234); + + var wire = new MemoryStream(); + using (var writer = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }) + await writer.WriteAsync(TlsContentType.Handshake, payload, CancellationToken.None); + + using var reader = new TlsRecordStream(new MemoryStream(wire.ToArray())) + { + Read = new TlsRecordProtection(suite, secret) + }; + + TlsRecordStream.Record record = await reader.ReadAsync(CancellationToken.None); + + // The outer type on the wire is application_data; the real type rides inside the sealed + // record, which is the whole point of TLS 1.3's inner content type. + Assert.Equal(TlsContentType.ApplicationData, (TlsContentType)wire.ToArray()[0]); + Assert.Equal(TlsContentType.Handshake, record.Type); + Assert.Equal(payload, record.Payload.ToArray()); + } + + /// + /// A record split across many transport reads is reassembled; several records in one read are + /// handed out one at a time. + /// + /// + /// Both directions of the same bookkeeping: chunk of 1 forces every record to be + /// assembled from fragments, and a chunk larger than a record forces several records out of + /// one buffer without a transport read in between. + /// + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(64 * 1024)] + public async Task Records_SurviveAnyTransportChunking(int chunk) + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + byte[][] payloads = + [ + Payload(1), + Payload(300), + Payload(TlsRecordStream.MaxPlaintext), + Payload(5000) + ]; + + var wire = new MemoryStream(); + using (var writer = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }) + { + foreach (byte[] payload in payloads) + await writer.WriteAsync( + TlsContentType.ApplicationData, payload, CancellationToken.None); + } + + using var reader = new TlsRecordStream(new DripStream(wire.ToArray(), chunk)) + { + Read = new TlsRecordProtection(suite, secret) + }; + + foreach (byte[] payload in payloads) + { + TlsRecordStream.Record record = await reader.ReadAsync(CancellationToken.None); + + Assert.Equal(TlsContentType.ApplicationData, record.Type); + Assert.Equal(payload, record.Payload.ToArray()); + } + } + + /// An unencrypted record keeps the framing a ClientHello needs. + /// + /// The legacy version byte is 0x0301 for a handshake record and 0x0303 otherwise. It is + /// meaningless to TLS 1.3 and load-bearing to REALITY: it is part of what a fingerprint + /// matches on, so a record layer that "corrected" it would change how the client looks. + /// + [Fact] + public async Task UnencryptedRecord_KeepsItsLegacyVersion() + { + byte[] payload = Payload(64); + + var wire = new MemoryStream(); + using (var writer = new TlsRecordStream(wire)) + { + await writer.WriteAsync(TlsContentType.Handshake, payload, CancellationToken.None); + await writer.WriteAsync( + TlsContentType.ChangeCipherSpec, new byte[] { 1 }, CancellationToken.None); + } + + byte[] bytes = wire.ToArray(); + Assert.Equal(22, bytes[0]); + Assert.Equal(3, bytes[1]); + Assert.Equal(1, bytes[2]); + Assert.Equal(payload.Length, (bytes[3] << 8) | bytes[4]); + + byte[] changeCipherSpec = bytes.AsSpan(5 + payload.Length).ToArray(); + Assert.Equal(20, changeCipherSpec[0]); + Assert.Equal(3, changeCipherSpec[2]); + + using var reader = new TlsRecordStream(new MemoryStream(bytes)); + TlsRecordStream.Record first = await reader.ReadAsync(CancellationToken.None); + TlsRecordStream.Record second = await reader.ReadAsync(CancellationToken.None); + + Assert.Equal(TlsContentType.Handshake, first.Type); + Assert.Equal(payload, first.Payload.ToArray()); + Assert.Equal(TlsContentType.ChangeCipherSpec, second.Type); + } + + /// + /// ChangeCipherSpec is handed back verbatim even once read protection is installed. + /// + [Fact] + public async Task ChangeCipherSpec_IsNeverDecrypted() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + + byte[] wire = [20, 3, 3, 0, 1, 1]; + + using var reader = new TlsRecordStream(new MemoryStream(wire)) + { + Read = new TlsRecordProtection(suite, secret) + }; + + TlsRecordStream.Record record = await reader.ReadAsync(CancellationToken.None); + + Assert.Equal(TlsContentType.ChangeCipherSpec, record.Type); + Assert.Equal(new byte[] { 1 }, record.Payload.ToArray()); + } + + /// A record larger than the limit is refused on its header, before its body. + [Fact] + public async Task OversizedRecord_IsRefused() + { + // A length one over the ciphertext limit, and no body at all behind it: the refusal has to + // come from the header, or this hangs waiting for bytes the peer never sends. + int length = TlsRecordStream.MaxCiphertext + 1; + byte[] wire = [23, 3, 3, (byte)(length >> 8), (byte)length]; + + using var reader = new TlsRecordStream(new MemoryStream(wire)); + + // The peer's malformed record is a protocol failure the caller can catch as one, not an + // InvalidOperationException that reads as a bug in this library. + var ex = await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + /// An encrypted record shorter than its own tag is refused. + [Fact] + public async Task RecordShorterThanItsTag_IsRefused() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] wire = [23, 3, 3, 0, 4, 1, 2, 3, 4]; + + using var reader = new TlsRecordStream(new MemoryStream(wire)) + { + Read = new TlsRecordProtection(suite, Secret(suite)) + }; + + // The peer's malformed record is a protocol failure the caller can catch as one, not an + // InvalidOperationException that reads as a bug in this library. + var ex = await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + /// A record that is all padding and no content type is refused. + [Fact] + public async Task RecordWithoutAContentType_IsRefused() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + + // Sealed by hand, because the writer never produces one: the inner content type is the + // last non-zero byte, so a plaintext of nothing but zeros has none. + byte[] inner = new byte[8]; + byte[] wire = new byte[5 + inner.Length + TlsCipherSuite.TagLength]; + wire[0] = 23; + wire[1] = 3; + wire[2] = 3; + wire[3] = (byte)((inner.Length + TlsCipherSuite.TagLength) >> 8); + wire[4] = (byte)(inner.Length + TlsCipherSuite.TagLength); + + using (var protection = new TlsRecordProtection(suite, secret)) + protection.Protect( + inner, + wire.AsSpan(5, inner.Length), + wire.AsSpan(5 + inner.Length, TlsCipherSuite.TagLength), + wire.AsSpan(0, 5)); + + using var reader = new TlsRecordStream(new MemoryStream(wire)) + { + Read = new TlsRecordProtection(suite, secret) + }; + + // The peer's malformed record is a protocol failure the caller can catch as one, not an + // InvalidOperationException that reads as a bug in this library. + var ex = await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + /// + /// After the handshake, a peer can send empty application-data records — each legal, each + /// carrying nothing — without end. The stream's read must give up on them, not wait for a + /// non-empty one that is never coming. + /// + [Fact] + public async Task AfterHandshake_EmptyRecordFlood_EndsTheReadInAnError() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + + // One sealed record whose only inner byte is the content type: application data, empty. + byte[] inner = [23]; + var wire = new MemoryStream(); + using (var protection = new TlsRecordProtection(suite, secret)) + { + for (int i = 0; i < 100; i++) + { + byte[] record = new byte[5 + inner.Length + TlsCipherSuite.TagLength]; + record[0] = 23; + record[1] = 3; + record[2] = 3; + record[3] = (byte)((inner.Length + TlsCipherSuite.TagLength) >> 8); + record[4] = (byte)(inner.Length + TlsCipherSuite.TagLength); + protection.Protect( + inner, + record.AsSpan(5, inner.Length), + record.AsSpan(5 + inner.Length, TlsCipherSuite.TagLength), + record.AsSpan(0, 5)); + wire.Write(record); + } + } + + var transport = new MemoryStream(wire.ToArray()); + var records = new TlsRecordStream(transport) { Read = new TlsRecordProtection(suite, secret) }; + 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); + } + + /// A tampered record does not open. + [Fact] + public async Task TamperedRecord_FailsItsTagCheck() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + + var wire = new MemoryStream(); + using (var writer = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }) + await writer.WriteAsync( + TlsContentType.ApplicationData, Payload(256), CancellationToken.None); + + byte[] bytes = wire.ToArray(); + bytes[10] ^= 0xff; + + using var reader = new TlsRecordStream(new MemoryStream(bytes)) + { + Read = new TlsRecordProtection(suite, secret) + }; + + var ex = await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + Assert.IsType(ex.InnerException); + } + + /// A transport that ends mid-record reports end of stream. + /// + /// catches and reports it to + /// its caller as a clean end of stream, so this is the exception type the layer above is + /// written against — not an implementation detail of how the bytes were read. + /// + [Fact] + public async Task TruncatedRecord_ReportsEndOfStream() + { + byte[] wire = [22, 3, 1, 0, 16, 1, 2, 3]; + + using var reader = new TlsRecordStream(new MemoryStream(wire)); + + await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + } + + /// + /// Application data larger than one record becomes several records — in one transport write. + /// + /// + /// The record split is RFC 8446 §5.1 and not negotiable. The single write is the point of the + /// staging buffer: 40 KiB of payload is three records, and sending those separately would be + /// three writes and three flushes to satisfy one caller. + /// + [Fact] + public async Task LargeApplicationWrite_IsThreeRecordsInOneWrite() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + byte[] payload = Payload(40 * 1024); + + var wire = new CountingStream(); + using (var writer = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }) + await writer.WriteApplicationDataAsync(payload, CancellationToken.None); + + Assert.Equal(1, wire.Writes); + + using var reader = new TlsRecordStream(new MemoryStream(wire.ToArray())) + { + Read = new TlsRecordProtection(suite, secret) + }; + + var received = new List(); + var lengths = new List(); + while (received.Count < payload.Length) + { + TlsRecordStream.Record record = await reader.ReadAsync(CancellationToken.None); + + Assert.Equal(TlsContentType.ApplicationData, record.Type); + lengths.Add(record.Payload.Length); + received.AddRange(record.Payload.ToArray()); + } + + Assert.Equal( + new[] { TlsRecordStream.MaxPlaintext, TlsRecordStream.MaxPlaintext, (40 * 1024) - (2 * TlsRecordStream.MaxPlaintext) }, + lengths); + Assert.Equal(payload, received); + } + + /// A write past the staging buffer takes more than one write, and still round-trips. + /// + /// The staging buffer holds three full-size records, so a 1 MiB write cannot go out in one + /// piece — what matters is that the payload comes back whole across the batches, which is the + /// case the batching loop gets wrong if it forgets what it has already staged. + /// + [Fact] + public async Task ApplicationWrite_LargerThanTheStagingBuffer_RoundTrips() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + byte[] payload = Payload(1024 * 1024); + + var wire = new CountingStream(); + using (var writer = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }) + await writer.WriteApplicationDataAsync(payload, CancellationToken.None); + + Assert.InRange(wire.Writes, 2, 64); + + using var reader = new TlsRecordStream(new MemoryStream(wire.ToArray())) + { + Read = new TlsRecordProtection(suite, secret) + }; + + var received = new List(); + while (received.Count < payload.Length) + { + TlsRecordStream.Record record = await reader.ReadAsync(CancellationToken.None); + received.AddRange(record.Payload.ToArray()); + } + + Assert.Equal(payload, received); + } + + /// + /// A record that has been read stays intact while the same connection writes. + /// + /// + /// Reading and writing share nothing, and this is the test that says so. A relay holds the + /// record it just read while it sends something else — the ordinary full-duplex pattern — so + /// a staging buffer shared between the two directions would corrupt the payload in the + /// reader's hand, on exactly the traffic a proxy exists to carry. + /// + [Fact] + public async Task ReadPayload_SurvivesAnInterleavedWrite() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] inboundSecret = Secret(suite); + byte[] outboundSecret = Secret(suite); + byte[] incoming = Payload(4096); + + var peer = new MemoryStream(); + using (var peerWriter = new TlsRecordStream(peer) { Write = new TlsRecordProtection(suite, inboundSecret) }) + await peerWriter.WriteAsync(TlsContentType.ApplicationData, incoming, CancellationToken.None); + + var wire = new CountingStream(); + wire.Write(peer.ToArray()); + wire.Position = 0; + + using var connection = new TlsRecordStream(wire) + { + Read = new TlsRecordProtection(suite, inboundSecret), + Write = new TlsRecordProtection(suite, outboundSecret) + }; + + TlsRecordStream.Record held = await connection.ReadAsync(CancellationToken.None); + + wire.Position = wire.Length; + await connection.WriteApplicationDataAsync(Payload(40 * 1024), CancellationToken.None); + + Assert.Equal(incoming, held.Payload.ToArray()); + } + + /// + /// A record handed out stays intact while the caller holds it, across the read that follows. + /// + /// + /// The payload is a slice of the layer's own buffer, promised valid until the next read — and + /// the next read is what compacts that buffer. This pins the boundary: consume the record, + /// then read again, and the bytes must still be the ones that arrived. + /// + [Fact] + public async Task PayloadStaysValid_UntilTheNextRead() + { + TlsCipherSuite suite = Suite(Aes128Gcm); + byte[] secret = Secret(suite); + byte[] first = Payload(2048); + byte[] second = Payload(2048); + + var wire = new MemoryStream(); + using (var writer = new TlsRecordStream(wire) { Write = new TlsRecordProtection(suite, secret) }) + { + await writer.WriteAsync(TlsContentType.ApplicationData, first, CancellationToken.None); + await writer.WriteAsync(TlsContentType.ApplicationData, second, CancellationToken.None); + } + + using var reader = new TlsRecordStream(new DripStream(wire.ToArray(), 700)) + { + Read = new TlsRecordProtection(suite, secret) + }; + + TlsRecordStream.Record held = await reader.ReadAsync(CancellationToken.None); + Assert.Equal(first, held.Payload.ToArray()); + + TlsRecordStream.Record next = await reader.ReadAsync(CancellationToken.None); + Assert.Equal(second, next.Payload.ToArray()); + } +} diff --git a/QuickProxyNet.Tests/TransportTest.cs b/QuickProxyNet.Tests/TransportTest.cs new file mode 100644 index 0000000..29467b7 --- /dev/null +++ b/QuickProxyNet.Tests/TransportTest.cs @@ -0,0 +1,353 @@ +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +/// +/// The ws and httpupgrade transport layers, exercised through +/// — the transport has no public surface of its own, and driving it +/// through a real protocol is what proves the layering order is right. +/// +public class TransportTest +{ + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + + private static VlessClient WsClient(string query = "") => + VlessClient.FromShareLink($"vless://{Uuid}@example.com:443?type=ws&security=none{query}"); + + /// A VLESS response header (ver=0, addonsLen=0) followed by the target's bytes. + private static byte[] VlessResponse(params byte[] payload) => [0x00, 0x00, .. payload]; + + /// + /// Drives the first read. ConnectAsync deliberately defers the VLESS response header, so + /// only a read forces the transport to actually carry traffic. + /// + private static async Task DriveFirstRead(Stream tunnel) + { + int read = await tunnel.ReadAsync(new byte[16]); + Assert.True(read > 0, "the transport produced no payload"); + } + + // === handshake === + + [Fact] + public async Task Handshake_SendsWellFormedUpgradeRequest() + { + var server = new FakeWebSocketServer(); + server.SendToClient(VlessResponse(0x41)); + + var tunnel = await WsClient("&path=%2Fchat&host=cdn.example.com") + .ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + string request = server.Request; + Assert.StartsWith("GET /chat HTTP/1.1\r\n", request); + Assert.Contains("Host: cdn.example.com\r\n", request); + Assert.Contains("Upgrade: websocket\r\n", request); + Assert.Contains("Connection: Upgrade\r\n", request); + Assert.Contains("Sec-WebSocket-Version: 13\r\n", request); + Assert.Contains("Sec-WebSocket-Key: ", request); + Assert.EndsWith("\r\n\r\n", request); + } + + [Fact] + public async Task Handshake_PathDefaultsToRoot() + { + var server = new FakeWebSocketServer(); + server.SendToClient(VlessResponse(0x41)); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + Assert.StartsWith("GET / HTTP/1.1\r\n", server.Request); + } + + [Fact] + public async Task Handshake_SendsPathQueryVerbatim() + { + // Xray's early-data feature rides on the path query. Stripping or re-encoding '?ed=2048' + // makes the server answer 404, so it must survive the round trip untouched. + var server = new FakeWebSocketServer(); + server.SendToClient(VlessResponse(0x41)); + + var tunnel = await WsClient("&path=%2Fws%3Fed%3D2048") + .ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + Assert.StartsWith("GET /ws?ed=2048 HTTP/1.1\r\n", server.Request); + } + + [Fact] + public async Task Handshake_HostHeaderFallsBackToSniThenServerHost() + { + var withSni = new FakeWebSocketServer(); + withSni.SendToClient(VlessResponse(0x41)); + var t1 = await WsClient("&sni=sni.example.com") + .ConnectAsync(withSni, "example.org", 443, CancellationToken.None); + await DriveFirstRead(t1); + Assert.Contains("Host: sni.example.com\r\n", withSni.Request); + + var bare = new FakeWebSocketServer(); + bare.SendToClient(VlessResponse(0x41)); + var t2 = await WsClient().ConnectAsync(bare, "example.org", 443, CancellationToken.None); + await DriveFirstRead(t2); + Assert.Contains("Host: example.com\r\n", bare.Request); + } + + [Fact] + public async Task Handshake_NotUpgraded_Throws() + { + // The overwhelmingly common real failure: right server, wrong path. + var server = new FakeWebSocketServer { StatusLine = "HTTP/1.1 404 Not Found" }; + + var ex = await Assert.ThrowsAsync( + () => WsClient("&path=%2Fwrong") + .ConnectAsync(server, "example.org", 443, CancellationToken.None).AsTask()); + + Assert.Equal(ProxyErrorCode.TransportUpgradeFailed, ex.ErrorCode); + Assert.Contains("404", ex.Message); + } + + [Fact] + public async Task Handshake_WrongAccept_Throws() + { + var server = new FakeWebSocketServer { AcceptOverride = "AAAAAAAAAAAAAAAAAAAAAAAAAAA=" }; + + var ex = await Assert.ThrowsAsync( + () => WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None).AsTask()); + + Assert.Equal(ProxyErrorCode.TransportUpgradeFailed, ex.ErrorCode); + } + + [Fact] + public async Task Handshake_MissingAccept_Throws() + { + var server = new FakeWebSocketServer { OmitAccept = true }; + + var ex = await Assert.ThrowsAsync( + () => WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None).AsTask()); + + Assert.Equal(ProxyErrorCode.TransportUpgradeFailed, ex.ErrorCode); + } + + [Fact] + public async Task Handshake_ServerClosesEarly_Throws() + { + var ex = await Assert.ThrowsAsync( + () => WsClient().ConnectAsync(new FakeProxyStream([]), "example.org", 443, CancellationToken.None) + .AsTask()); + + Assert.Equal(ProxyErrorCode.TransportUpgradeFailed, ex.ErrorCode); + } + + // === framing === + + [Fact] + public async Task Ws_WritesProtocolHeaderAsMaskedFrames() + { + var server = new FakeWebSocketServer(); + server.SendToClient(VlessResponse(0x41)); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + // FakeWebSocketServer throws on an unmasked frame, so reaching here proves the client + // masked it. What arrives is the VLESS request with the framing removed. + byte[] payload = server.PayloadFromClient; + Assert.Equal(0x00, payload[0]); // VLESS version + Assert.Equal(0x01, payload[18]); // TCP command + } + + [Fact] + public async Task Ws_ReadsPayloadAcrossFrames() + { + // The response header lands in one frame and the payload in another: a byte stream, + // not a message stream, so the reader must not care where the boundary fell. + var server = new FakeWebSocketServer(); + server.SendToClient([0x00, 0x00]); + server.SendToClient([0x41, 0x42, 0x43]); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + int read = await tunnel.ReadAsync(buffer); + Assert.Equal([0x41, 0x42, 0x43], buffer[..read]); + } + + [Fact] + public async Task Ws_EmptyFrame_IsNotEndOfStream() + { + // A zero-length binary frame is legal. Reporting its 0 as EOF would truncate the + // tunnel silently — the reader must keep going until real bytes arrive. + var server = new FakeWebSocketServer(); + server.SendToClient([0x00, 0x00]); + server.SendToClient([]); + server.SendToClient([0x5A]); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + int read = await tunnel.ReadAsync(buffer); + Assert.Equal(1, read); + Assert.Equal(0x5A, buffer[0]); + } + + [Fact] + public async Task Ws_CloseFrame_IsEndOfStream() + { + var server = new FakeWebSocketServer(); + server.SendToClient([0x00, 0x00, 0x41]); + server.SendClose(); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + Assert.Equal(1, await tunnel.ReadAsync(buffer)); // the payload byte + Assert.Equal(0, await tunnel.ReadAsync(buffer)); // then a clean EOF + } + + [Fact] + public async Task Ws_LargePayload_SurvivesExtendedLengthFraming() + { + // Crosses the 126-byte boundary into 16-bit extended length. + byte[] payload = new byte[5000]; + for (int i = 0; i < payload.Length; i++) + payload[i] = (byte)(i % 251); + + var server = new FakeWebSocketServer(); + server.SendToClient([0x00, 0x00]); + server.SendToClient(payload); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var received = new byte[payload.Length]; + int total = 0; + while (total < payload.Length) + { + int read = await tunnel.ReadAsync(received.AsMemory(total)); + Assert.True(read > 0, "the stream ended before the payload was complete"); + total += read; + } + + Assert.Equal(payload, received); + + // And the same size in the other direction. + await tunnel.WriteAsync(payload); + Assert.Equal(payload, server.PayloadFromClient[^payload.Length..]); + } + + [Fact] + public async Task Handshake_PipelinedBytes_AreNotLost() + { + // A server that flushes the first frame together with the 101 must not lose it to the + // header parser's buffer. + var server = new FakeWebSocketServer + { + PipelinedAfterHandshake = FakeWebSocketServer.EncodeServerFrame([0x00, 0x00, 0x37]) + }; + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + int read = await tunnel.ReadAsync(buffer); + Assert.Equal(1, read); + Assert.Equal(0x37, buffer[0]); + } + + // === httpupgrade === + + [Fact] + public async Task HttpUpgrade_CarriesRawBytesWithoutFraming() + { + var server = new FakeWebSocketServer(framed: false); + server.SendToClient(VlessResponse(0x41, 0x42)); + + var client = VlessClient.FromShareLink( + $"vless://{Uuid}@example.com:443?type=httpupgrade&security=none&path=%2Fup"); + var tunnel = await client.ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + int read = await tunnel.ReadAsync(buffer); + Assert.Equal([0x41, 0x42], buffer[..read]); + + // The request went out unframed, so the VLESS header is the first thing on the wire. + Assert.StartsWith("GET /up HTTP/1.1\r\n", server.Request); + Assert.Equal(0x00, server.PayloadFromClient[0]); + Assert.Equal(0x01, server.PayloadFromClient[18]); + } + + [Fact] + public async Task HttpUpgrade_SendsNoWebSocketKey() + { + // Not cosmetic: sing-box routes any request carrying Sec-WebSocket-Key to its WebSocket + // handler, which an httpupgrade inbound does not have, and answers 404. Xray accepts + // either form, so only running both servers caught it. + var server = new FakeWebSocketServer(framed: false); + server.SendToClient(VlessResponse(0x41)); + + var client = VlessClient.FromShareLink( + $"vless://{Uuid}@example.com:443?type=httpupgrade&security=none"); + var tunnel = await client.ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + Assert.DoesNotContain("Sec-WebSocket-Key", server.Request, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Sec-WebSocket-Version", server.Request, StringComparison.OrdinalIgnoreCase); + + // The camouflage that does not break sing-box stays: the request still reads as an + // ordinary upgrade to anything in the middle. + Assert.Contains("Upgrade: websocket\r\n", server.Request); + Assert.Contains("Connection: Upgrade\r\n", server.Request); + } + + [Fact] + public async Task Ws_SendsWebSocketKey() + { + var server = new FakeWebSocketServer(); + server.SendToClient(VlessResponse(0x41)); + + var tunnel = await WsClient().ConnectAsync(server, "example.org", 443, CancellationToken.None); + await DriveFirstRead(tunnel); + + Assert.Contains("Sec-WebSocket-Key: ", server.Request); + } + + [Fact] + public async Task HttpUpgrade_AcceptsResponseWithoutAcceptHeader() + { + // An httpupgrade server is not a WebSocket endpoint and need not prove it is one. + var server = new FakeWebSocketServer(framed: false) { OmitAccept = true }; + server.SendToClient(VlessResponse(0x41)); + + var client = VlessClient.FromShareLink( + $"vless://{Uuid}@example.com:443?type=httpupgrade&security=none"); + var tunnel = await client.ConnectAsync(server, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + Assert.Equal(1, await tunnel.ReadAsync(buffer)); + } + + // === parsing === + + [Theory] + [InlineData("vless://u@h:1?type=ws&path=%2Fa%2Fb", "/a/b")] + [InlineData("vless://u@h:1?type=ws&path=a%2Fb", "a/b")] + [InlineData("vless://u@h:1?type=ws", null)] + public void Parse_CapturesPath(string link, string? expected) + { + Assert.Equal(expected, VlessShareLink.Parse(link).Path); + } + + [Fact] + public void Parse_CapturesHostHeader() + { + Assert.Equal("cdn.example.com", + VlessShareLink.Parse("vless://u@h:1?type=ws&host=cdn.example.com").HostHeader); + } + + [Fact] + public void Parse_Trojan_CapturesPathAndHost() + { + var o = TrojanShareLink.Parse("trojan://pw@h:443?type=ws&path=%2Ftj&host=cdn.example.com"); + Assert.Equal("/tj", o.Path); + Assert.Equal("cdn.example.com", o.HostHeader); + } +} diff --git a/QuickProxyNet.Tests/TrojanTest.cs b/QuickProxyNet.Tests/TrojanTest.cs new file mode 100644 index 0000000..c5f3eae --- /dev/null +++ b/QuickProxyNet.Tests/TrojanTest.cs @@ -0,0 +1,203 @@ +using System.Text; +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +public class TrojanTest +{ + private const string Password = "mysecretpassword"; + + // hex(SHA224("mysecretpassword")), computed independently with Python hashlib. + private const string ExpectedHashHex = "aec5b55fda5d423724436969d6d318d5f2a2ea8891872fbaa2ee4fcf"; + + // === TrojanShareLink.Parse === + + [Fact] + public void Parse_BasicPassword() + { + var o = TrojanShareLink.Parse("trojan://secret@example.com:443#node1"); + Assert.Equal("secret", o.Password); + Assert.Equal("example.com", o.Host); + Assert.Equal(443, o.Port); + Assert.Equal("tcp", o.Transport); + Assert.Equal(TransportKind.RawTcp, o.TransportKind); + Assert.Equal("node1", o.Remark); + Assert.False(o.AllowInsecure); + } + + [Fact] + public void Parse_UrlEncodedPassword() + { + // "p@ss w0rd/1" percent-encoded in the userinfo. + var o = TrojanShareLink.Parse("trojan://p%40ss%20w0rd%2F1@example.com:443"); + Assert.Equal("p@ss w0rd/1", o.Password); + } + + [Fact] + public void Parse_SniAlpnAllowInsecureType() + { + var o = TrojanShareLink.Parse( + "trojan://pw@cdn.example.com:8443?sni=real.example.com&alpn=h2%2Chttp%2F1.1&allowInsecure=1&type=tcp#t"); + Assert.Equal("real.example.com", o.Sni); + Assert.NotNull(o.Alpn); + Assert.Equal(["h2", "http/1.1"], o.Alpn); + Assert.True(o.AllowInsecure); + Assert.Equal("tcp", o.Transport); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("1")] + public void Parse_AllowInsecure_Truthy(string value) + { + var o = TrojanShareLink.Parse($"trojan://pw@example.com:443?allowInsecure={value}"); + Assert.True(o.AllowInsecure); + } + + [Theory] + [InlineData("0")] + [InlineData("false")] + [InlineData("no")] + public void Parse_AllowInsecure_Falsy(string value) + { + var o = TrojanShareLink.Parse($"trojan://pw@example.com:443?insecure={value}"); + Assert.False(o.AllowInsecure); + } + + [Fact] + public void Parse_PeerAlias_MapsToSni() + { + var o = TrojanShareLink.Parse("trojan://pw@example.com:443?peer=real.example.com"); + Assert.Equal("real.example.com", o.Sni); + } + + [Fact] + public void Parse_IPv6Host_StripsBrackets() + { + var o = TrojanShareLink.Parse("trojan://pw@[2001:db8::1]:443"); + Assert.Equal("2001:db8::1", o.Host); + } + + [Theory] + [InlineData("")] + [InlineData("vless://pw@example.com:443")] + [InlineData("trojan://@example.com:443")] + [InlineData("trojan://pw@example.com")] + public void TryParse_RejectsInvalid(string link) + { + Assert.False(TrojanShareLink.TryParse(link, out _)); + } + + // === TrojanHelper.BuildRequest === + + [Fact] + public void BuildRequest_ProducesExactWireBytes() + { + Span buf = stackalloc byte[512]; + int n = TrojanHelper.BuildRequest(buf, Password, "mc.example.com", 25565); + + // The full expected request: 56 hex bytes | CRLF | CMD | ATYP+addr | port | CRLF. + var expected = new List(); + expected.AddRange(Encoding.ASCII.GetBytes(ExpectedHashHex)); // 56 lowercase-hex ASCII bytes + expected.Add(0x0D); // CR + expected.Add(0x0A); // LF + expected.Add(0x01); // CMD CONNECT + expected.Add(0x03); // ATYP domain (SOCKS5-style) + expected.Add(0x0E); // domain length 14 + expected.AddRange(Encoding.ASCII.GetBytes("mc.example.com")); + expected.Add(0x63); // port 25565 hi + expected.Add(0xDD); // port 25565 lo + expected.Add(0x0D); // CR + expected.Add(0x0A); // LF + + Assert.Equal(expected.ToArray(), buf.Slice(0, n).ToArray()); + } + + [Fact] + public void BuildRequest_HashIs56LowercaseHexBytes() + { + Span buf = stackalloc byte[512]; + TrojanHelper.BuildRequest(buf, Password, "1.2.3.4", 80); + + string hash = Encoding.ASCII.GetString(buf.Slice(0, 56).ToArray()); + Assert.Equal(ExpectedHashHex, hash); + Assert.Equal(0x0D, buf[56]); + Assert.Equal(0x0A, buf[57]); + } + + [Fact] + public void BuildRequest_IPv4_UsesSocks5AddressType() + { + Span buf = stackalloc byte[512]; + int n = TrojanHelper.BuildRequest(buf, Password, "1.2.3.4", 443); + + // After hash(56) + CRLF(2) + CMD(1) = offset 59. + Assert.Equal(0x01, buf[58]); // CMD + Assert.Equal(0x01, buf[59]); // ATYP IPv4 + Assert.Equal([1, 2, 3, 4], buf.Slice(60, 4).ToArray()); + Assert.Equal(0x01, buf[64]); // port 443 hi + Assert.Equal(0xBB, buf[65]); // port 443 lo + Assert.Equal(0x0D, buf[66]); + Assert.Equal(0x0A, buf[67]); + Assert.Equal(68, n); + } + + // === TrojanClient construction / gating === + + [Fact] + public void Client_NullOptions_ThrowsArgumentNull() + { + Assert.Throws(() => new TrojanClient(null!)); + } + + [Fact] + public void Client_EmptyPassword_ThrowsAtConstruction() + { + var bad = new TrojanOptions { Password = "", Host = "example.com", Port = 443 }; + Assert.Throws(() => new TrojanClient(bad)); + } + + [Fact] + public void Client_IPv6Host_ConstructsWithoutThrowing() + { + // The base ProxyClient ctor must bracket the IPv6 literal when composing ProxyUri. + var client = new TrojanClient(TrojanShareLink.Parse("trojan://secret@[2001:db8::1]:443")); + Assert.Equal("2001:db8::1", client.ProxyHost); + Assert.Equal(443, client.ProxyPort); + } + + [Fact] + public async Task Client_UnsupportedTransport_ThrowsNotSupported_BeforeTls() + { + // A FakeProxyStream that is NOT a TLS endpoint: if EnsureSupported did not run first, + // AuthenticateAsClientAsync would fail with a different exception. + var stream = new FakeProxyStream([]); + var client = new TrojanClient( + TrojanShareLink.Parse("trojan://pw@example.com:443?type=grpc")); + + await Assert.ThrowsAsync( + () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + } + + // === Factory === + + [Fact] + public void Factory_CreatesTrojanClient() + { + var client = ProxyClientFactory.Instance.Create( + new Uri("trojan://pw@example.com:443?sni=a.com&allowInsecure=1")); + var trojan = Assert.IsType(client); + Assert.Equal(ProxyType.Trojan, trojan.Type); + Assert.Equal("pw", trojan.Options.Password); + Assert.True(trojan.Options.AllowInsecure); + } + + [Fact] + public void FromShareLink_CreatesClient() + { + var client = TrojanClient.FromShareLink("trojan://pw@example.com:443"); + Assert.Equal("example.com", client.Options.Host); + Assert.Equal(443, client.Options.Port); + } +} diff --git a/QuickProxyNet.Tests/VisionTest.cs b/QuickProxyNet.Tests/VisionTest.cs new file mode 100644 index 0000000..2ae2b6a --- /dev/null +++ b/QuickProxyNet.Tests/VisionTest.cs @@ -0,0 +1,435 @@ +using System.Text; + +namespace QuickProxyNet.Tests; + +/// +/// The xtls-rprx-vision padding protocol, both directions. +/// +/// +/// The failure this guards against is not a crash. A Vision stream read as plain VLESS almost +/// always yields a plausible first response — the padding header is 21 bytes and the payload +/// follows it — and corrupts everything after. So the assertions here are about the bytes past +/// the first frame as much as about the first one. +/// +public class VisionTest +{ + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + + private static readonly byte[] UuidBigEndian = + [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 + ]; + + private const byte PaddingContinue = 0x00; + private const byte PaddingEnd = 0x01; + + /// Builds one padding frame, with the UUID prefix only when asked for. + private static byte[] Frame(byte command, ReadOnlySpan content, int padding, bool withUuid) + { + var bytes = new List(); + if (withUuid) + bytes.AddRange(UuidBigEndian); + + bytes.Add(command); + bytes.Add((byte)(content.Length >> 8)); + bytes.Add((byte)content.Length); + bytes.Add((byte)(padding >> 8)); + bytes.Add((byte)padding); + bytes.AddRange(content.ToArray()); + bytes.AddRange(Enumerable.Repeat((byte)0xEE, padding)); // padding is skipped, not zero-checked + return [.. bytes]; + } + + private static VisionStream Wrap(byte[] serverBytes, out MemoryStream sent) + { + var duplex = new DuplexStream(serverBytes); + sent = duplex.Written; + return new VisionStream(duplex, UuidBigEndian); + } + + /// + /// Drains the stream through either ReadAsync or the synchronous Read(Span). + /// The two paths are separate implementations inside , so a + /// divergence between them is caught only by running the same scenario through both. + /// + private static async Task ReadAllAsync(Stream stream, int chunk = 4096, bool sync = false) + { + var all = new MemoryStream(); + byte[] buffer = new byte[chunk]; + while (true) + { + int n = sync ? stream.Read(buffer.AsSpan()) : await stream.ReadAsync(buffer); + if (n == 0) + break; + + all.Write(buffer, 0, n); + } + + return all.ToArray(); + } + + // === reading === + + [Fact] + public async Task Read_StripsASingleClosingFrame() + { + byte[] payload = Encoding.ASCII.GetBytes("HTTP/1.1 204 No Content\r\n\r\n"); + byte[] wire = [.. Frame(PaddingEnd, payload, padding: 64, withUuid: true), .. Encoding.ASCII.GetBytes("trailing")]; + + await using VisionStream stream = Wrap(wire, out _); + + byte[] expected = [.. payload, .. Encoding.ASCII.GetBytes("trailing")]; + Assert.Equal(expected, await ReadAllAsync(stream)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_StripsSeveralFrames_AndOnlyTheFirstCarriesTheUuid(bool sync) + { + byte[] wire = + [ + .. Frame(PaddingContinue, "one"u8, padding: 32, withUuid: true), + .. Frame(PaddingContinue, "two"u8, padding: 0, withUuid: false), + .. Frame(PaddingEnd, "three"u8, padding: 17, withUuid: false), + .. "raw"u8.ToArray() + ]; + + await using VisionStream stream = Wrap(wire, out _); + + Assert.Equal("onetwothreeraw", Encoding.ASCII.GetString(await ReadAllAsync(stream, sync: sync))); + } + + /// + /// The frames are a byte stream, not packets: a header can straddle two reads and content + /// can arrive one byte at a time. This is the case that a naive implementation passes in + /// testing and fails against a real server. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_SurvivesFramesSplitAcrossEveryByteBoundary(bool sync) + { + byte[] wire = + [ + .. Frame(PaddingContinue, "hello "u8, padding: 40, withUuid: true), + .. Frame(PaddingEnd, "world"u8, padding: 3, withUuid: false), + .. "!"u8.ToArray() + ]; + + var duplex = new DuplexStream(wire) { MaxRead = 1 }; + await using var stream = new VisionStream(duplex, UuidBigEndian); + + Assert.Equal("hello world!", Encoding.ASCII.GetString(await ReadAllAsync(stream, chunk: 3, sync: sync))); + } + + [Fact] + public async Task Read_PassesThroughWhenTheServerDoesNotFrame() + { + byte[] wire = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\n\r\nplain body, no vision here"); + + await using VisionStream stream = Wrap(wire, out _); + + Assert.Equal(wire, await ReadAllAsync(stream)); + } + + /// A stream that ends before a first frame could exist is payload, not an error. + [Fact] + public async Task Read_ShortStreamIsPayload() + { + await using VisionStream stream = Wrap("hi"u8.ToArray(), out _); + + Assert.Equal("hi", Encoding.ASCII.GetString(await ReadAllAsync(stream))); + } + + /// + /// The direct command ends the framing just as end does. Live servers send it + /// far more often, and a client that ignores it waits for a header that never comes — which + /// is how this was found: against real nodes, not here. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_DirectCommandEndsTheFraming(bool sync) + { + const byte PaddingDirect = 0x02; + byte[] wire = + [ + .. Frame(PaddingDirect, "framed"u8, padding: 12, withUuid: true), + .. Encoding.ASCII.GetBytes("everything after is raw") + ]; + + await using VisionStream stream = Wrap(wire, out _); + + Assert.Equal("framedeverything after is raw", Encoding.ASCII.GetString(await ReadAllAsync(stream, sync: sync))); + } + + /// + /// A server that simply closes on a frame boundary — no closing command — has ended the + /// stream, not corrupted it. The distinction is the difference between a clean EOF and an + /// exception on every completed download. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_CloseOnAFrameBoundaryIsACleanEnd(bool sync) + { + byte[] wire = Frame(PaddingContinue, "all there is"u8, padding: 8, withUuid: true); + + await using VisionStream stream = Wrap(wire, out _); + + Assert.Equal("all there is", Encoding.ASCII.GetString(await ReadAllAsync(stream, sync: sync))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_TruncatedFrameThrows(bool sync) + { + // A header promising 100 bytes of content, with 4 delivered. + byte[] wire = Frame(PaddingEnd, "abcd"u8, padding: 0, withUuid: true); + wire[UuidBigEndian.Length + 2] = 100; + + await using VisionStream stream = Wrap(wire, out _); + + await Assert.ThrowsAsync(async () => await ReadAllAsync(stream, sync: sync)); + } + + /// + /// Padding-only frames are legal and Xray sends one or two. Sixty-five in a row is a peer + /// keeping a read from returning, and the read must end in an error rather than never. + /// + [Fact] + public async Task Read_PaddingOnlyFrameFlood_IsRefused() + { + var wire = new List(); + wire.AddRange(Frame(PaddingContinue, ReadOnlySpan.Empty, padding: 8, withUuid: true)); + for (int i = 0; i < 100; i++) + wire.AddRange(Frame(PaddingContinue, ReadOnlySpan.Empty, padding: 8, withUuid: false)); + wire.AddRange(Frame(PaddingEnd, "late"u8, padding: 0, withUuid: false)); + + await using VisionStream stream = Wrap([.. wire], out _); + + var ex = await Assert.ThrowsAsync(async () => await ReadAllAsync(stream)); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + /// + /// A server that is not framing and answers with fewer than 21 bytes — then waits for the + /// client — must have those bytes delivered, not held until a header that is never coming. + /// The first byte that is not the UUID already settles the question. + /// + [Fact] + public async Task Read_ShortNonVisionAnswer_IsDeliveredWithoutWaitingForAFullHeader() + { + var source = new StallingStream("+OK\r\n"u8.ToArray()); + await using var stream = new VisionStream(source, UuidBigEndian); + + byte[] buffer = new byte[64]; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + int n = await stream.ReadAsync(buffer, timeout.Token); + + Assert.Equal("+OK\r\n", Encoding.ASCII.GetString(buffer, 0, n)); + } + + // === writing === + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Write_PadsTheFirstWriteAndThenRunsRaw(bool sync) + { + await using VisionStream stream = Wrap([], out MemoryStream sent); + + if (sync) + { + stream.Write("GET / HTTP/1.1\r\n\r\n"u8); + stream.Write("second"u8); + } + else + { + await stream.WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray()); + await stream.WriteAsync("second"u8.ToArray()); + } + + byte[] written = sent.ToArray(); + Assert.Equal(UuidBigEndian, written[..16]); + Assert.Equal(PaddingEnd, written[16]); + + int contentLength = (written[17] << 8) | written[18]; + int paddingLength = (written[19] << 8) | written[20]; + Assert.Equal(18, contentLength); + Assert.Equal("GET / HTTP/1.1\r\n\r\n", Encoding.ASCII.GetString(written, 21, contentLength)); + + // Everything after the frame is the second write, unwrapped. + int frameEnd = 21 + contentLength + paddingLength; + Assert.Equal("second", Encoding.ASCII.GetString(written, frameEnd, written.Length - frameEnd)); + } + + /// + /// Xray pads a short packet out past 900 bytes. Matching that matters: the padding exists to + /// make the first records an uninformative length, and a distinctly-sized one is worse than + /// none at all. + /// + [Fact] + public async Task Write_PadsShortPacketsPastNineHundredBytes() + { + await using VisionStream stream = Wrap([], out MemoryStream sent); + + await stream.WriteAsync("tiny"u8.ToArray()); + + byte[] written = sent.ToArray(); + int contentLength = (written[17] << 8) | written[18]; + int paddingLength = (written[19] << 8) | written[20]; + Assert.Equal(4, contentLength); + Assert.InRange(contentLength + paddingLength, 900, 1400); + } + + [Fact] + public async Task Write_OversizedFirstWriteGoesOutRaw() + { + byte[] big = new byte[9000]; + Array.Fill(big, (byte)0x5A); + + await using VisionStream stream = Wrap([], out MemoryStream sent); + await stream.WriteAsync(big); + + Assert.Equal(big, sent.ToArray()); + } + + // === the request header that turns Vision on === + + [Fact] + public void BuildRequest_CarriesFlowInTheAddons() + { + Span buf = stackalloc byte[512]; + int n = VlessHelper.BuildRequest(buf, Uuid, "example.com", 443, VisionStream.FlowName); + + byte[] wire = buf[..n].ToArray(); + Assert.Equal(0x00, wire[0]); + Assert.Equal(UuidBigEndian, wire[1..17]); + Assert.Equal(2 + VisionStream.FlowName.Length, wire[17]); // addons length + Assert.Equal(0x0A, wire[18]); // Addons.Flow, wire type 2 + Assert.Equal(VisionStream.FlowName.Length, wire[19]); + Assert.Equal(VisionStream.FlowName, Encoding.ASCII.GetString(wire, 20, VisionStream.FlowName.Length)); + + int afterAddons = 20 + VisionStream.FlowName.Length; + Assert.Equal(0x01, wire[afterAddons]); // command TCP + Assert.Equal(443, (wire[afterAddons + 1] << 8) | wire[afterAddons + 2]); + Assert.Equal(0x02, wire[afterAddons + 3]); // domain + } + + [Fact] + public void BuildRequest_WithoutFlow_IsUnchanged() + { + Span withoutFlow = stackalloc byte[512]; + int n = VlessHelper.BuildRequest(withoutFlow, Uuid, "example.com", 443); + + Assert.Equal(0x00, withoutFlow[17]); // addons length stays zero + Assert.Equal(0x01, withoutFlow[18]); // command TCP follows immediately + Assert.Equal(1 + 16 + 1 + 1 + 2 + 2 + "example.com".Length, n); + } + + [Fact] + public void IsVision_MatchesOnlyTheImplementedFlow() + { + Assert.True(VlessHelper.IsVision("xtls-rprx-vision")); + Assert.False(VlessHelper.IsVision("xtls-rprx-direct")); + Assert.False(VlessHelper.IsVision("XTLS-RPRX-VISION")); + Assert.False(VlessHelper.IsVision(null)); + Assert.False(VlessHelper.IsVision("")); + } + + /// Hands out one chunk, then blocks like a peer that is waiting for its turn. + private sealed class StallingStream(byte[] first) : Stream + { + private bool _served; + + 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 async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (!_served) + { + _served = true; + int n = Math.Min(buffer.Length, first.Length); + first.AsSpan(0, n).CopyTo(buffer.Span); + return n; + } + + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) { } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + /// A stream that reads from a fixed script and records what was written. + private sealed class DuplexStream(byte[] serverBytes) : Stream + { + private int _position; + + public MemoryStream Written { get; } = new(); + + /// Caps how much one read returns, to simulate a stream that dribbles. + public int MaxRead { get; init; } = int.MaxValue; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => serverBytes.Length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override int Read(Span buffer) + { + int n = Math.Min(Math.Min(buffer.Length, MaxRead), serverBytes.Length - _position); + serverBytes.AsSpan(_position, n).CopyTo(buffer); + _position += n; + return n; + } + + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + => ValueTask.FromResult(Read(buffer.Span)); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => Task.FromResult(Read(buffer.AsSpan(offset, count))); + + public override void Write(ReadOnlySpan buffer) => Written.Write(buffer); + + public override void Write(byte[] buffer, int offset, int count) => Written.Write(buffer, offset, count); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + Written.Write(buffer.Span); + return ValueTask.CompletedTask; + } + + 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(); + } +} diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs new file mode 100644 index 0000000..bad29ca --- /dev/null +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -0,0 +1,511 @@ +using QuickProxyNet.Tests.Helpers; + +namespace QuickProxyNet.Tests; + +public class VlessTest +{ + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + + // Big-endian bytes are exactly the hex digits of the canonical string, in order. + private static readonly byte[] UuidBigEndian = + [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 + ]; + + // === UuidCodec === + + [Fact] + public void UuidCodec_WritesBigEndian_NotMixedEndian() + { + Span dest = stackalloc byte[16]; + UuidCodec.WriteBigEndian(Uuid, dest); + Assert.Equal(UuidBigEndian, dest.ToArray()); + } + + [Fact] + public void UuidCodec_RejectsSmallDestination() + { + Span dest = stackalloc byte[8]; + Assert.False(UuidCodec.TryWriteBigEndian(Uuid, dest)); + } + + // === Non-UUID ids === + // + // Xray's common/uuid.ParseString does NOT reject a short non-UUID id: for length 1..30 + // it derives UUIDv5(nil-namespace, utf8(id)). Both endpoints derive the same value, so + // such ids work end to end and ~0.3% of real-world VLESS links use one. + // + // The vectors below come from an independent UUIDv5 implementation that was first + // validated against the published RFC 4122 vector + // uuid5(NAMESPACE_DNS, "python.org") == 886313e1-3b8a-5372-9b90-0c9aee199e5d. + // DockerProtocolTests.Vless_NonUuidId_DerivesSameIdAsXray proves it against real Xray. + + [Theory] + [InlineData("not-a-uuid", "9b70e619-d7b3-55b1-b743-756ebd573b4e")] + [InlineData("a", "35b65f33-a679-5e76-af3c-273ea349ede4")] + [InlineData("password", "750db9b2-386a-5d2f-a2ea-64504781c566")] + [InlineData("MyUser123", "dec19763-791a-5f34-9a6b-a09c0630022c")] + public void UuidCodec_DerivesUuidV5_ForShortNonUuidId(string id, string expected) + { + Span dest = stackalloc byte[16]; + Assert.True(UuidCodec.TryWriteBigEndian(id, dest)); + + // The derived value is a canonical UUIDv5: version nibble 5, RFC 4122 variant. + Assert.Equal(0x50, dest[6] & 0xF0); + Assert.Equal(0x80, dest[8] & 0xC0); + + Span expectedBytes = stackalloc byte[16]; + Guid.Parse(expected).TryWriteBytes(expectedBytes, bigEndian: true, out _); + Assert.Equal(expectedBytes.ToArray(), dest.ToArray()); + } + + [Fact] + public void UuidCodec_Derivation_IsDeterministic() + { + Span a = stackalloc byte[16]; + Span b = stackalloc byte[16]; + Assert.True(UuidCodec.TryWriteBigEndian("stable-id", a)); + Assert.True(UuidCodec.TryWriteBigEndian("stable-id", b)); + Assert.Equal(a.ToArray(), b.ToArray()); + } + + [Theory] + [InlineData(0)] // empty: upstream errors + [InlineData(31)] // too long to derive, too short to be canonical: upstream errors + [InlineData(37)] // longer than any canonical form: upstream errors + [InlineData(40)] + public void UuidCodec_RejectsLengthsUpstreamRejects(int length) + { + Span dest = stackalloc byte[16]; + Assert.False(UuidCodec.TryWriteBigEndian(new string('x', length), dest)); + } + + [Theory] + [InlineData(1)] + [InlineData(30)] // longest id upstream will derive from + public void UuidCodec_AcceptsDerivableLengths(int length) + { + Span dest = stackalloc byte[16]; + Assert.True(UuidCodec.TryWriteBigEndian(new string('x', length), dest)); + } + + [Fact] + public void UuidCodec_Rejects32To36CharsThatAreNotHex() + { + // Inside the canonical length window there is no derivation fallback: upstream + // parses these as hex and fails, and so must we. + Span dest = stackalloc byte[16]; + Assert.False(UuidCodec.TryWriteBigEndian(new string('z', 32), dest)); + Assert.False(UuidCodec.TryWriteBigEndian(new string('z', 36), dest)); + } + + [Fact] + public void UuidCodec_CanonicalUuid_IsNotDerived() + { + // A real UUID must still round-trip to its own bytes, not to a hash of its text. + Span dest = stackalloc byte[16]; + Assert.True(UuidCodec.TryWriteBigEndian(Uuid, dest)); + Assert.Equal(UuidBigEndian, dest.ToArray()); + } + + // === ProxyAddress (VLESS type codes: 01 IPv4, 02 domain, 03 IPv6) === + + [Fact] + public void ProxyAddress_IPv4() + { + Span buf = stackalloc byte[ProxyAddress.MaxLength]; + int n = ProxyAddress.WriteTypeAndAddress("1.2.3.4", buf, 0x01, 0x02, 0x03); + Assert.Equal(5, n); + Assert.Equal([0x01, 1, 2, 3, 4], buf.Slice(0, n).ToArray()); + } + + [Fact] + public void ProxyAddress_Domain() + { + Span buf = stackalloc byte[ProxyAddress.MaxLength]; + int n = ProxyAddress.WriteTypeAndAddress("mc.example.com", buf, 0x01, 0x02, 0x03); + Assert.Equal(0x02, buf[0]); // domain type + Assert.Equal(14, buf[1]); // length + Assert.Equal("mc.example.com"u8.ToArray(), buf.Slice(2, 14).ToArray()); + Assert.Equal(2 + 14, n); + } + + [Fact] + public void ProxyAddress_IPv6() + { + Span buf = stackalloc byte[ProxyAddress.MaxLength]; + int n = ProxyAddress.WriteTypeAndAddress("2001:db8::1", buf, 0x01, 0x02, 0x03); + Assert.Equal(0x03, buf[0]); // IPv6 type + Assert.Equal(1 + 16, n); + } + + // === VlessHelper.BuildRequest === + + [Fact] + public void BuildRequest_ProducesExactWireBytes() + { + Span buf = stackalloc byte[512]; + int n = VlessHelper.BuildRequest(buf, Uuid, "mc.example.com", 25565); + + byte[] expected = + [ + 0x00, // version + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, // uuid + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, + 0x00, // addons length + 0x01, // command TCP + 0x63, 0xDD, // port 25565 + 0x02, // domain address type + 0x0E, // domain length 14 + 0x6D, 0x63, 0x2E, 0x65, 0x78, 0x61, 0x6D, // "mc.example.com" + 0x70, 0x6C, 0x65, 0x2E, 0x63, 0x6F, 0x6D + ]; + Assert.Equal(expected, buf.Slice(0, n).ToArray()); + } + + // === VlessShareLink.Parse === + + [Fact] + public void Parse_SecurityNone_Defaults() + { + var o = VlessShareLink.Parse($"vless://{Uuid}@example.com:443?type=tcp&security=none#node1"); + Assert.Equal(Uuid, o.Id); + Assert.Equal("example.com", o.Host); + Assert.Equal(443, o.Port); + Assert.Equal(VlessSecurity.None, o.Security); + Assert.Equal(TransportKind.RawTcp, o.TransportKind); + Assert.Equal("node1", o.Remark); + } + + [Fact] + public void Parse_Tls_WithSniAndAlpn() + { + var o = VlessShareLink.Parse( + $"vless://{Uuid}@1.2.3.4:8443?type=tcp&security=tls&sni=cdn.example.com&alpn=h2%2Chttp%2F1.1#tls-node"); + Assert.Equal(VlessSecurity.Tls, o.Security); + Assert.Equal("cdn.example.com", o.Sni); + Assert.NotNull(o.Alpn); + Assert.Equal(["h2", "http/1.1"], o.Alpn); + } + + [Fact] + 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"); + Assert.Equal(VlessSecurity.Reality, o.Security); + Assert.Equal("PUBKEY", o.RealityPublicKey); + Assert.Equal("ab12", o.RealityShortId); + Assert.Equal("chrome", o.Fingerprint); + Assert.Equal("xtls-rprx-vision", o.Flow); + } + + [Theory] + [InlineData("")] + [InlineData("http://example.com:443")] + [InlineData("vless://@example.com:443")] + public void TryParse_RejectsInvalid(string link) + { + Assert.False(VlessShareLink.TryParse(link, out _)); + } + + [Fact] + public void TryParse_ShortNonUuidId_IsAccepted_AndKeptVerbatim() + { + // Xray maps a 1..30 character id to UUIDv5(nil, id) rather than rejecting it. + // The options keep the id as written; the derivation happens at the wire encoder. + Assert.True(VlessShareLink.TryParse("vless://not-a-uuid@example.com:443", out var o)); + Assert.Equal("not-a-uuid", o.Id); + } + + [Fact] + public void TryParse_MalformedUri_ReportsTheRealProblem_NotTheScheme() + { + // Broken generators leave "&key=value" in the authority, before the first '?'. + // The old message blamed the scheme, which sends the reader hunting in the wrong + // place; 87 of 15031 real-world vless links hit exactly this path. + var ex = Assert.Throws(() => + VlessShareLink.Parse("vless://11223344-5566-7788-99aa-bbccddeeff00@1.2.3.4:443&type=raw?type=tcp")); + Assert.Contains("not a well-formed URI", ex.Message); + Assert.DoesNotContain("must start with", ex.Message); + } + + [Fact] + public void Parse_UnknownSecurity_Rejected_NoSilentPlaintextDowngrade() + { + // A typo like security=tsl must NOT silently fall back to plaintext. + Assert.False(VlessShareLink.TryParse($"vless://{Uuid}@example.com:443?security=tsl", out _)); + } + + // === HTML-escaped links (& as the separator) === + // + // 68 vless and 10 trojan links in a 17k real-world corpus are published HTML-escaped, + // 51 of them REALITY. Splitting on '&' leaves keys named "amp;security", "amp;flow". + // Treating those as unknown keys is NOT harmless: the node then looks like plain + // security=none with no flow, passes EnsureSupported, and the client connects in + // cleartext — sending the UUID unencrypted to a REALITY server. + + [Fact] + public void Parse_HtmlEscapedSeparators_DoNotSilentlyDowngradeRealityToPlaintext() + { + var o = VlessShareLink.Parse( + $"vless://{Uuid}@example.com:443?type=tcp&security=reality&pbk=PUBKEY" + + "&sid=ab12&flow=xtls-rprx-vision"); + + Assert.Equal(VlessSecurity.Reality, o.Security); + Assert.Equal("PUBKEY", o.RealityPublicKey); + Assert.Equal("ab12", o.RealityShortId); + Assert.Equal("xtls-rprx-vision", o.Flow); + } + + /// + /// The escaped link having parsed as REALITY is only half the guarantee. The other half is + /// that connecting actually starts a TLS handshake — the failure this guards against is the + /// UUID going out in cleartext to a server expecting REALITY. + /// + [Fact] + public void HtmlEscapedRealityLink_StartsATlsHandshake_NotACleartextRequest() + { + // A syntactically real key, so the handshake gets as far as writing a ClientHello. + const string PublicKey = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; + var o = VlessShareLink.Parse( + $"vless://{Uuid}@example.com:443?type=tcp&security=reality&pbk={PublicKey}" + + "&sid=ab12&sni=www.example.org"); + + Assert.Equal(VlessSecurity.Reality, o.Security); + + // 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(() => + new VlessClient(o).ConnectAsync(transport, "example.com", 443).AsTask().GetAwaiter().GetResult()); + + byte[] written = transport.ToArray(); + Assert.NotEmpty(written); + Assert.Equal(0x16, written[0]); // TLS handshake record + Assert.Equal(0x03, written[1]); + Assert.DoesNotContain(UuidBigEndian, IndexesOf(written)); // and no cleartext credential + + static IEnumerable IndexesOf(byte[] haystack) => + Enumerable.Range(0, Math.Max(haystack.Length - UuidBigEndian.Length + 1, 0)) + .Select(i => haystack.AsSpan(i, UuidBigEndian.Length).ToArray()); + } + + [Fact] + public void Parse_HtmlEscapedSeparators_TlsIsNotLostEither() + { + var o = VlessShareLink.Parse( + $"vless://{Uuid}@example.com:443?type=tcp&security=tls&sni=cdn.example.com"); + Assert.Equal(VlessSecurity.Tls, o.Security); + Assert.Equal("cdn.example.com", o.Sni); + } + + [Fact] + public void Parse_IPv6Host_StripsBrackets() + { + var o = VlessShareLink.Parse($"vless://{Uuid}@[2001:db8::1]:443?security=none"); + Assert.Equal("2001:db8::1", o.Host); + } + + // === VlessClient construction === + + [Fact] + public void Client_NullOptions_ThrowsArgumentNull() + { + Assert.Throws(() => new VlessClient(null!)); + } + + [Fact] + public void Client_IPv6Host_ConstructsWithoutThrowing() + { + // The base ProxyClient ctor must bracket the IPv6 literal when composing ProxyUri. + 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); + } + + [Fact] + public void Client_UnusableUuid_ThrowsAtConstruction() + { + // 31 chars: outside Xray's 1..30 derivation window and not a canonical UUID. + var bad = new VlessOptions { Id = new string('x', 31), Host = "example.com", Port = 443 }; + Assert.Throws(() => new VlessClient(bad)); + } + + [Fact] + public void Client_ShortNonUuidId_ConstructsWithoutThrowing() + { + // The client must accept exactly what the share-link parser accepts; validating + // with Guid.TryParse here would reject links that parsed fine a moment earlier. + var o = new VlessOptions { Id = "not-a-uuid", Host = "example.com", Port = 443 }; + var client = new VlessClient(o); + Assert.Equal("example.com", client.ProxyHost); + } + + // === VlessClient response header (via FakeProxyStream) === + // + // The response header is validated on the FIRST READ, not inside ConnectAsync. Neither + // Xray nor sing-box flushes `ver + addonsLen` until the target has produced data, so an + // eager read deadlocks every client-speaks-first protocol (HTTP, TLS, Minecraft) — the + // same trap VmessResponseStream already documents. That was measured against both servers + // and is pinned end to end by DockerProtocolTests. + // + // The assertions below therefore drive a read; the exceptions and error codes they expect + // are unchanged. + + [Fact] + public async Task Client_ServerClosesEarly_WrapsAsProxyProtocolException() + { + // Server sends only 1 byte then EOF (typical wrong-UUID drop). + var stream = new FakeProxyStream([0x00]); + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443")); + + var tunnel = await client.ConnectAsync(stream, "example.org", 443, CancellationToken.None); + + var ex = await Assert.ThrowsAsync( + async () => Assert.Equal(0, await tunnel.ReadAsync(new byte[16]))); + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + + [Fact] + public async Task Client_None_WritesRequest_AndDefersResponseHeader() + { + // Server response: ver=00, addonsLen=00, then the target's payload. + var stream = new FakeProxyStream([0x00, 0x00, 0x41, 0x42]); + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=none")); + + var tunnel = await client.ConnectAsync(stream, "mc.example.com", 25565, CancellationToken.None); + + // The request must already be on the wire when ConnectAsync returns... + var written = stream.WrittenBytes; + Assert.Equal(0x00, written[0]); // version + Assert.Equal(UuidBigEndian, written[1..17]); // uuid big-endian + Assert.Equal(0x01, written[18]); // TCP command + + // ...while the response header has not been touched yet. + Assert.NotSame(stream, tunnel); + + // The first read consumes the header and hands back only the target's bytes. + var buffer = new byte[16]; + int read = await tunnel.ReadAsync(buffer); + Assert.Equal(2, read); + Assert.Equal([0x41, 0x42], buffer[..read]); + } + + [Fact] + public async Task Client_None_DrainsAddons() + { + // ver=00, addonsLen=03, then 3 addon bytes, then the target's payload. + var stream = new FakeProxyStream([0x00, 0x03, 0xAA, 0xBB, 0xCC, 0x5A]); + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443")); + + var tunnel = await client.ConnectAsync(stream, "example.org", 443, CancellationToken.None); + + var buffer = new byte[16]; + int read = await tunnel.ReadAsync(buffer); + Assert.Equal(1, read); + Assert.Equal(0x5A, buffer[0]); // addons were drained, not returned as payload + } + + [Fact] + public async Task Client_BadResponseVersion_Throws() + { + var stream = new FakeProxyStream([0x01, 0x00]); // wrong version + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443")); + + var tunnel = await client.ConnectAsync(stream, "example.org", 443, CancellationToken.None); + + var ex = await Assert.ThrowsAsync( + async () => Assert.Equal(0, await tunnel.ReadAsync(new byte[16]))); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + [Fact] + public async Task Client_None_DoesNotReadResponseHeaderDuringConnect() + { + // The regression guard for the deadlock: a server that sends NOTHING must still let + // ConnectAsync complete, because a real VLESS server sends nothing until the client's + // request has reached the target. + var stream = new FakeProxyStream([]); + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=none")); + + var tunnel = await client.ConnectAsync(stream, "example.org", 443, CancellationToken.None); + + Assert.NotNull(tunnel); + Assert.NotEmpty(stream.WrittenBytes); + } + + /// + /// 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. + /// + [Theory] + [InlineData("x")] // not base64url at all + [InlineData("AAAA")] // decodes to 3 bytes, not 32 + public async Task Client_Reality_MalformedPublicKey_ThrowsFormatBeforeWriting(string pbk) + { + var stream = new FakeProxyStream([0x00, 0x00]); + var client = new VlessClient( + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=reality&pbk={pbk}")); + + var ex = await Assert.ThrowsAsync( + () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + + Assert.Contains(pbk, ex.Message); + } + + /// + /// 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. + /// + [Fact] + public void RealityHandshakeException_IsAProxyProtocolExceptionWithACode() + { + Assert.IsAssignableFrom(new RealityHandshakeException("x")); + Assert.Equal(ProxyErrorCode.InvalidResponse, new RealityHandshakeException("x").ErrorCode); + Assert.Equal(ProxyErrorCode.AuthFailed, + new RealityHandshakeException(ProxyErrorCode.AuthFailed, "x").ErrorCode); + } + + /// + /// The user id is the credential. A mistyped real one lands in the same error as garbage + /// does, and the error text ends up in logs — so the text must not contain the id. + /// + [Fact] + public void Client_BadUserId_DoesNotEchoTheIdInTheError() + { + const string almostAUuid = "11223344-5566-7788-99aa-bbccddeeff00-SECRETTAIL"; + + var ex = Assert.Throws(() => + new VlessClient(new VlessOptions { Id = almostAUuid, Host = "example.com", Port = 443 })); + + Assert.DoesNotContain("SECRETTAIL", ex.Message); + Assert.DoesNotContain("11223344", ex.Message); + Assert.Contains("user id", ex.Message); + } + + [Fact] + public async Task Client_UnsupportedTransport_Throws() + { + var stream = new FakeProxyStream([0x00, 0x00]); + var client = new VlessClient( + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?type=grpc&security=none")); + + await Assert.ThrowsAsync( + () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + } + + // === Factory === + + [Fact] + public void Factory_CreatesVlessClient() + { + var client = ProxyClientFactory.Instance.Create( + new Uri($"vless://{Uuid}@example.com:443?security=tls&sni=a.com")); + var vless = Assert.IsType(client); + Assert.Equal(ProxyType.Vless, vless.Type); + Assert.Equal(VlessSecurity.Tls, vless.Options.Security); + } +} diff --git a/QuickProxyNet.Tests/VmessBodyTest.cs b/QuickProxyNet.Tests/VmessBodyTest.cs new file mode 100644 index 0000000..9e38c26 --- /dev/null +++ b/QuickProxyNet.Tests/VmessBodyTest.cs @@ -0,0 +1,932 @@ +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, so the analyzer is +// off for this file. +#pragma warning disable CA2022 + +namespace QuickProxyNet.Tests; + +/// +/// Tests for the VMessAEAD (alterId = 0) server response header () +/// and the encrypted body stream (). +/// +/// Every wire vector below is GROUND TRUTH produced by an independent Python +/// reimplementation (scratchpad/vmess_body_truth.py) written from docs/vmess-aead-body.md +/// alone — stdlib hashlib/struct, a hand-rolled ipad/opad nested KDF, and AES-GCM / +/// ChaCha20-Poly1305 from `cryptography`. That script first reproduces public digests and +/// every response-header KDF / body-key vector already committed in VmessCryptoTest.cs +/// before a single new byte is trusted. +/// +/// All inputs are synthetic counting byte patterns; the body key and IV are the same ones +/// VmessRequestTest.cs puts in the request header, so the two suites describe one session. +/// +public class VmessBodyTest +{ + // ---- synthetic session (must match scratchpad/vmess_body_truth.py) ---- + private static readonly byte[] RequestBodyKey = Hex("b0b1b2b3b4b5b6b7b8b9babbbcbdbebf"); // b0..bf + private static readonly byte[] RequestBodyIv = Hex("a0a1a2a3a4a5a6a7a8a9aaabacadaeaf"); // a0..af + private const byte RespV = 0x2A; + + // ---- pinned §1.1 / §3.2 derivations ---- + private const string ExpectedResponseBodyKey = "9f52527783ea1185acd5d4dcf1bf91b7"; + private const string ExpectedResponseBodyIv = "503563c1bda45327ff4617750a06bd81"; + private const string ExpectedLengthKey = "9aa6c3a953070fb2b824d497eff752eb"; + private const string ExpectedLengthIv = "e78e477b1580b507a7b362d4"; + private const string ExpectedHeaderKey = "121c1a9b66ac3e594b88e0dc0b18cf4e"; + private const string ExpectedHeaderIv = "97b677a44b45c1ebaa0b9dca"; + + // ---- pinned §3.3 response-header wire blobs ---- + // plaintext 2a 00 00 00 (respV, option 0, command 0, commandLength 0) + private const string ResponseHeaderSimple = + "81ca9ac9e3abe6c98c139ad323b26980eff9" + + "35010f1736251faac223a9f0b6850515d7551c49"; + + // plaintext 2a 11 01 03 aa bb cc (option 0x11, command 1, 3 bytes of command data) + private const string ResponseHeaderWithCommand = + "81c9eb8cb14aa1f067216223a98cf6de6b5c" + + "35100e1490b0751dd3edb3d77003356deb9dba3b262609"; + + // ---- pinned §2 body chunks: "hello", "A", then the empty terminating chunk ---- + private const string ReqAesChunk0 = "001544389a0d0c1a7cfbb71cffad86675dd476786bf27e"; + private const string ReqAesChunk1 = "001175176c3bb4505f393ef9a69e4edfb3658c"; + private const string ReqAesChunk2 = "00108395338e49af5292e4781b6bd1bb1740"; + private const string ReqAesStream = ReqAesChunk0 + ReqAesChunk1 + ReqAesChunk2; + + private const string RespAesChunk0 = "001552e3196f715e243c00a891bdf7d6e27f0ea77bdc5c"; + private const string RespAesChunk1 = "001103bd1f0c159ce095a258433d6abd36b5ae"; + private const string RespAesChunk2 = "00109bd03fbc3f9121279559c23d393cc81f"; + private const string RespAesStream = RespAesChunk0 + RespAesChunk1 + RespAesChunk2; + + private const string ReqChaChaStream = + "0015c3594c59801a4515b6568b2f7d20e718896061fa0c" + + "00116b1fb18d3171052494b63e7764f2b94336" + + "0010714efaf791b66f10a481aaa6ef614e6c"; + + private const string RespChaChaStream = + "0015414af98c7ab728bb713e78cd2d85532c776286c679" + + "0011a2a7190cc1e6d6a7102002dde97f92bbb6" + + "0010dc6d73c188506318fe0317271056986a"; + + private const string ExpectedChaChaRequestKey = + "790c29e849c35d78178bd38cee4cb5e38c9fa2ef9aa23a1cfc546c546f01046c"; + + private static byte[] Hex(string h) => Convert.FromHexString(h); + private static byte[] ResponseBodyKey => Hex(ExpectedResponseBodyKey); + private static byte[] ResponseBodyIv => Hex(ExpectedResponseBodyIv); + + private static VmessStream ClientStream( + DuplexTestStream transport, VmessSecurity security = VmessSecurity.Aes128Gcm) + => new(transport, RequestBodyKey, RequestBodyIv, ResponseBodyKey, ResponseBodyIv, + security, leaveInnerOpen: true); + + // Independent re-implementation of §1.4 + §2.1, used only to build the oversized + // fixtures the pinned vectors do not cover. + private static byte[] SealChunk(byte[] key, byte[] iv, ushort counter, byte[] plaintext) + { + byte[] nonce = new byte[12]; + BinaryPrimitives.WriteUInt16BigEndian(nonce, counter); + iv.AsSpan(2, 10).CopyTo(nonce.AsSpan(2)); + + byte[] wire = new byte[2 + plaintext.Length + 16]; + BinaryPrimitives.WriteUInt16BigEndian(wire, (ushort)(plaintext.Length + 16)); + using var gcm = new AesGcm(key, 16); + gcm.Encrypt(nonce, plaintext, wire.AsSpan(2, plaintext.Length), wire.AsSpan(2 + plaintext.Length, 16)); + return wire; + } + + private static byte[] Concat(byte[] first, byte[] second) + { + byte[] result = new byte[first.Length + second.Length]; + first.CopyTo(result, 0); + second.CopyTo(result, first.Length); + return result; + } + + // ========================= test-helper anchor ========================= + + [Fact] + public void SealChunkHelper_ReproducesThePinnedChunks() + { + // The local §1.4/§2.1 re-implementation is only trustworthy if it reproduces the + // Python-pinned chunks, so anchor it before using it to build fixtures. + Assert.Equal(ReqAesChunk0, + Convert.ToHexStringLower(SealChunk(RequestBodyKey, RequestBodyIv, 0, "hello"u8.ToArray()))); + Assert.Equal(ReqAesChunk1, + Convert.ToHexStringLower(SealChunk(RequestBodyKey, RequestBodyIv, 1, [0x41]))); + Assert.Equal(ReqAesChunk2, + Convert.ToHexStringLower(SealChunk(RequestBodyKey, RequestBodyIv, 2, []))); + Assert.Equal(RespAesChunk0, + Convert.ToHexStringLower(SealChunk(ResponseBodyKey, ResponseBodyIv, 0, "hello"u8.ToArray()))); + Assert.Equal(RespAesChunk2, + Convert.ToHexStringLower(SealChunk(ResponseBodyKey, ResponseBodyIv, 2, []))); + } + + // ========================= §1.1 response key/IV ========================= + + [Fact] + public void DeriveBodyKeys_MatchesGroundTruth() + { + Span key = stackalloc byte[16]; + Span iv = stackalloc byte[16]; + VmessResponse.DeriveBodyKeys(RequestBodyKey, RequestBodyIv, key, iv); + + Assert.Equal(ExpectedResponseBodyKey, Convert.ToHexStringLower(key)); + Assert.Equal(ExpectedResponseBodyIv, Convert.ToHexStringLower(iv)); + } + + [Fact] + public void DeriveBodyKeys_IsSha256Truncated() + { + // Independent re-computation of §1.1 straight from SHA-256. + Assert.Equal( + ExpectedResponseBodyKey, + Convert.ToHexStringLower(SHA256.HashData(RequestBodyKey).AsSpan(0, 16))); + Assert.Equal( + ExpectedResponseBodyIv, + Convert.ToHexStringLower(SHA256.HashData(RequestBodyIv).AsSpan(0, 16))); + } + + [Fact] + public void DeriveBodyKeys_WrongInputSize_Throws() + { + Assert.Throws(() => + { + Span key = stackalloc byte[16]; + Span iv = stackalloc byte[16]; + VmessResponse.DeriveBodyKeys(new byte[15], RequestBodyIv, key, iv); + }); + + Assert.Throws(() => + { + Span key = stackalloc byte[16]; + Span iv = stackalloc byte[16]; + VmessResponse.DeriveBodyKeys(RequestBodyKey, new byte[17], key, iv); + }); + } + + // ========================= §3.2 response-header KDF ========================= + + [Fact] + public void DeriveHeaderKeys_MatchesGroundTruth() + { + Span lengthKey = stackalloc byte[16]; + Span lengthIv = stackalloc byte[12]; + Span headerKey = stackalloc byte[16]; + Span headerIv = stackalloc byte[12]; + + VmessResponse.DeriveHeaderKeys( + ResponseBodyKey, ResponseBodyIv, lengthKey, lengthIv, headerKey, headerIv); + + Assert.Equal(ExpectedLengthKey, Convert.ToHexStringLower(lengthKey)); + Assert.Equal(ExpectedLengthIv, Convert.ToHexStringLower(lengthIv)); + Assert.Equal(ExpectedHeaderKey, Convert.ToHexStringLower(headerKey)); + Assert.Equal(ExpectedHeaderIv, Convert.ToHexStringLower(headerIv)); + } + + [Fact] + public void DeriveHeaderKeys_KeysComeFromTheKey_IvsFromTheIv() + { + // Re-derive each value with the single-path KDF directly: both *keys* must be + // functions of responseBodyKey only, both *IVs* of responseBodyIV only. + Span scratch = stackalloc byte[16]; + VmessKdf.Kdf16(ResponseBodyKey, "AEAD Resp Header Len Key"u8, scratch); + Assert.Equal(ExpectedLengthKey, Convert.ToHexStringLower(scratch)); + + VmessKdf.Kdf16(ResponseBodyKey, "AEAD Resp Header Key"u8, scratch); + Assert.Equal(ExpectedHeaderKey, Convert.ToHexStringLower(scratch)); + + Span nonce = stackalloc byte[12]; + VmessKdf.Kdf12(ResponseBodyIv, "AEAD Resp Header Len IV"u8, nonce); + Assert.Equal(ExpectedLengthIv, Convert.ToHexStringLower(nonce)); + + VmessKdf.Kdf12(ResponseBodyIv, "AEAD Resp Header IV"u8, nonce); + Assert.Equal(ExpectedHeaderIv, Convert.ToHexStringLower(nonce)); + + // Swapping the two inputs must NOT reproduce the pinned values. + VmessKdf.Kdf16(ResponseBodyIv, "AEAD Resp Header Key"u8, scratch); + Assert.NotEqual(ExpectedHeaderKey, Convert.ToHexStringLower(scratch)); + } + + [Fact] + public void DeriveHeaderKeys_WrongInputSize_Throws() + { + Assert.Throws(() => + { + Span lengthKey = stackalloc byte[16]; + Span lengthIv = stackalloc byte[12]; + Span headerKey = stackalloc byte[16]; + Span headerIv = stackalloc byte[12]; + VmessResponse.DeriveHeaderKeys(new byte[15], ResponseBodyIv, lengthKey, lengthIv, headerKey, headerIv); + }); + } + + // ========================= §3.3/§3.4 response header ========================= + + [Fact] + public async Task ReadResponseHeader_HappyPath_MatchesGroundTruth() + { + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple + RespAesStream)); + + var header = await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None); + + Assert.Equal(RespV, header.ResponseVerifier); + Assert.Equal(0x00, header.Option); + Assert.Equal(0x00, header.Command); + Assert.Equal(0x00, header.CommandLength); + + // Exactly 18 + 4 + 16 = 38 bytes consumed; the body chunks are left untouched. + Assert.Equal(RespAesStream, Convert.ToHexStringLower(transport.Unread)); + } + + [Fact] + public async Task ReadResponseHeader_WithCommand_ParsesAndSkipsCommandData() + { + var transport = new DuplexTestStream(Hex(ResponseHeaderWithCommand)); + + var header = await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None); + + Assert.Equal(RespV, header.ResponseVerifier); + Assert.Equal(0x11, header.Option); + Assert.Equal(0x01, header.Command); + Assert.Equal(0x03, header.CommandLength); + + // The command data lives inside the sealed header, so nothing is left over. + Assert.Empty(transport.Unread); + } + + [Fact] + public async Task ReadResponseHeader_ResponseVerifierMismatch_IsRejected() + { + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple)); + + var ex = await Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, 0x2B, CancellationToken.None)); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + Assert.Contains("0x2B", ex.Message); + Assert.Contains("0x2A", ex.Message); + } + + [Fact] + public async Task ReadResponseHeader_TruncatedLengthPrefix_IsAnErrorNotEof() + { + // 17 of the 18 length bytes: truncation, never a clean end of stream. + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple)[..17]); + + await AssertClosedBeforeResponseAsync(transport); + } + + [Fact] + public async Task ReadResponseHeader_EmptyStream_IsAnErrorNotEof() + { + var transport = new DuplexTestStream([]); + + await AssertClosedBeforeResponseAsync(transport); + } + + /// + /// A connection that ends before the response header is how a VMess server rejects a request + /// it cannot authenticate. That has to surface as a proxy error naming the protocol and the + /// usual causes, with the raw end-of-stream kept underneath — not as the raw end-of-stream. + /// + private static async Task AssertClosedBeforeResponseAsync(DuplexTestStream transport) + { + var ex = await Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + Assert.IsType(ex.InnerException); + Assert.Contains("VMess", ex.Message); + Assert.Contains("clock", ex.Message); + } + + [Fact] + public async Task ReadResponseHeader_TruncatedSealedHeader_IsAnErrorNotEof() + { + // Full 18-byte length block, then only 19 of the 20 sealed header bytes. + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple)[..^1]); + + await AssertClosedBeforeResponseAsync(transport); + } + + [Fact] + public async Task ReadResponseHeader_TamperedLengthBlock_FailsAuthentication() + { + byte[] wire = Hex(ResponseHeaderSimple); + wire[0] ^= 0xFF; + var transport = new DuplexTestStream(wire); + + var ex = await Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + + // A length block sealed under other keys is the same class of failure as a verifier + // mismatch — the server rejected us or something else answered — so it gets the same code. + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + Assert.IsType(ex.InnerException); + } + + [Fact] + public async Task ReadResponseHeader_TamperedHeader_FailsAuthentication() + { + byte[] wire = Hex(ResponseHeaderSimple); + wire[^1] ^= 0xFF; + var transport = new DuplexTestStream(wire); + + var ex = await Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + [Fact] + public async Task ReadResponseHeader_WrongKeySize_Throws() + { + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple)); + + await Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, new byte[15], ResponseBodyIv, RespV, CancellationToken.None)); + } + + [Fact] + public async Task ReadResponseHeader_DrippingTransport_ReadsExactly() + { + // A transport that hands out one byte at a time must still reassemble the header. + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple + RespAesStream), maxReadSize: 1); + + var header = await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None); + + Assert.Equal(RespV, header.ResponseVerifier); + Assert.Equal(RespAesStream, Convert.ToHexStringLower(transport.Unread)); + } + + // ========================= §2 body: write path ========================= + + [Fact] + public async Task Write_ProducesThePinnedWireBytes() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + await stream.WriteAsync("hello"u8.ToArray()); + Assert.Equal(ReqAesChunk0, Convert.ToHexStringLower(transport.Written)); + + await stream.WriteAsync(new byte[] { 0x41 }); + Assert.Equal(ReqAesChunk0 + ReqAesChunk1, Convert.ToHexStringLower(transport.Written)); + + await stream.CompleteWriteAsync(); + Assert.Equal(ReqAesStream, Convert.ToHexStringLower(transport.Written)); + } + + [Fact] + public async Task Write_LengthFieldIsTheSealedSize_NotThePlaintextSize() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + await stream.WriteAsync("hello"u8.ToArray()); + + byte[] wire = transport.Written; + Assert.Equal(21, BinaryPrimitives.ReadUInt16BigEndian(wire)); // 5 + 16, not 5 + Assert.Equal(23, wire.Length); // 2 + 21 + } + + [Fact] + public async Task Write_TerminatorIsAnAuthenticatedEmptyChunk() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + await stream.CompleteWriteAsync(); + + byte[] wire = transport.Written; + Assert.Equal(18, wire.Length); + Assert.Equal(0x00, wire[0]); + Assert.Equal(0x10, wire[1]); // length == Overhead == 16 + Assert.True(stream.IsWriteCompleted); + } + + [Fact] + public async Task Write_TerminatorIsIdempotent() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + await stream.CompleteWriteAsync(); + await stream.CompleteWriteAsync(); + await stream.DisposeAsync(); + await stream.DisposeAsync(); + + Assert.Equal(18, transport.Written.Length); + } + + [Fact] + public async Task Dispose_WritesTheTerminatingChunkExactlyOnce() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + await stream.WriteAsync("hello"u8.ToArray()); + await stream.WriteAsync(new byte[] { 0x41 }); + await stream.DisposeAsync(); + + Assert.Equal(ReqAesStream, Convert.ToHexStringLower(transport.Written)); + } + + [Fact] + public async Task Dispose_DisposesTheInnerStreamUnlessAskedNotTo() + { + var owned = new DuplexTestStream([]); + await new VmessStream(owned, RequestBodyKey, RequestBodyIv, ResponseBodyKey, ResponseBodyIv, + VmessSecurity.Aes128Gcm).DisposeAsync(); + Assert.Equal(1, owned.DisposeCount); + + var borrowed = new DuplexTestStream([]); + await ClientStream(borrowed).DisposeAsync(); + Assert.Equal(0, borrowed.DisposeCount); + } + + [Fact] + public async Task Write_AfterCompletion_Throws() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + await stream.CompleteWriteAsync(); + + Assert.False(stream.CanWrite); + await Assert.ThrowsAsync(async () => + await stream.WriteAsync("x"u8.ToArray())); + } + + [Fact] + public async Task Write_EmptyBuffer_EmitsNothing() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + await stream.WriteAsync(ReadOnlyMemory.Empty); + + // An empty write must NOT be confused with the terminating chunk. + Assert.Empty(transport.Written); + Assert.Equal(0, stream.WriteChunkCounter); + } + + [Fact] + public async Task Write_LargeBuffer_SplitsAtTheSendChunkBound() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + byte[] payload = new byte[20000]; + for (int i = 0; i < payload.Length; i++) + payload[i] = (byte)i; + + await stream.WriteAsync(payload); + + byte[] wire = transport.Written; + int offset = 0; + foreach (int plaintextLength in new[] { 8174, 8174, 20000 - (2 * 8174) }) + { + Assert.Equal(plaintextLength + 16, BinaryPrimitives.ReadUInt16BigEndian(wire.AsSpan(offset))); + offset += 2 + plaintextLength + 16; + } + + Assert.Equal(wire.Length, offset); + Assert.Equal(3, stream.WriteChunkCounter); + Assert.Equal(8174, VmessStream.MaxSendPlaintextSize); + } + + [Fact] + public void Write_SyncOverloadDelegatesToTheAsyncPath() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + stream.Write("hello"u8); + stream.Write([0x41], 0, 1); + + Assert.Equal(ReqAesChunk0 + ReqAesChunk1, Convert.ToHexStringLower(transport.Written)); + } + + // ========================= §2 body: read path ========================= + + [Fact] + public async Task Read_RoundTripsThePinnedWireBytes() + { + var transport = new DuplexTestStream(Hex(RespAesStream)); + var stream = ClientStream(transport); + + byte[] buffer = new byte[64]; + + int read = await stream.ReadAsync(buffer); + Assert.Equal("hello"u8.ToArray(), buffer[..read]); + + read = await stream.ReadAsync(buffer); + Assert.Equal(new byte[] { 0x41 }, buffer[..read]); + + Assert.Equal(0, await stream.ReadAsync(buffer)); + Assert.True(stream.IsReadCompleted); + + // Subsequent reads keep reporting a clean end of stream. + Assert.Equal(0, await stream.ReadAsync(buffer)); + } + + [Fact] + public async Task Read_BufferSmallerThanTheChunk_BuffersTheLeftover() + { + var transport = new DuplexTestStream(Hex(RespAesStream)); + var stream = ClientStream(transport); + + 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]); + } + + // "hello" is 5 bytes: 2 + 2 + 1 — the third read must stop at the chunk boundary + // instead of merging the next chunk in, and only one chunk may have been opened. + Assert.Equal("hello"u8.ToArray(), assembled); + Assert.Equal(1, stream.ReadChunkCounter); + + int last = await stream.ReadAsync(two); + Assert.Equal(new byte[] { 0x41 }, two[..last]); + Assert.Equal(0, await stream.ReadAsync(two)); + } + + [Fact] + public async Task Read_DrippingTransport_ReassemblesChunks() + { + var transport = new DuplexTestStream(Hex(RespAesStream), maxReadSize: 1); + var stream = ClientStream(transport); + + byte[] buffer = new byte[64]; + int read = await stream.ReadAsync(buffer); + + Assert.Equal("hello"u8.ToArray(), buffer[..read]); + } + + [Fact] + public async Task Read_EmptyChunkIsTheOnlyCleanEof() + { + // A stream whose very first chunk is the terminator (counter 0). + var transport = new DuplexTestStream(SealChunk(ResponseBodyKey, ResponseBodyIv, 0, [])); + var stream = ClientStream(transport); + + Assert.Equal(0, await stream.ReadAsync(new byte[16])); + Assert.True(stream.IsReadCompleted); + Assert.Equal(1, stream.ReadChunkCounter); // the empty chunk was opened and verified + } + + [Fact] + public async Task Read_BadTag_IsAHardErrorNotEof() + { + byte[] wire = Hex(RespAesChunk0); + wire[^1] ^= 0xFF; + var transport = new DuplexTestStream(wire); + var stream = ClientStream(transport); + + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + } + + [Fact] + public async Task Read_TamperedTerminator_IsAHardErrorNotEof() + { + byte[] wire = SealChunk(ResponseBodyKey, ResponseBodyIv, 0, []); + wire[^1] ^= 0xFF; + var transport = new DuplexTestStream(wire); + var stream = ClientStream(transport); + + // The empty chunk is authenticated: a broken tag must not be reported as EOF. + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + Assert.False(stream.IsReadCompleted); + } + + [Fact] + public async Task Read_ChunksOutOfOrder_FailTheNonceCheck() + { + // The chunk sealed with counter 1 cannot be opened as if it were chunk 0. + var transport = new DuplexTestStream(Hex(RespAesChunk1)); + var stream = ClientStream(transport); + + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + } + + [Fact] + public async Task Read_TruncatedLengthPrefix_IsAnErrorNotEof() + { + var transport = new DuplexTestStream([0x00]); + var stream = ClientStream(transport); + + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + } + + [Fact] + public async Task Read_TruncatedChunkBody_IsAnErrorNotEof() + { + var transport = new DuplexTestStream(Hex(RespAesChunk0)[..^1]); + var stream = ClientStream(transport); + + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + } + + [Fact] + public async Task Read_TransportClosedWithoutTerminator_IsAnErrorNotEof() + { + // A complete data chunk, then a FIN with no terminating empty chunk. + var transport = new DuplexTestStream(Hex(RespAesChunk0)); + var stream = ClientStream(transport); + + byte[] buffer = new byte[64]; + Assert.Equal(5, await stream.ReadAsync(buffer)); + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(buffer)); + } + + [Fact] + public async Task Read_LengthBelowTheTagSize_IsRejected() + { + byte[] wire = new byte[17]; + wire[1] = 0x0F; // a sealed chunk shorter than the 16-byte tag is impossible + var transport = new DuplexTestStream(wire); + var stream = ClientStream(transport); + + var ex = await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + [Fact] + public async Task Read_AcceptsChunksLargerThan16384_UpToTheUint16Cap() + { + // 65519 is the wire-format maximum plaintext (65535 - 16); "16384" is not a + // v2ray/Xray constant and must not be used as a receive limit. + byte[] payload = new byte[VmessStream.MaxReceivePlaintextSize]; + for (int i = 0; i < payload.Length; i++) + payload[i] = (byte)(i * 7); + + byte[] wire = Concat( + SealChunk(ResponseBodyKey, ResponseBodyIv, 0, payload), + SealChunk(ResponseBodyKey, ResponseBodyIv, 1, [])); + + var transport = new DuplexTestStream(wire); + var stream = ClientStream(transport); + + byte[] received = new byte[payload.Length]; + int offset = 0; + while (offset < received.Length) + { + int read = await stream.ReadAsync(received.AsMemory(offset)); + Assert.True(read > 0); + offset += read; + } + + Assert.Equal(payload, received); + Assert.Equal(65519, VmessStream.MaxReceivePlaintextSize); + Assert.Equal(0, await stream.ReadAsync(received)); + } + + [Fact] + public void Read_SyncOverloadDelegatesToTheAsyncPath() + { + var transport = new DuplexTestStream(Hex(RespAesStream)); + var stream = ClientStream(transport); + + Span buffer = stackalloc byte[64]; + int read = stream.Read(buffer); + Assert.Equal("hello"u8.ToArray(), buffer[..read].ToArray()); + + byte[] array = new byte[64]; + read = stream.Read(array, 0, array.Length); + Assert.Equal(new byte[] { 0x41 }, array[..read]); + Assert.Equal(0, stream.Read(array, 0, array.Length)); + } + + // ========================= §1.4 nonce evolution ========================= + + [Fact] + public async Task ChunkNonce_CounterIncrements_AndTheIvTailStaysConstant() + { + var transport = new DuplexTestStream([]); + var stream = ClientStream(transport); + + Assert.Equal(0, stream.WriteChunkCounter); + await stream.WriteAsync("hello"u8.ToArray()); + Assert.Equal(1, stream.WriteChunkCounter); + await stream.WriteAsync(new byte[] { 0x41 }); + Assert.Equal(2, stream.WriteChunkCounter); + await stream.CompleteWriteAsync(); + Assert.Equal(3, stream.WriteChunkCounter); + + // Re-open every chunk with an independently constructed nonce: + // nonce[0:2] = uint16 BE counter, nonce[2:12] = requestBodyIV[2:12]. + byte[] wire = transport.Written; + byte[][] expectedPlaintexts = ["hello"u8.ToArray(), [0x41], []]; + + int offset = 0; + using var gcm = new AesGcm(RequestBodyKey, 16); + + for (ushort counter = 0; counter < expectedPlaintexts.Length; counter++) + { + int sealedLength = BinaryPrimitives.ReadUInt16BigEndian(wire.AsSpan(offset)); + int plaintextLength = sealedLength - 16; + + byte[] nonce = new byte[12]; + BinaryPrimitives.WriteUInt16BigEndian(nonce, counter); + RequestBodyIv.AsSpan(2, 10).CopyTo(nonce.AsSpan(2)); + + // Only the first two bytes ever change; the tail is the body IV verbatim. + Assert.Equal(RequestBodyIv[2..12], nonce[2..12]); + Assert.Equal(counter, BinaryPrimitives.ReadUInt16BigEndian(nonce)); + + byte[] plaintext = new byte[plaintextLength]; + byte[] ciphertext = wire[(offset + 2)..(offset + 2 + plaintextLength)]; + byte[] tag = wire[(offset + 2 + plaintextLength)..(offset + 2 + sealedLength)]; + gcm.Decrypt(nonce, ciphertext, tag, plaintext); + Assert.Equal(expectedPlaintexts[counter], plaintext); + + // A stale counter must not authenticate. + if (counter > 0) + { + byte[] stale = (byte[])nonce.Clone(); + BinaryPrimitives.WriteUInt16BigEndian(stale, (ushort)(counter - 1)); + Assert.Throws(() => + gcm.Decrypt(stale, ciphertext, tag, new byte[plaintextLength])); + } + + offset += 2 + sealedLength; + } + + Assert.Equal(wire.Length, offset); + } + + // ========================= ciphers & framing options ========================= + + [Fact] + public async Task ChaCha20_WriteAndRead_MatchThePinnedWireBytes() + { + if (!ChaCha20Poly1305.IsSupported) + return; // gated exactly like the implementation + + Span expanded = stackalloc byte[32]; + VmessBodyKeys.ExpandChaCha20Key(RequestBodyKey, expanded); + Assert.Equal(ExpectedChaChaRequestKey, Convert.ToHexStringLower(expanded)); + + var transport = new DuplexTestStream(Hex(RespChaChaStream)); + var stream = ClientStream(transport, VmessSecurity.ChaCha20Poly1305); + + await stream.WriteAsync("hello"u8.ToArray()); + await stream.WriteAsync(new byte[] { 0x41 }); + await stream.CompleteWriteAsync(); + Assert.Equal(ReqChaChaStream, Convert.ToHexStringLower(transport.Written)); + + byte[] buffer = new byte[64]; + int read = await stream.ReadAsync(buffer); + Assert.Equal("hello"u8.ToArray(), buffer[..read]); + read = await stream.ReadAsync(buffer); + Assert.Equal(new byte[] { 0x41 }, buffer[..read]); + Assert.Equal(0, await stream.ReadAsync(buffer)); + } + + [Fact] + public void UnsupportedSecurity_Throws() + { + var transport = new DuplexTestStream([]); + + var ex = Assert.Throws(() => + new VmessStream(transport, RequestBodyKey, RequestBodyIv, ResponseBodyKey, ResponseBodyIv, + (VmessSecurity)VmessRequest.SecurityNone)); + + Assert.Contains("5", ex.Message); + } + + [Fact] + public void Constructor_WrongKeyOrIvSize_Throws() + { + var transport = new DuplexTestStream([]); + + Assert.Throws(() => + new VmessStream(transport, new byte[15], RequestBodyIv, ResponseBodyKey, ResponseBodyIv, + VmessSecurity.Aes128Gcm)); + + Assert.Throws(() => + new VmessStream(transport, RequestBodyKey, RequestBodyIv, ResponseBodyKey, new byte[17], + VmessSecurity.Aes128Gcm)); + + Assert.Throws(() => + new VmessStream(null!, RequestBodyKey, RequestBodyIv, ResponseBodyKey, ResponseBodyIv, + VmessSecurity.Aes128Gcm)); + } + + // ========================= half-close & end-to-end ========================= + + [Fact] + public async Task HalfClose_ReadKeepsWorkingAfterTheWriteDirectionIsClosed() + { + var transport = new DuplexTestStream(Hex(RespAesStream)); + var stream = ClientStream(transport); + + await stream.CompleteWriteAsync(); + Assert.True(stream.IsWriteCompleted); + Assert.False(stream.IsReadCompleted); + + byte[] buffer = new byte[64]; + int read = await stream.ReadAsync(buffer); + Assert.Equal("hello"u8.ToArray(), buffer[..read]); + + // The two directions keep independent counters. + Assert.Equal(1, stream.WriteChunkCounter); + Assert.Equal(1, stream.ReadChunkCounter); + } + + [Fact] + public async Task ReadAndWriteDirectionsUseIndependentKeys() + { + // Response chunks are sealed with responseBodyKey; feeding the request-keyed + // chunks to the read direction must fail. + var transport = new DuplexTestStream(Hex(ReqAesChunk0)); + var stream = ClientStream(transport); + + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[64])); + } + + [Fact] + public async Task ResponseHeaderThenBody_ReadsAsOneServerStream() + { + var transport = new DuplexTestStream(Hex(ResponseHeaderSimple + RespAesStream)); + + var header = await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None); + Assert.Equal(RespV, header.ResponseVerifier); + + var stream = ClientStream(transport); + var assembled = new List(); + byte[] buffer = new byte[64]; + int read; + while ((read = await stream.ReadAsync(buffer)) > 0) + assembled.AddRange(buffer[..read]); + + Assert.Equal("helloA"u8.ToArray(), assembled); + Assert.True(stream.IsReadCompleted); + } + + [Fact] + public async Task ReadAndWrite_HonorCancellation() + { + var transport = new DuplexTestStream(Hex(RespAesStream)); + var stream = ClientStream(transport); + + 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 UseAfterDispose_Throws() + { + var transport = new DuplexTestStream(Hex(RespAesStream)); + var stream = ClientStream(transport); + await stream.DisposeAsync(); + + Assert.False(stream.CanRead); + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[16])); + await Assert.ThrowsAsync(async () => + await stream.WriteAsync("x"u8.ToArray())); + } + + [Fact] + public void Constants_MatchSpecSizes() + { + Assert.Equal(16, VmessStream.TagSize); + Assert.Equal(2, VmessStream.LengthPrefixSize); + Assert.Equal(65535, VmessStream.MaxSealedChunkSize); + Assert.Equal(65519, VmessStream.MaxReceivePlaintextSize); + Assert.Equal(8174, VmessStream.MaxSendPlaintextSize); + Assert.Equal(18, VmessResponse.LengthBlockSize); + Assert.Equal(4, VmessResponse.MinHeaderSize); + } +} diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs new file mode 100644 index 0000000..899efdd --- /dev/null +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -0,0 +1,1287 @@ +using System.Buffers.Binary; +using System.Net; +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; + +/// +/// Tests for the VMess configuration layer (, +/// ) and the client that ties the wire pieces together +/// (). +/// +/// The handshake is exercised END-TO-END against the real : the +/// test acts as the SERVER. It opens the sealed request header the client wrote — using +/// only the shared UUID, exactly as a v2ray inbound would — recovers the randomly +/// generated session keys from it, and seals a response header plus body chunks with +/// those recovered keys. That works despite having no seam for +/// injecting fixed randomness, so no production API was weakened to make it testable. +/// +/// All UUIDs, hosts and payloads are synthetic. +/// +public class VmessClientTest +{ + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + private const string ProxyHost = "proxy.example.com"; + private const int ProxyPort = 443; + + // ================================ share-link fixtures ================================ + + private static string Link(string json) + => "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + private static string LinkUrlSafeUnpadded(string json) + => "vmess://" + Convert.ToBase64String(Encoding.UTF8.GetBytes(json)) + .Replace('+', '-').Replace('/', '_').TrimEnd('='); + + /// A complete, realistic v2rayN payload with the port encoded as a string. + private const string FullJson = """ + {"v":"2","ps":"my node","add":"cdn.example.com","port":"8443", + "id":"11223344-5566-7788-99aa-bbccddeeff00","aid":"0","scy":"aes-128-gcm", + "net":"tcp","type":"none","host":"","path":"","tls":"tls","sni":"real.example.com"} + """; + + /// + /// Chosen so its standard base64 contains BOTH '+' and '/' and two '=' pad characters: + /// stripping the padding and translating to the URL-safe alphabet therefore exercises + /// every branch of the decoder at once. + /// + private const string UrlSafeJson = """ + {"v":"2","ps":"?~?~?~","add":"a.example.com","port":"443","id":"11223344-5566-7788-99aa-bbccddeeff00","aid":"0","scy":"aes-128-gcm","net":"tcp","tls":"tls","sni":"real.example.com"} + """; + + private static string MinimalJson( + string id = Uuid, string add = ProxyHost, string port = "\"443\"", string extra = "") + => $$"""{"add":"{{add}}","port":{{port}},"id":"{{id}}"{{extra}}}"""; + + // ================================ share-link: accepts ================================ + + [Fact] + public void Parse_FullLink_PortAsString() + { + var o = VmessShareLink.Parse(Link(FullJson)); + + Assert.Equal(Uuid, o.Id); + Assert.Equal("cdn.example.com", o.Host); + Assert.Equal(8443, o.Port); + Assert.Equal(0, o.AlterId); + Assert.Equal(VmessSecurityKind.Aes128Gcm, o.Security); + Assert.Equal("tcp", o.Transport); + Assert.Equal(TransportKind.RawTcp, o.TransportKind); + Assert.True(o.UseTls); + Assert.Equal("real.example.com", o.Sni); + Assert.Equal("my node", o.Remark); + Assert.False(o.AllowInsecure); + } + + [Fact] + public void Parse_PortAsJsonNumber() + { + var o = VmessShareLink.Parse(Link(MinimalJson(port: "8080"))); + Assert.Equal(8080, o.Port); + } + + [Fact] + public void Parse_UrlSafeBase64_WithoutPadding() + { + // Guard the fixture: if this ever stops holding, the test would silently stop + // covering the URL-safe alphabet and the padding restoration. + string standard = Convert.ToBase64String(Encoding.UTF8.GetBytes(UrlSafeJson)); + Assert.Contains('+', standard); + Assert.Contains('/', standard); + Assert.EndsWith("==", standard); + + var o = VmessShareLink.Parse(LinkUrlSafeUnpadded(UrlSafeJson)); + + Assert.Equal("a.example.com", o.Host); + Assert.Equal(443, o.Port); + Assert.Equal("?~?~?~", o.Remark); + Assert.Equal("real.example.com", o.Sni); + } + + [Fact] + public void Parse_StandardAndUrlSafe_AgreeExactly() + { + var standard = VmessShareLink.Parse(Link(UrlSafeJson)); + var urlSafe = VmessShareLink.Parse(LinkUrlSafeUnpadded(UrlSafeJson)); + + Assert.Equal(standard.Id, urlSafe.Id); + Assert.Equal(standard.Host, urlSafe.Host); + Assert.Equal(standard.Port, urlSafe.Port); + Assert.Equal(standard.Remark, urlSafe.Remark); + Assert.Equal(standard.Security, urlSafe.Security); + } + + [Fact] + public void Parse_IgnoresWhitespaceInsideThePayload() + { + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(MinimalJson())); + string spaced = "vmess://" + encoded[..10] + "\r\n " + encoded[10..]; + + var o = VmessShareLink.Parse(spaced); + Assert.Equal(ProxyHost, o.Host); + } + + [Theory] + [InlineData("\"auto\"", VmessSecurityKind.Auto)] + [InlineData("\"AUTO\"", VmessSecurityKind.Auto)] + [InlineData("\"\"", VmessSecurityKind.Auto)] + [InlineData("\"aes-128-gcm\"", VmessSecurityKind.Aes128Gcm)] + [InlineData("\"AES-128-GCM\"", VmessSecurityKind.Aes128Gcm)] + [InlineData("\"chacha20-poly1305\"", VmessSecurityKind.ChaCha20Poly1305)] + public void Parse_ScyVariants(string scy, VmessSecurityKind expected) + { + var o = VmessShareLink.Parse(Link(MinimalJson(extra: $",\"scy\":{scy}"))); + Assert.Equal(expected, o.Security); + } + + [Fact] + public void Parse_MissingScy_DefaultsToAuto() + { + Assert.Equal(VmessSecurityKind.Auto, VmessShareLink.Parse(Link(MinimalJson())).Security); + } + + [Fact] + public void Parse_SecurityFieldIsAnAliasForScy() + { + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"security\":\"chacha20-poly1305\""))); + Assert.Equal(VmessSecurityKind.ChaCha20Poly1305, o.Security); + } + + [Theory] + [InlineData(",\"tls\":\"tls\"", true)] + [InlineData(",\"tls\":\"TLS\"", true)] + [InlineData(",\"tls\":\"\"", false)] + [InlineData(",\"tls\":\"none\"", false)] + [InlineData("", false)] + public void Parse_TlsFlag(string extra, bool expected) + { + Assert.Equal(expected, VmessShareLink.Parse(Link(MinimalJson(extra: extra))).UseTls); + } + + [Fact] + public void Parse_Sni_PrefersExplicitValue() + { + var o = VmessShareLink.Parse(Link(MinimalJson( + extra: ",\"sni\":\"sni.example.com\",\"host\":\"host.example.com\""))); + Assert.Equal("sni.example.com", o.Sni); + } + + [Fact] + public void Parse_Sni_FallsBackToHostField() + { + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"host\":\"host.example.com\""))); + Assert.Equal("host.example.com", o.Sni); + } + + [Fact] + public void Parse_Sni_FallsBackToAddress() + { + // Neither 'sni' nor 'host' present, and empty strings must not win either. + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"sni\":\"\",\"host\":\"\""))); + Assert.Equal(ProxyHost, o.Sni); + } + + [Fact] + public void Parse_Alpn_CommaSeparatedString() + { + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"alpn\":\"h2, http/1.1\""))); + Assert.Equal(["h2", "http/1.1"], o.Alpn); + } + + [Fact] + public void Parse_Alpn_JsonArray() + { + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"alpn\":[\"h2\",\"http/1.1\"]"))); + Assert.Equal(["h2", "http/1.1"], o.Alpn); + } + + [Fact] + public void Parse_Alpn_MissingOrEmpty_IsNull() + { + Assert.Null(VmessShareLink.Parse(Link(MinimalJson())).Alpn); + Assert.Null(VmessShareLink.Parse(Link(MinimalJson(extra: ",\"alpn\":\"\""))).Alpn); + } + + [Theory] + [InlineData(",\"allowInsecure\":true")] + [InlineData(",\"allowInsecure\":\"1\"")] + [InlineData(",\"allowInsecure\":1")] + [InlineData(",\"skip-cert-verify\":true")] + public void Parse_AllowInsecure_Truthy(string extra) + { + Assert.True(VmessShareLink.Parse(Link(MinimalJson(extra: extra))).AllowInsecure); + } + + [Theory] + [InlineData("")] + [InlineData(",\"allowInsecure\":false")] + [InlineData(",\"allowInsecure\":\"0\"")] + public void Parse_AllowInsecure_Falsy(string extra) + { + Assert.False(VmessShareLink.Parse(Link(MinimalJson(extra: extra))).AllowInsecure); + } + + [Fact] + public void Parse_IPv6Host_StripsBrackets() + { + var o = VmessShareLink.Parse(Link(MinimalJson(add: "[2001:db8::1]"))); + Assert.Equal("2001:db8::1", o.Host); + } + + [Theory] + [InlineData(",\"aid\":\"0\"")] + [InlineData(",\"aid\":0")] + [InlineData(",\"aid\":\"\"")] + [InlineData(",\"alterId\":0")] + [InlineData("")] + public void Parse_AlterIdZero_IsAccepted(string extra) + { + Assert.Equal(0, VmessShareLink.Parse(Link(MinimalJson(extra: extra))).AlterId); + } + + [Fact] + public void Parse_UnsupportedTransport_ParsesButResolvesToUnsupported() + { + // Parsed so callers can inspect it; rejected at connect time, not here. + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"net\":\"grpc\""))); + Assert.Equal("grpc", o.Transport); + Assert.Equal(TransportKind.Unsupported, o.TransportKind); + } + + [Theory] + [InlineData("tcp", nameof(TransportKind.RawTcp))] + [InlineData("raw", nameof(TransportKind.RawTcp))] + [InlineData("ws", nameof(TransportKind.WebSocket))] + [InlineData("websocket", nameof(TransportKind.WebSocket))] + [InlineData("httpupgrade", nameof(TransportKind.HttpUpgrade))] + public void Parse_SupportedTransports(string net, string expected) + { + // Compared by name: TransportKind is internal, and an internal parameter type cannot + // appear on the public signature xUnit needs to discover the theory. + var o = VmessShareLink.Parse(Link(MinimalJson(extra: $",\"net\":\"{net}\""))); + Assert.Equal(expected, o.TransportKind.ToString()); + } + + // ================================ share-link: rejects ================================ + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("vless://11223344-5566-7788-99aa-bbccddeeff00@example.com:443")] + [InlineData("trojan://pw@example.com:443")] + [InlineData("vmess://")] + [InlineData("vmess://!!!not-base64!!!")] + [InlineData("vmess://a")] + public void TryParse_RejectsMalformedLinks(string link) + { + Assert.False(VmessShareLink.TryParse(link, out _)); + } + + [Fact] + public void TryParse_RejectsInvalidJson() + { + Assert.False(VmessShareLink.TryParse(Link("{\"add\":\"a.com\","), out _)); + Assert.False(VmessShareLink.TryParse(Link("not json at all"), out _)); + } + + [Fact] + public void TryParse_RejectsNonObjectJson() + { + Assert.False(VmessShareLink.TryParse(Link("[1,2,3]"), out _)); + Assert.False(VmessShareLink.TryParse(Link("\"a string\""), out _)); + } + + [Fact] + public void TryParse_RejectsMissingOrInvalidId() + { + Assert.False(VmessShareLink.TryParse( + Link("""{"add":"a.example.com","port":"443"}"""), out _)); + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(id: "")), out _)); + // 31 chars: outside Xray's 1..30 derivation window and not a canonical UUID. + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(id: new string('x', 31))), out _)); + // 34 chars: inside the canonical window, so it is parsed as hex — and it is truncated. + Assert.False(VmessShareLink.TryParse( + Link(MinimalJson(id: "11223344-5566-7788-99aa-bbccddeeff")), out _)); + } + + // ===================== grammar 1b: '#remark' appended after the base64 ===================== + // + // 184 of the 423 real-world vmess links in the corpus put the remark after the base64 + // payload instead of in the JSON's "ps". '#' is in neither base64 alphabet, so the + // split is unambiguous. + + [Fact] + public void TryParse_FragmentAfterBase64_IsRemark_NotPayload() + { + Assert.True(VmessShareLink.TryParse(Link(MinimalJson()) + "#My%20Node", out var o)); + Assert.Equal(ProxyHost, o.Host); + Assert.Equal("My Node", o.Remark); + } + + [Fact] + public void TryParse_FragmentAfterBase64_DoesNotOverrideJsonPs() + { + string json = MinimalJson(extra: ",\"ps\":\"from json\""); + Assert.True(VmessShareLink.TryParse(Link(json) + "#from fragment", out var o)); + Assert.Equal("from json", o.Remark); + } + + [Fact] + public void TryParse_EmptyFragmentAfterBase64_IsIgnored() + { + Assert.True(VmessShareLink.TryParse(Link(MinimalJson()) + "#", out var o)); + Assert.Null(o.Remark); + } + + // ===================== grammar 2: the standard URI form ===================== + // + // vmess://{uuid}@{host}:{port}?{query}#{remark} — 48 links in the corpus. The query + // keys mean DIFFERENT things than the JSON fields: type=transport (JSON 'net'), + // headerType=obfuscation (JSON 'type'), encryption=body cipher (JSON 'scy'), + // security=transport security (JSON 'tls'). + + [Fact] + public void TryParse_StandardUri_MapsQueryKeysToTheRightFields() + { + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@cdn.example.com:8443" + + "?encryption=chacha20-poly1305&type=tcp&security=tls&sni=real.example.com" + + "&alpn=h2,http/1.1#my%20node", + out var o)); + + Assert.Equal(Uuid, o.Id); + Assert.Equal("cdn.example.com", o.Host); + Assert.Equal(8443, o.Port); + Assert.Equal(VmessSecurityKind.ChaCha20Poly1305, o.Security); // from 'encryption' + Assert.Equal("tcp", o.Transport); // from 'type' + Assert.True(o.UseTls); // from 'security' + Assert.Equal("real.example.com", o.Sni); + Assert.Equal(["h2", "http/1.1"], o.Alpn); + Assert.Equal("my node", o.Remark); + Assert.Equal(0, o.AlterId); + } + + // 'security' carries either meaning in the wild. These pin the disambiguation, which is + // by value rather than by guess — see the comment in TryParseStandardUri. + + [Theory] + [InlineData("auto", nameof(VmessSecurityKind.Auto))] + [InlineData("aes-128-gcm", nameof(VmessSecurityKind.Aes128Gcm))] + [InlineData("chacha20-poly1305", nameof(VmessSecurityKind.ChaCha20Poly1305))] + public void TryParse_StandardUri_SecurityHoldingABodyCipher_IsReadAsOne(string value, string expected) + { + // 611 corpus links (27% of all vmess) look exactly like this. Rejecting them as an + // unrecognized transport security made every one of them unusable. + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=tcp&security={value}", out var o)); + + Assert.Equal(expected, o.Security.ToString()); + Assert.False(o.UseTls); + } + + [Fact] + public void TryParse_StandardUri_SecurityTls_StillMeansTls() + { + // The disambiguation must not cost the documented reading. + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=tcp&security=tls", out var o)); + Assert.True(o.UseTls); + } + + [Fact] + public void TryParse_StandardUri_SecurityNone_StaysTransportSecurity() + { + // 'none' is valid in both vocabularies. It keeps its documented meaning, and both + // readings agree there is no TLS — so the ambiguity costs nothing here. + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=tcp&security=none", out var o)); + Assert.False(o.UseTls); + Assert.Equal(VmessSecurityKind.Auto, o.Security); + } + + [Fact] + public void TryParse_StandardUri_SecurityRealityIsStillRejected() + { + Assert.False(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=tcp&security=reality&pbk=x", out _)); + } + + [Fact] + public void TryParse_StandardUri_VlessStyleEncryptionNone_IsTreatedAsUnspecified() + { + // Producers copy VLESS's mandatory 'encryption=none' onto vmess links, where it does + // not mean VMess's unencrypted body mode. + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?encryption=none&type=ws&security=tls&path=/x", out var o)); + + Assert.Equal(VmessSecurityKind.Auto, o.Security); + Assert.True(o.UseTls); + } + + [Fact] + public void TryParse_StandardUri_UnknownTransportSecurity_IsStillRejected() + { + // Anything belonging to neither vocabulary must still fail rather than default to + // plaintext. + Assert.False(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=tcp&security=quic", out _)); + } + + [Fact] + public void TryParse_StandardUri_TypeIsTransport_NotObfuscation() + { + // 'type=ws' must set the transport, NOT be rejected as a header obfuscation. + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?encryption=auto&type=ws&path=/x", out var o)); + Assert.Equal("ws", o.Transport); + } + + [Fact] + public void TryParse_StandardUri_DefaultsToTcpAndAuto() + { + Assert.True(VmessShareLink.TryParse($"vmess://{Uuid}@a.example.com:443", out var o)); + Assert.Equal("tcp", o.Transport); + Assert.Equal(VmessSecurityKind.Auto, o.Security); + Assert.False(o.UseTls); + Assert.Equal("a.example.com", o.Sni); + } + + [Fact] + public void TryParse_StandardUri_HeaderTypeRejectedOnlyOnTcp() + { + Assert.False(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=tcp&headerType=http", out _)); + + // Meaningless on ws, so real clients ignore it — and so must we. + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?type=ws&headerType=http", out _)); + } + + [Fact] + public void TryParse_StandardUri_Reality_IsRejected() + { + Assert.False(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?security=reality&pbk=x", out _)); + } + + [Fact] + public void TryParse_StandardUri_UnknownTransportSecurity_NoSilentPlaintextDowngrade() + { + // A typo like security=tsl must NOT quietly become plaintext. + Assert.False(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?security=tsl", out _)); + } + + [Fact] + public void TryParse_StandardUri_IPv6Host_StripsBrackets() + { + Assert.True(VmessShareLink.TryParse($"vmess://{Uuid}@[2001:db8::1]:443", out var o)); + Assert.Equal("2001:db8::1", o.Host); + } + + [Fact] + public void TryParse_StandardUri_AllowInsecureAliases() + { + Assert.True(VmessShareLink.TryParse( + $"vmess://{Uuid}@a.example.com:443?security=tls&allowInsecure=1", out var o)); + Assert.True(o.AllowInsecure); + } + + // ===================== 'type' on the JSON grammar ===================== + + [Fact] + public void TryParse_Json_HeaderTypeRejectedOnlyOnTcp() + { + Assert.False(VmessShareLink.TryParse( + Link(MinimalJson(extra: ",\"net\":\"tcp\",\"type\":\"http\"")), out _)); + + // 16 corpus links carry junk in 'type' while running over ws, where the field has + // no meaning at all. + Assert.True(VmessShareLink.TryParse( + Link(MinimalJson(extra: ",\"net\":\"ws\",\"type\":\"---\"")), out var o)); + Assert.Equal("ws", o.Transport); + } + + [Fact] + public void TryParse_ShortNonUuidId_IsAccepted_AndKeptVerbatim() + { + // Xray maps a 1..30 character id to UUIDv5(nil, id) rather than rejecting it. + // The options keep the id as written; the derivation happens at the wire encoder. + Assert.True(VmessShareLink.TryParse(Link(MinimalJson(id: "not-a-uuid")), out var o)); + Assert.Equal("not-a-uuid", o.Id); + } + + [Theory] + [InlineData("")] // missing entirely + [InlineData("\"\"")] // empty string + [InlineData("\"abc\"")] // not a number + [InlineData("0")] // out of range + [InlineData("-1")] + [InlineData("65536")] + [InlineData("null")] + public void TryParse_RejectsMissingOrInvalidPort(string port) + { + string json = port.Length == 0 + ? $$"""{"add":"{{ProxyHost}}","id":"{{Uuid}}"}""" + : MinimalJson(port: port); + + Assert.False(VmessShareLink.TryParse(Link(json), out _)); + } + + [Fact] + public void TryParse_RejectsMissingAddress() + { + Assert.False(VmessShareLink.TryParse( + Link($$"""{"port":"443","id":"{{Uuid}}"}"""), out _)); + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(add: "")), out _)); + } + + [Theory] + [InlineData(",\"aid\":\"1\"")] + [InlineData(",\"aid\":1")] + [InlineData(",\"aid\":64")] + [InlineData(",\"alterId\":16")] + public void TryParse_RejectsNonZeroAlterId(string extra) + { + // AEAD-only: a non-zero alterId means legacy MD5 auth, which is NOT implemented. + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(extra: extra)), out _)); + + var ex = Assert.Throws(() => + VmessShareLink.Parse(Link(MinimalJson(extra: extra)))); + Assert.Contains("alterId", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryParse_RejectsUnparseableAlterId() + { + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(extra: ",\"aid\":\"abc\"")), out _)); + } + + [Theory] + [InlineData("none")] + [InlineData("zero")] + [InlineData("aes-128-cfb")] + [InlineData("bogus")] + public void TryParse_RejectsUnknownScy(string scy) + { + // Silently defaulting would change how the body is protected without telling anyone. + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(extra: $",\"scy\":\"{scy}\"")), out _)); + } + + [Fact] + public void TryParse_RejectsRealityAndHeaderObfuscation() + { + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(extra: ",\"tls\":\"reality\"")), out _)); + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(extra: ",\"type\":\"http\"")), out _)); + + // "none" and an absent value are the supported header types. + Assert.True(VmessShareLink.TryParse(Link(MinimalJson(extra: ",\"type\":\"none\"")), out _)); + } + + [Fact] + public void Parse_ThrowsFormatExceptionWithAReason() + { + var ex = Assert.Throws(() => VmessShareLink.Parse("vmess://%%%")); + Assert.False(string.IsNullOrWhiteSpace(ex.Message)); + } + + // ================================ options ================================ + + [Fact] + public void ResolveSecurity_MapsExplicitKinds() + { + Assert.Equal(VmessSecurity.Aes128Gcm, Options(VmessSecurityKind.Aes128Gcm).ResolveSecurity()); + Assert.Equal(VmessSecurity.ChaCha20Poly1305, + Options(VmessSecurityKind.ChaCha20Poly1305).ResolveSecurity()); + } + + [Fact] + public void ResolveSecurity_AutoPicksAnAvailableAeadCipher() + { + VmessSecurity resolved = Options(VmessSecurityKind.Auto).ResolveSecurity(); + + Assert.True(resolved is VmessSecurity.Aes128Gcm or VmessSecurity.ChaCha20Poly1305); + Assert.True(resolved == VmessSecurity.Aes128Gcm + ? AesGcm.IsSupported + : ChaCha20Poly1305.IsSupported); + + // Auto must never reach the wire as security type 2. + Assert.NotEqual(2, (byte)resolved); + } + + private static VmessOptions Options( + VmessSecurityKind security = VmessSecurityKind.Aes128Gcm, + string transport = "tcp", + int alterId = 0, + string id = Uuid) + => new() + { + Id = id, + Host = ProxyHost, + Port = ProxyPort, + Security = security, + AlterId = alterId, + Transport = transport + }; + + // ================================ client construction ================================ + + [Fact] + public void Client_NullOptions_ThrowsArgumentNull() + { + Assert.Throws(() => new VmessClient(null!)); + } + + [Theory] + [InlineData("")] + [InlineData("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] // 31: past the derivation window + [InlineData("11223344-5566-7788-99aa-bbccddeeff")] // 34: canonical window, truncated + public void Client_UnusableUuid_ThrowsAtConstruction(string id) + { + Assert.Throws(() => new VmessClient(Options(id: id))); + } + + [Theory] + [InlineData(1)] + [InlineData(64)] + public void Client_NonZeroAlterId_ThrowsAtConstruction(int alterId) + { + var ex = Assert.Throws(() => new VmessClient(Options(alterId: alterId))); + Assert.Contains("alterId", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Client_ExposesTypeAndOptions() + { + VmessOptions options = Options(); + var client = new VmessClient(options); + + Assert.Equal(ProxyType.Vmess, client.Type); + Assert.Equal(ProxyHost, client.ProxyHost); + Assert.Equal(ProxyPort, client.ProxyPort); + Assert.Same(options, client.Options); + Assert.Equal("vmess", client.ProxyUri.Scheme); + } + + [Fact] + public void Client_IPv6Host_ConstructsWithoutThrowing() + { + var client = VmessClient.FromShareLink(Link(MinimalJson(add: "[2001:db8::1]"))); + Assert.Equal("2001:db8::1", client.ProxyHost); + } + + [Fact] + public void FromShareLink_CreatesClient() + { + var client = VmessClient.FromShareLink(Link(FullJson)); + Assert.Equal("cdn.example.com", client.Options.Host); + Assert.Equal(8443, client.Options.Port); + Assert.True(client.Options.UseTls); + } + + /// + /// Builds a link that can actually parse. A VMess payload is + /// base64 JSON, not a host, so rejects anything containing '=' + /// padding or longer than its host-length limit — which is most real links. Trailing + /// whitespace is legal JSON, so pad until the encoding is alphanumeric-only. + /// + private static string UriLink(string json) + { + for (int pad = 0; pad < 3; pad++) + { + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(json + new string(' ', pad))); + if (encoded.AsSpan().IndexOfAny('+', '/', '=') < 0) + return "vmess://" + encoded; + } + + throw new InvalidOperationException("No Uri-safe encoding of the payload was found."); + } + + [Fact] + 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 vmess = Assert.IsType(client); + Assert.Equal(ProxyType.Vmess, vmess.Type); + Assert.Equal("cdn.example.com", vmess.Options.Host); + Assert.Equal(8443, vmess.Options.Port); + Assert.Equal(VmessSecurityKind.Aes128Gcm, vmess.Options.Security); + Assert.True(vmess.Options.UseTls); + } + + [Fact] + public void Factory_InvalidVmessUri_Throws() + { + var uri = new Uri(UriLink(MinimalJson(extra: ",\"aid\":\"1\""))); + Assert.Throws(() => ProxyClientFactory.Instance.Create(uri)); + } + + [Fact] + public void Factory_LongVmessLink_CannotBeExpressedAsAUri() + { + // Documents the limitation the factory's XML docs call out: a full v2rayN payload + // exceeds the Uri host-length limit, so callers must use FromShareLink instead. + Assert.False(Uri.TryCreate(Link(FullJson), UriKind.Absolute, out _)); + Assert.Equal("cdn.example.com", VmessClient.FromShareLink(Link(FullJson)).Options.Host); + } + + [Theory] + [InlineData("grpc")] + [InlineData("h2")] + [InlineData("xhttp")] + public async Task Client_UnsupportedTransport_ThrowsNotSupported_BeforeWritingAnything(string net) + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: $",\"net\":\"{net}\""))); + + await Assert.ThrowsAsync( + () => client.ConnectAsync(transport, "target.example.com", 80, CancellationToken.None).AsTask()); + + // EnsureSupported must run before any byte hits the wire and before any read. + Assert.Empty(transport.Written); + Assert.Equal(0, transport.ReadCount); + Assert.Equal(0, transport.DisposeCount); + } + + [Fact] + public async Task Client_NullStream_ThrowsArgumentNull() + { + var client = new VmessClient(Options()); + await Assert.ThrowsAsync( + () => client.ConnectAsync(null!, "target.example.com", 80, CancellationToken.None).AsTask()); + } + + // ================================ end-to-end handshake ================================ + + [Fact] + public async Task Handshake_WritesOnlyTheSealedRequestHeader_AndReadsNothing() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"aes-128-gcm\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + // Nothing was read: the response header is deferred to the first read, because a + // real server does not flush it until the target produces data. + Assert.Equal(0, transport.ReadCount); + + ParsedRequest request = OpenRequest(transport.Written, Uuid); + + // Exactly one header, 58 + L bytes, and no body chunk. + Assert.Equal(58 + request.CommandSectionLength, transport.Written.Length); + + Assert.Equal(0x01, request.Version); + Assert.Equal(VmessRequest.CommandTcp, request.Command); + Assert.Equal(0x00, request.Reserved); + Assert.Equal("mc.example.com", request.Host); + Assert.Equal(25565, request.Port); + Assert.Equal(0x02, request.AddressType); // domain + Assert.InRange(request.PaddingLength, 0, 15); + + await body.DisposeAsync(); + } + + [Fact] + public async Task Handshake_SendsBaselineOptionByte_NotTheMaskedPaddedProfile() + { + // THE critical interop assertion: VmessStream implements only the baseline framing + // (plain uint16 lengths, no padding, no authenticated length). Announcing v2ray's + // usual 0x1D (S|M|P|A) would make the server mask every chunk length with SHAKE128 + // and append padding, desynchronizing the reader on the very first chunk. + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson())); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + ParsedRequest request = OpenRequest(transport.Written, Uuid); + + Assert.Equal(0x01, request.Option); + Assert.Equal(VmessRequest.OptionChunkStream, request.Option); + Assert.NotEqual(VmessRequest.DefaultOption, request.Option); + + // Every framing-changing flag must be clear. + Assert.Equal(0, request.Option & VmessRequest.OptionChunkMasking); + Assert.Equal(0, request.Option & VmessRequest.OptionGlobalPadding); + Assert.Equal(0, request.Option & VmessRequest.OptionAuthenticatedLength); + + await body.DisposeAsync(); + } + + [Theory] + [InlineData("aes-128-gcm", VmessRequest.SecurityAes128Gcm)] + [InlineData("chacha20-poly1305", VmessRequest.SecurityChaCha20Poly1305)] + public async Task Handshake_WritesTheRequestedSecurityNibble(string scy, byte expected) + { + if (expected == VmessRequest.SecurityChaCha20Poly1305 && !ChaCha20Poly1305.IsSupported) + return; // gated exactly like the implementation + + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: $",\"scy\":\"{scy}\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + Assert.Equal(expected, OpenRequest(transport.Written, Uuid).Security); + await body.DisposeAsync(); + } + + [Fact] + public async Task Handshake_AutoResolvesToAConcreteCipherOnTheWire() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"auto\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + byte security = OpenRequest(transport.Written, Uuid).Security; + + // Never 2 (AUTO) or 0 (UNKNOWN): the client resolves before serializing. + Assert.True(security is VmessRequest.SecurityAes128Gcm or VmessRequest.SecurityChaCha20Poly1305); + Assert.Equal((byte)client.Options.ResolveSecurity(), security); + + await body.DisposeAsync(); + } + + [Fact] + public async Task Handshake_TargetIPv4_UsesAtyp01() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson())); + + Stream body = await client.ConnectAsync(transport, "192.0.2.10", 443, CancellationToken.None); + ParsedRequest request = OpenRequest(transport.Written, Uuid); + + Assert.Equal(0x01, request.AddressType); + Assert.Equal("192.0.2.10", request.Host); + Assert.Equal(443, request.Port); + + await body.DisposeAsync(); + } + + [Fact] + public async Task Handshake_TargetIPv6_UsesAtyp03() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson())); + + Stream body = await client.ConnectAsync(transport, "2001:db8::1", 8080, CancellationToken.None); + ParsedRequest request = OpenRequest(transport.Written, Uuid); + + Assert.Equal(0x03, request.AddressType); + Assert.Equal("2001:db8::1", request.Host); + + await body.DisposeAsync(); + } + + [Fact] + public async Task Handshake_RequestHeaderIsFreshPerConnection() + { + var client = VmessClient.FromShareLink(Link(MinimalJson())); + + var first = new ScriptedDuplexStream(); + var second = new ScriptedDuplexStream(); + Stream a = await client.ConnectAsync(first, "mc.example.com", 25565, CancellationToken.None); + Stream b = await client.ConnectAsync(second, "mc.example.com", 25565, CancellationToken.None); + + // Different AuthID, connection nonce and body keys every time. + Assert.NotEqual(Convert.ToHexStringLower(first.Written), Convert.ToHexStringLower(second.Written)); + + ParsedRequest one = OpenRequest(first.Written, Uuid); + ParsedRequest two = OpenRequest(second.Written, Uuid); + Assert.NotEqual(one.BodyKey, two.BodyKey); + Assert.NotEqual(one.BodyIv, two.BodyIv); + + await a.DisposeAsync(); + await b.DisposeAsync(); + } + + [Theory] + [InlineData("aes-128-gcm")] + [InlineData("chacha20-poly1305")] + public async Task Handshake_FullRoundTrip_ReadsResponseHeaderThenBody(string scy) + { + if (scy == "chacha20-poly1305" && !ChaCha20Poly1305.IsSupported) + return; + + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: $",\"scy\":\"{scy}\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + // --- act as the server: recover the session from the sealed request header --- + ParsedRequest request = OpenRequest(transport.Written, Uuid); + var session = new ServerSession(request); + + transport.Enqueue(session.SealResponseHeader(request.RespV, option: 0, command: 0, commandData: [])); + transport.Enqueue(session.SealResponseChunk(0, "hello"u8.ToArray())); + transport.Enqueue(session.SealResponseChunk(1, "world"u8.ToArray())); + transport.Enqueue(session.SealResponseChunk(2, [])); // in-band EOF + + // --- read the payload back through the client's stream --- + var received = new List(); + byte[] buffer = new byte[64]; + int read; + while ((read = await body.ReadAsync(buffer)) > 0) + received.AddRange(buffer[..read]); + + Assert.Equal("helloworld"u8.ToArray(), received); + Assert.Equal(0, transport.Unread); + + // --- and check the client's own writes are chunks the server could open --- + transport.ClearWritten(); + await body.WriteAsync("ping"u8.ToArray()); + Assert.Equal("ping"u8.ToArray(), session.OpenRequestChunk(0, transport.Written)); + + await body.DisposeAsync(); + } + + [Fact] + public async Task Handshake_ResponseVerifierMismatch_FailsOnTheFirstRead() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"aes-128-gcm\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + ParsedRequest request = OpenRequest(transport.Written, Uuid); + var session = new ServerSession(request); + + // Echo the WRONG verifier byte. + transport.Enqueue(session.SealResponseHeader((byte)(request.RespV ^ 0xFF), 0, 0, [])); + transport.Enqueue(session.SealResponseChunk(0, "hello"u8.ToArray())); + + var ex = await Assert.ThrowsAsync( + async () => await body.ReadAsync(new byte[64])); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + [Fact] + public async Task Handshake_TamperedResponseHeader_FailsAuthentication() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"aes-128-gcm\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + ParsedRequest request = OpenRequest(transport.Written, Uuid); + var session = new ServerSession(request); + + byte[] header = session.SealResponseHeader(request.RespV, 0, 0, []); + header[^1] ^= 0xFF; + transport.Enqueue(header); + + var ex = await Assert.ThrowsAsync( + async () => await body.ReadAsync(new byte[64])); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); + } + + [Fact] + public async Task Handshake_ResponseHeaderIsReadExactlyOnce() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"aes-128-gcm\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + ParsedRequest request = OpenRequest(transport.Written, Uuid); + var session = new ServerSession(request); + + transport.Enqueue(session.SealResponseHeader(request.RespV, 0, 0, [])); + transport.Enqueue(session.SealResponseChunk(0, "a"u8.ToArray())); + transport.Enqueue(session.SealResponseChunk(1, "b"u8.ToArray())); + transport.Enqueue(session.SealResponseChunk(2, [])); + + byte[] buffer = new byte[16]; + Assert.Equal(1, await body.ReadAsync(buffer)); + Assert.Equal((byte)'a', buffer[0]); + + // The second read must go straight to the body: if the header were re-read the + // chunk bytes would be consumed as a header and fail. + Assert.Equal(1, await body.ReadAsync(buffer)); + Assert.Equal((byte)'b', buffer[0]); + Assert.Equal(0, await body.ReadAsync(buffer)); + } + + [Fact] + public async Task Handshake_ServerCommandData_IsParsedAndSkipped() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"aes-128-gcm\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + + ParsedRequest request = OpenRequest(transport.Written, Uuid); + var session = new ServerSession(request); + + // A dynamic-port style directive: a minimal client parses and ignores it. + transport.Enqueue(session.SealResponseHeader(request.RespV, option: 0x11, command: 0x01, + commandData: [0xAA, 0xBB, 0xCC])); + transport.Enqueue(session.SealResponseChunk(0, "hello"u8.ToArray())); + transport.Enqueue(session.SealResponseChunk(1, [])); + + byte[] buffer = new byte[64]; + int read = await body.ReadAsync(buffer); + + Assert.Equal("hello"u8.ToArray(), buffer[..read]); + Assert.Equal(0, await body.ReadAsync(buffer)); + } + + [Fact] + public async Task Handshake_DisposingTheBodyStream_DisposesTheTransport() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson(extra: ",\"scy\":\"aes-128-gcm\""))); + + Stream body = await client.ConnectAsync(transport, "mc.example.com", 25565, CancellationToken.None); + await body.DisposeAsync(); + + Assert.Equal(1, transport.DisposeCount); + + // Disposal also emits the authenticated empty chunk that closes the write half. + ParsedRequest request = OpenRequest(transport.Written, Uuid); + byte[] terminator = transport.Written[(58 + request.CommandSectionLength)..]; + Assert.Equal(18, terminator.Length); + Assert.Equal(16, BinaryPrimitives.ReadUInt16BigEndian(terminator)); + } + + [Fact] + public async Task Handshake_HonorsCancellation() + { + var transport = new ScriptedDuplexStream(); + var client = VmessClient.FromShareLink(Link(MinimalJson())); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync( + () => client.ConnectAsync(transport, "mc.example.com", 25565, cts.Token).AsTask()); + + // The transport is disposed on the failed handshake rather than leaked. + Assert.Equal(1, transport.DisposeCount); + } + + // ================================ server-side reference ================================ + + /// + /// The fields a VMess server recovers from a sealed request header. + /// + private sealed record ParsedRequest( + byte Version, + byte[] BodyIv, + byte[] BodyKey, + byte RespV, + byte Option, + int PaddingLength, + byte Security, + byte Reserved, + byte Command, + int Port, + byte AddressType, + string Host, + int CommandSectionLength); + + /// + /// Opens a sealed VMessAEAD request header exactly as proxy/vmess/aead/encrypt.go + /// would, using only the shared UUID, and verifies the inner FNV-1a checksum. + /// + private static ParsedRequest OpenRequest(byte[] wire, string uuid) + { + Assert.True(wire.Length >= VmessRequest.SealOverhead, "request header is truncated"); + + byte[] cmdKey = new byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(uuid, cmdKey); + + byte[] authId = wire[..16]; + byte[] connectionNonce = wire[34..42]; + + // --- length AEAD (AAD = authid) --- + byte[] lengthKey = new byte[16]; + byte[] lengthNonce = new byte[12]; + VmessKdf.Kdf16(cmdKey, "VMess Header AEAD Key_Length"u8, authId, connectionNonce, lengthKey); + VmessKdf.Kdf12(cmdKey, "VMess Header AEAD Nonce_Length"u8, authId, connectionNonce, lengthNonce); + + byte[] lengthPlaintext = new byte[2]; + using (var gcm = new AesGcm(lengthKey, 16)) + gcm.Decrypt(lengthNonce, wire.AsSpan(16, 2), wire.AsSpan(18, 16), lengthPlaintext, authId); + + int length = BinaryPrimitives.ReadUInt16BigEndian(lengthPlaintext); + + // --- payload AEAD (AAD = authid) --- + byte[] payloadKey = new byte[16]; + byte[] payloadNonce = new byte[12]; + VmessKdf.Kdf16(cmdKey, "VMess Header AEAD Key"u8, authId, connectionNonce, payloadKey); + VmessKdf.Kdf12(cmdKey, "VMess Header AEAD Nonce"u8, authId, connectionNonce, payloadNonce); + + byte[] data = new byte[length]; + using (var gcm = new AesGcm(payloadKey, 16)) + gcm.Decrypt(payloadNonce, wire.AsSpan(42, length), wire.AsSpan(42 + length, 16), data, authId); + + // --- command section --- + int paddingLength = data[35] >> 4; + int port = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(38, 2)); + byte addressType = data[40]; + + int offset = 41; + string host; + switch (addressType) + { + case 0x01: + host = new IPAddress(data.AsSpan(offset, 4)).ToString(); + offset += 4; + break; + case 0x02: + int domainLength = data[offset++]; + host = Encoding.UTF8.GetString(data, offset, domainLength); + offset += domainLength; + break; + case 0x03: + host = new IPAddress(data.AsSpan(offset, 16)).ToString(); + offset += 16; + break; + default: + throw new InvalidOperationException($"Unexpected address type 0x{addressType:X2}."); + } + + offset += paddingLength; + + // The inner FNV-1a-32 covers everything up to itself, padding included. + Assert.Equal(offset + 4, length); + Assert.Equal( + Fnv1a32.Compute(data.AsSpan(0, offset)), + BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(offset, 4))); + + return new ParsedRequest( + Version: data[0], + BodyIv: data[1..17], + BodyKey: data[17..33], + RespV: data[33], + Option: data[34], + PaddingLength: paddingLength, + Security: (byte)(data[35] & 0x0F), + Reserved: data[36], + Command: data[37], + Port: port, + AddressType: addressType, + Host: host, + CommandSectionLength: length); + } + + /// + /// The server half of a VMess session: derives the response keys from the recovered + /// request keys and seals response headers and body chunks the client must accept. + /// + private sealed class ServerSession + { + private readonly byte[] _requestBodyKey; + private readonly byte[] _requestBodyIv; + private readonly byte[] _responseBodyKey = new byte[16]; + private readonly byte[] _responseBodyIv = new byte[16]; + private readonly VmessSecurity _security; + + public ServerSession(ParsedRequest request) + { + _requestBodyKey = request.BodyKey; + _requestBodyIv = request.BodyIv; + _security = (VmessSecurity)request.Security; + + // responseBodyKey/IV = SHA256(requestBodyKey/IV)[0:16]. + VmessResponse.DeriveBodyKeys( + _requestBodyKey, _requestBodyIv, _responseBodyKey, _responseBodyIv); + } + + public byte[] SealResponseHeader(byte respV, byte option, byte command, byte[] commandData) + { + byte[] lengthKey = new byte[16]; + byte[] lengthIv = new byte[12]; + byte[] headerKey = new byte[16]; + byte[] headerIv = new byte[12]; + VmessResponse.DeriveHeaderKeys( + _responseBodyKey, _responseBodyIv, lengthKey, lengthIv, headerKey, headerIv); + + byte[] plaintext = new byte[4 + commandData.Length]; + plaintext[0] = respV; + plaintext[1] = option; + plaintext[2] = command; + plaintext[3] = (byte)commandData.Length; + commandData.CopyTo(plaintext, 4); + + byte[] wire = new byte[18 + plaintext.Length + 16]; + + byte[] lengthPlaintext = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(lengthPlaintext, (ushort)plaintext.Length); + using (var gcm = new AesGcm(lengthKey, 16)) + gcm.Encrypt(lengthIv, lengthPlaintext, wire.AsSpan(0, 2), wire.AsSpan(2, 16)); + + using (var gcm = new AesGcm(headerKey, 16)) + gcm.Encrypt(headerIv, plaintext, + wire.AsSpan(18, plaintext.Length), wire.AsSpan(18 + plaintext.Length, 16)); + + return wire; + } + + /// Seals a server→client body chunk: uint16BE(sealedLen) ‖ sealed. + public byte[] SealResponseChunk(ushort counter, byte[] plaintext) + { + byte[] wire = new byte[2 + plaintext.Length + 16]; + BinaryPrimitives.WriteUInt16BigEndian(wire, (ushort)(plaintext.Length + 16)); + + Transform(_responseBodyKey, _responseBodyIv, counter, plaintext, + wire.AsSpan(2, plaintext.Length), wire.AsSpan(2 + plaintext.Length, 16), encrypt: true); + + return wire; + } + + /// Opens a client→server body chunk and returns its plaintext. + public byte[] OpenRequestChunk(ushort counter, byte[] wire) + { + int sealedLength = BinaryPrimitives.ReadUInt16BigEndian(wire); + Assert.Equal(wire.Length, 2 + sealedLength); + + int plaintextLength = sealedLength - 16; + byte[] plaintext = new byte[plaintextLength]; + + Transform(_requestBodyKey, _requestBodyIv, counter, wire.AsSpan(2, plaintextLength).ToArray(), + plaintext, wire.AsSpan(2 + plaintextLength, 16), encrypt: false); + + return plaintext; + } + + // nonce = uint16BE(counter) ‖ bodyIV[2:12]; AAD is empty. + private void Transform( + byte[] key, byte[] iv, ushort counter, byte[] input, + Span output, Span tag, bool encrypt) + { + byte[] nonce = new byte[12]; + BinaryPrimitives.WriteUInt16BigEndian(nonce, counter); + iv.AsSpan(2, 10).CopyTo(nonce.AsSpan(2)); + + if (_security == VmessSecurity.Aes128Gcm) + { + using var gcm = new AesGcm(key, 16); + if (encrypt) + gcm.Encrypt(nonce, input, output, tag); + else + gcm.Decrypt(nonce, input, tag, output); + return; + } + + byte[] expanded = new byte[32]; + VmessBodyKeys.ExpandChaCha20Key(key, expanded); + using var chacha = new ChaCha20Poly1305(expanded); + if (encrypt) + chacha.Encrypt(nonce, input, output, tag); + else + chacha.Decrypt(nonce, input, tag, output); + } + } +} diff --git a/QuickProxyNet.Tests/VmessCryptoTest.cs b/QuickProxyNet.Tests/VmessCryptoTest.cs new file mode 100644 index 0000000..98d29e2 --- /dev/null +++ b/QuickProxyNet.Tests/VmessCryptoTest.cs @@ -0,0 +1,252 @@ +namespace QuickProxyNet.Tests; + +/// +/// Tests for the VMessAEAD crypto primitives (, +/// , , ). +/// +/// The KDF / expansion vectors below are GROUND TRUTH produced by an independent +/// Python reimplementation (manual ipad/opad nested HMAC, standalone FNV/CRC/MD5/SHA) +/// over fixed synthetic inputs — NO real UUIDs/IPs. The standalone primitives are also +/// anchored against publicly known vectors (CRC32("123456789")=0xCBF43926, +/// FNV-1a-32("")=0x811C9DC5, etc.), which the Python must reproduce before its KDF +/// output is trusted. +/// +public class VmessCryptoTest +{ + // ---- synthetic inputs (must match scratchpad/vmess_truth.py) ---- + private static readonly byte[] CmdKey = + [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f]; // 00..0F + + private static readonly byte[] AuthId = + [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f]; // 10..1F + + private static readonly byte[] Nonce = + [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]; // 8-byte connection nonce + + private static byte[] Hex(string h) => Convert.FromHexString(h); + + // ========================= VmessKdf: 3-path (request header) ========================= + + [Fact] + public void Kdf16_RequestLengthKey_MatchesGroundTruth() + { + Span dst = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey, "VMess Header AEAD Key_Length"u8, AuthId, Nonce, dst); + Assert.Equal(Hex("47c5ee168f14ba38aebc458844e45fa3"), dst.ToArray()); + } + + [Fact] + public void Kdf12_RequestLengthNonce_MatchesGroundTruth() + { + Span dst = stackalloc byte[12]; + VmessKdf.Kdf12(CmdKey, "VMess Header AEAD Nonce_Length"u8, AuthId, Nonce, dst); + Assert.Equal(Hex("1568461eed64408cce969ac2"), dst.ToArray()); + } + + [Fact] + public void Kdf16_RequestPayloadKey_MatchesGroundTruth() + { + Span dst = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey, "VMess Header AEAD Key"u8, AuthId, Nonce, dst); + Assert.Equal(Hex("e008b551916d71746eb05acd8cfc6e9b"), dst.ToArray()); + } + + [Fact] + public void Kdf12_RequestPayloadNonce_MatchesGroundTruth() + { + Span dst = stackalloc byte[12]; + VmessKdf.Kdf12(CmdKey, "VMess Header AEAD Nonce"u8, AuthId, Nonce, dst); + Assert.Equal(Hex("a8b2aa472f51ef4939775062"), dst.ToArray()); + } + + // ========================= VmessKdf: 1-path (response header) ========================= + + [Fact] + public void Kdf16_ResponseLengthKey_MatchesGroundTruth() + { + Span dst = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey, "AEAD Resp Header Len Key"u8, dst); + Assert.Equal(Hex("1dbcdc6d886515862212014a20172f03"), dst.ToArray()); + } + + [Fact] + public void Kdf12_ResponseLengthIv_MatchesGroundTruth() + { + Span dst = stackalloc byte[12]; + VmessKdf.Kdf12(CmdKey, "AEAD Resp Header Len IV"u8, dst); + Assert.Equal(Hex("c10dd09b55bbeca420a83f58"), dst.ToArray()); + } + + [Fact] + public void Kdf16_ResponsePayloadKey_MatchesGroundTruth() + { + Span dst = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey, "AEAD Resp Header Key"u8, dst); + Assert.Equal(Hex("e8c31fe70c8376b328379ee4bacfa47b"), dst.ToArray()); + } + + [Fact] + public void Kdf12_ResponsePayloadIv_MatchesGroundTruth() + { + Span dst = stackalloc byte[12]; + VmessKdf.Kdf12(CmdKey, "AEAD Resp Header IV"u8, dst); + Assert.Equal(Hex("4c594d26e9cb8a2feeee9cca"), dst.ToArray()); + } + + // ========================= VmessKdf: auth-id AES key (1-path) ========================= + + [Fact] + public void Kdf16_AuthIdEncryptionKey_MatchesGroundTruth() + { + Span dst = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey, "AES Auth ID Encryption"u8, dst); + Assert.Equal(Hex("9fa4289c41650861a45b34aeab3879fe"), dst.ToArray()); + } + + // ========================= VmessKdf: truncation & validation ========================= + + [Fact] + public void Kdf16_And_Kdf12_ArePrefixesOfTheSame32ByteOutput() + { + // Kdf16/Kdf12 truncate the same 32-byte KDF output, so the 12-byte prefix must + // equal the first 12 bytes of the 16-byte result (Key_Length full = ...5fa3eecd...). + Span full16 = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey, "VMess Header AEAD Key_Length"u8, AuthId, Nonce, full16); + Assert.Equal( + Hex("47c5ee168f14ba38aebc458844e45fa3"), + full16.ToArray()); + } + + [Fact] + public void Kdf16_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[15]; + VmessKdf.Kdf16(CmdKey, "AEAD Resp Header Key"u8, small); + }); + } + + [Fact] + public void Kdf12_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[11]; + VmessKdf.Kdf12(CmdKey, "AEAD Resp Header IV"u8, AuthId, Nonce, small); + }); + } + + // ========================= FNV-1a-32 ========================= + + [Fact] + public void Fnv1a32_KnownVectors() + { + Assert.Equal(0x811c9dc5u, Fnv1a32.Compute(ReadOnlySpan.Empty)); + Assert.Equal(0xe40c292cu, Fnv1a32.Compute("a"u8)); + } + + [Fact] + public void Fnv1a32_SyntheticSample_MatchesGroundTruth() + { + // 00..13 (20 bytes) + Span sample = stackalloc byte[20]; + for (int i = 0; i < sample.Length; i++) + sample[i] = (byte)i; + + Assert.Equal(0x783b3501u, Fnv1a32.Compute(sample)); + Assert.Equal(0x4f9f2cabu, Fnv1a32.Compute("hello"u8)); + } + + [Fact] + public void Fnv1a32_WriteBigEndian_IsNetworkOrder() + { + Span dst = stackalloc byte[4]; + Fnv1a32.WriteBigEndian("hello"u8, dst); + Assert.Equal(Hex("4f9f2cab"), dst.ToArray()); + } + + // ========================= CRC-32/IEEE ========================= + + [Fact] + public void Crc32_KnownVectors() + { + Assert.Equal(0xCBF43926u, Crc32.Compute("123456789"u8)); + Assert.Equal(0x00000000u, Crc32.Compute(ReadOnlySpan.Empty)); + } + + [Fact] + public void Crc32_SyntheticSample_MatchesGroundTruth() + { + // 00..0B (12 bytes) — an AuthID plaintext prefix (timestamp ‖ random). + Span sample = stackalloc byte[12]; + for (int i = 0; i < sample.Length; i++) + sample[i] = (byte)i; + + Assert.Equal(0x9270c965u, Crc32.Compute(sample)); + } + + [Fact] + public void Crc32_WriteBigEndian_IsNetworkOrder() + { + Span dst = stackalloc byte[4]; + Crc32.WriteBigEndian("123456789"u8, dst); + Assert.Equal(Hex("cbf43926"), dst.ToArray()); + } + + // ========================= VmessBodyKeys: ChaCha20 expansion ========================= + + [Fact] + public void ExpandChaCha20Key_MatchesGroundTruth() + { + // bodyKey = 00..0F + Span key = stackalloc byte[32]; + VmessBodyKeys.ExpandChaCha20Key(CmdKey, key); + Assert.Equal( + Hex("1ac1ef01e96caf1be0d329331a4fc2a8e0542db5418c43d256a6a643afa553fe"), + key.ToArray()); + } + + [Fact] + public void ExpandChaCha20Key_HalvesAreMd5Chained() + { + // Sanity: key[16:32] = MD5(key[0:16]); MD5("") anchor is unrelated but confirms + // the BCL MD5 is the standard one via the well-known empty-string digest. + Assert.Equal( + "d41d8cd98f00b204e9800998ecf8427e", + Convert.ToHexStringLower(System.Security.Cryptography.MD5.HashData(ReadOnlySpan.Empty))); + } + + [Fact] + public void ExpandChaCha20Key_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[31]; + VmessBodyKeys.ExpandChaCha20Key(CmdKey, small); + }); + } + + // ========================= VmessBodyKeys: response key/IV ========================= + + [Fact] + public void DeriveResponseKeyOrIv_MatchesGroundTruth() + { + // source = requestBodyKey = 00..0F -> SHA256(x)[0:16] + Span dst = stackalloc byte[16]; + VmessBodyKeys.DeriveResponseKeyOrIv(CmdKey, dst); + Assert.Equal(Hex("be45cb2605bf36bebde684841a28f0fd"), dst.ToArray()); + } + + [Fact] + public void DeriveResponseKeyOrIv_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[15]; + VmessBodyKeys.DeriveResponseKeyOrIv(CmdKey, small); + }); + } +} diff --git a/QuickProxyNet.Tests/VmessRequestTest.cs b/QuickProxyNet.Tests/VmessRequestTest.cs new file mode 100644 index 0000000..1d977bf --- /dev/null +++ b/QuickProxyNet.Tests/VmessRequestTest.cs @@ -0,0 +1,755 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet.Tests; + +/// +/// Tests for the VMessAEAD (alterId = 0) client request header: +/// , and . +/// +/// Every wire vector below is GROUND TRUTH produced by an independent Python +/// reimplementation (scratchpad/vmess_request_truth.py) written from +/// docs/vmess-aead-request.md alone — stdlib hashlib/zlib/struct, a hand-rolled +/// ipad/opad nested KDF and a hand-rolled FNV-1a-32, with AES-ECB/AES-GCM from +/// `cryptography`. That script first reproduces public known-answer vectors +/// (CRC32("123456789")=0xCBF43926, FNV-1a-32("")=0x811C9DC5, MD5("")) and the KDF +/// vectors already committed in VmessCryptoTest before any value here is trusted. +/// +/// All inputs are synthetic: a made-up UUID, a fixed timestamp, counting byte +/// patterns, and RFC-documentation targets (mc.example.com, 192.0.2.10). +/// +public class VmessRequestTest +{ + // ---- synthetic scenario (must match scratchpad/vmess_request_truth.py) ---- + private const string Uuid = "11223344-5566-7788-99aa-bbccddeeff00"; + private const long Timestamp = 1700000000L; + + private static readonly byte[] Random4 = Hex("aabbccdd"); + private static readonly byte[] ConnectionNonce = Hex("0102030405060708"); + private static readonly byte[] BodyIv = Hex("a0a1a2a3a4a5a6a7a8a9aaabacadaeaf"); + private static readonly byte[] BodyKey = Hex("b0b1b2b3b4b5b6b7b8b9babbbcbdbebf"); + private const byte RespV = 0x2A; + private const byte Option = 0x1D; // S|M|P|A + private const byte Security = VmessRequest.SecurityAes128Gcm; // 3 + private const byte Command = VmessRequest.CommandTcp; // 1 + + private static readonly byte[] Padding15 = Hex("e0e1e2e3e4e5e6e7e8e9eaebecedee"); + private static readonly byte[] Padding0 = []; + + private const string DomainHost = "mc.example.com"; + private const int DomainPort = 25565; + private const string IPv4Host = "192.0.2.10"; + private const int IPv4Port = 443; + + // ---- pinned expectations ---- + private const string ExpectedCmdKey = "704509150f5149ab9e46f235943a0cf1"; + private const string ExpectedAuthIdKey = "d6ab098903a2d086db7b1473959b93ed"; + private const string ExpectedAuthIdPlaintext = "000000006553f100aabbccdd5bdd8f9d"; + private const string ExpectedAuthId = "b76d66e3b7e88c9e6bd82d8bd530a9a1"; + + private const string ExpectedCommandDomainPad15 = + "01a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf2a1df3" + + "000163dd020e6d632e6578616d706c652e636f6de0e1e2e3e4e5e6e7e8e9eaebecedee" + + "bfbe1759"; + + private const string ExpectedCommandDomainPad0 = + "01a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf2a1d03" + + "000163dd020e6d632e6578616d706c652e636f6d2a3bdef2"; + + private const string ExpectedCommandIPv4Pad15 = + "01a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf2a1df3" + + "000101bb01c000020ae0e1e2e3e4e5e6e7e8e9eaebecedeef9190117"; + + private const string ExpectedCommandIPv4Pad0 = + "01a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf2a1d03" + + "000101bb01c000020acbfcda38"; + + private const string ExpectedWireDomainPad15 = + "b76d66e3b7e88c9e6bd82d8bd530a9a1e5e5800c8a518063849e15436598e33cf69f" + + "0102030405060708" + + "e10b9b9a4af9ddb12a73a436cd39304f38f3119cc7084e1e6a34eefc6b489e24447ad6" + + "9d43deded7de3a56cb346402328dec2f57a4dedb18a9f9a125615740e0d537f9309af7" + + "6b1b55d066fd66442fbfbff185570c89035db2005a"; + + private const string ExpectedWireDomainPad0 = + "b76d66e3b7e88c9e6bd82d8bd530a9a1e592f5c5f887a7fbc084eb3a16349b72f6b6" + + "0102030405060708" + + "e10b9b9a4af9ddb12a73a436cd39304f38f3119cc7084e1e6a34eefc6b489e24447ad6" + + "6d43deded7de3a56cb346402328dec2f57a4dedb1863239d34366928564bcdd415f97f" + + "3d9cbd0a44ac"; + + private const string ExpectedWireIPv4Pad15 = + "b76d66e3b7e88c9e6bd82d8bd530a9a1e5eeded78ce590ae08a9e8f2a2b4827d81ee" + + "0102030405060708" + + "e10b9b9a4af9ddb12a73a436cd39304f38f3119cc7084e1e6a34eefc6b489e24447ad6" + + "9d43debcb1ddf43baa10e19bb10378a6d46d555d9fa2f4ae287caba7108b655708835" + + "24cc3f633e83e436bf256"; + + private const string ExpectedWireIPv4Pad0 = + "b76d66e3b7e88c9e6bd82d8bd530a9a1e59f53e4457e72b26646dceb7de514b34d09" + + "0102030405060708" + + "e10b9b9a4af9ddb12a73a436cd39304f38f3119cc7084e1e6a34eefc6b489e24447ad6" + + "6d43debcb1ddf43baa10ca8689d89aa806f52494b2073fd760ab873b3fcf"; + + private static byte[] Hex(string h) => Convert.FromHexString(h); + + private static byte[] CmdKey() + { + var key = new byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(Uuid, key); + return key; + } + + private static byte[] AuthId() + { + var authId = new byte[VmessAuthId.Size]; + VmessAuthId.Create(CmdKey(), Timestamp, Random4, authId); + return authId; + } + + // The material is a ref struct, so it is rebuilt per call from the array fields. + private static VmessRequestMaterial Material(byte[] padding) => new() + { + AuthIdTimestamp = Timestamp, + AuthIdRandom = Random4, + ConnectionNonce = ConnectionNonce, + BodyKey = BodyKey, + BodyIv = BodyIv, + ResponseVerifier = RespV, + Padding = padding, + }; + + private static byte[] BuildCommandSection(byte[] padding, string host, int port) + { + var buffer = new byte[VmessRequest.MaxCommandSectionSize]; + int length = VmessRequest.WriteCommandSection( + buffer, Material(padding), Option, Security, Command, host, port); + return buffer[..length]; + } + + private static byte[] BuildWire(byte[] padding, string host, int port) + { + var buffer = new byte[VmessRequest.MaxRequestSize]; + int length = VmessRequest.Build( + buffer, CmdKey(), Material(padding), Option, Security, Command, host, port); + return buffer[..length]; + } + + // ========================= §1 cmdKey ========================= + + [Fact] + public void CmdKey_MatchesGroundTruth() + { + Assert.Equal(ExpectedCmdKey, Convert.ToHexStringLower(CmdKey())); + } + + [Fact] + public void CmdKey_IsMd5OfUuidPlusMagic_52ByteInput() + { + // Independent re-computation of §1 from the raw pieces, to prove the helper + // hashes uuid16 ‖ magic (52 bytes) in that order and nothing else. + byte[] input = new byte[52]; + Guid.Parse(Uuid).TryWriteBytes(input.AsSpan(0, 16), bigEndian: true, out _); + "c48619fe-8f02-49e0-b9e9-edf763e17e21"u8.CopyTo(input.AsSpan(16)); + + Assert.Equal(Convert.ToHexStringLower(MD5.HashData(input)), Convert.ToHexStringLower(CmdKey())); + } + + [Fact] + public void CmdKey_UsesBigEndianUuidBytes_NotGuidToByteArray() + { + // Guid.ToByteArray() is little-endian for the first three fields; using it would + // silently produce a different (wrong) cmdKey. + byte[] wrong = new byte[52]; + Guid.Parse(Uuid).ToByteArray().CopyTo(wrong, 0); + "c48619fe-8f02-49e0-b9e9-edf763e17e21"u8.CopyTo(wrong.AsSpan(16)); + + Assert.NotEqual(ExpectedCmdKey, Convert.ToHexStringLower(MD5.HashData(wrong))); + } + + [Fact] + public void CmdKey_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[15]; + VmessCmdKey.Derive(Uuid, small); + }); + } + + [Fact] + public void CmdKey_UnusableUuid_Throws() + { + // 31 characters: too long for Xray's derivation window (1..30), too short to be a + // canonical UUID (32..36). Upstream errors here, so must we. + Assert.Throws(() => + { + Span dst = stackalloc byte[16]; + VmessCmdKey.Derive(new string('x', 31), dst); + }); + + Assert.Throws(() => + { + Span dst = stackalloc byte[16]; + VmessCmdKey.Derive("", dst); + }); + } + + [Fact] + public void CmdKey_ShortNonUuidId_UsesDerivedUuid() + { + // A short non-UUID id is not an error: it is mapped to UUIDv5(nil, id), so the + // cmdKey must equal the one derived from that UUID's canonical spelling. + Span fromText = stackalloc byte[16]; + VmessCmdKey.Derive("not-a-uuid", fromText); + + Span fromDerivedUuid = stackalloc byte[16]; + VmessCmdKey.Derive("9b70e619-d7b3-55b1-b743-756ebd573b4e", fromDerivedUuid); + + Assert.Equal(fromDerivedUuid.ToArray(), fromText.ToArray()); + } + + // ========================= §3 AuthID ========================= + + [Fact] + public void AuthId_Plaintext_MatchesGroundTruth() + { + Span plaintext = stackalloc byte[16]; + VmessAuthId.WritePlaintext(Timestamp, Random4, plaintext); + Assert.Equal(ExpectedAuthIdPlaintext, Convert.ToHexStringLower(plaintext)); + } + + [Fact] + public void AuthId_Plaintext_LayoutIsTimestampRandomCrc() + { + Span plaintext = stackalloc byte[16]; + VmessAuthId.WritePlaintext(Timestamp, Random4, plaintext); + + // [0..8) int64 big-endian timestamp + Assert.Equal(Timestamp, BinaryPrimitives.ReadInt64BigEndian(plaintext)); + // [8..12) the supplied random bytes + Assert.Equal(Random4, plaintext.Slice(8, 4).ToArray()); + // [12..16) CRC-32/IEEE of the FIRST TWELVE bytes, big-endian + uint crc = Crc32.Compute(plaintext[..12]); + Assert.Equal(crc, BinaryPrimitives.ReadUInt32BigEndian(plaintext[12..])); + Assert.Equal(0x5bdd8f9du, crc); + } + + [Fact] + public void AuthId_Plaintext_ZeroInputs_MatchesGroundTruth() + { + Span plaintext = stackalloc byte[16]; + VmessAuthId.WritePlaintext(0, new byte[4], plaintext); + Assert.Equal("0000000000000000000000007bd5c66f", Convert.ToHexStringLower(plaintext)); + } + + [Fact] + public void AuthId_EncryptionKey_MatchesGroundTruth() + { + Span key = stackalloc byte[16]; + VmessKdf.Kdf16(CmdKey(), "AES Auth ID Encryption"u8, key); + Assert.Equal(ExpectedAuthIdKey, Convert.ToHexStringLower(key)); + } + + [Fact] + public void AuthId_Encrypted_MatchesGroundTruth() + { + Assert.Equal(ExpectedAuthId, Convert.ToHexStringLower(AuthId())); + } + + [Fact] + public void AuthId_ZeroInputs_MatchesGroundTruth() + { + Span authId = stackalloc byte[16]; + VmessAuthId.Create(CmdKey(), 0, new byte[4], authId); + Assert.Equal("fd5844d453230c0f57028e2d77bb6cf8", Convert.ToHexStringLower(authId)); + } + + [Fact] + public void AuthId_IsSingleBlockEcb_NoPaddingNoIv() + { + // Decrypting the 16-byte AuthID with the derived key must return the plaintext + // exactly — proving raw ECB of one block (an AEAD or CBC would not round-trip). + byte[] key = new byte[16]; + VmessKdf.Kdf16(CmdKey(), "AES Auth ID Encryption"u8, key); + + using var aes = Aes.Create(); + aes.Key = key; + byte[] decrypted = aes.DecryptEcb(AuthId(), PaddingMode.None); + + Assert.Equal(ExpectedAuthIdPlaintext, Convert.ToHexStringLower(decrypted)); + } + + [Fact] + public void AuthId_ConvenienceOverload_UsesCurrentTimeAndFreshRandomness() + { + byte[] key = new byte[16]; + VmessKdf.Kdf16(CmdKey(), "AES Auth ID Encryption"u8, key); + + Span first = stackalloc byte[16]; + Span second = stackalloc byte[16]; + VmessAuthId.Create(CmdKey(), first); + VmessAuthId.Create(CmdKey(), second); + + // Fresh randomness per call. + Assert.NotEqual(first.ToArray(), second.ToArray()); + + using var aes = Aes.Create(); + aes.Key = key; + byte[] plaintext = aes.DecryptEcb(first, PaddingMode.None); + + long stamp = BinaryPrimitives.ReadInt64BigEndian(plaintext); + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + // VMessAEAD sends the exact current second (no ±30 s legacy jitter). + Assert.InRange(stamp, now - 5, now + 5); + Assert.Equal(Crc32.Compute(plaintext.AsSpan(0, 12)), + BinaryPrimitives.ReadUInt32BigEndian(plaintext.AsSpan(12))); + } + + [Fact] + public void AuthId_RandomWrongLength_Throws() + { + Assert.Throws(() => + { + Span dst = stackalloc byte[16]; + VmessAuthId.Create(CmdKey(), Timestamp, new byte[3], dst); + }); + } + + [Fact] + public void AuthId_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[15]; + VmessAuthId.Create(CmdKey(), Timestamp, Random4, small); + }); + } + + [Fact] + public void AuthId_CmdKeyWrongLength_Throws() + { + Assert.Throws(() => + { + Span dst = stackalloc byte[16]; + VmessAuthId.Create(new byte[15], Timestamp, Random4, dst); + }); + } + + [Fact] + public void AuthId_PlaintextDestinationTooSmall_Throws() + { + Assert.Throws(() => + { + Span small = stackalloc byte[15]; + VmessAuthId.WritePlaintext(Timestamp, Random4, small); + }); + } + + // ========================= §5 command section ========================= + + [Fact] + public void CommandSection_Domain_Padding15_MatchesGroundTruth() + { + byte[] data = BuildCommandSection(Padding15, DomainHost, DomainPort); + Assert.Equal(75, data.Length); + Assert.Equal(ExpectedCommandDomainPad15, Convert.ToHexStringLower(data)); + } + + [Fact] + public void CommandSection_Domain_Padding0_MatchesGroundTruth() + { + byte[] data = BuildCommandSection(Padding0, DomainHost, DomainPort); + Assert.Equal(60, data.Length); + Assert.Equal(ExpectedCommandDomainPad0, Convert.ToHexStringLower(data)); + } + + [Fact] + public void CommandSection_IPv4_Padding15_MatchesGroundTruth() + { + byte[] data = BuildCommandSection(Padding15, IPv4Host, IPv4Port); + Assert.Equal(64, data.Length); + Assert.Equal(ExpectedCommandIPv4Pad15, Convert.ToHexStringLower(data)); + } + + [Fact] + public void CommandSection_IPv4_Padding0_MatchesGroundTruth() + { + byte[] data = BuildCommandSection(Padding0, IPv4Host, IPv4Port); + Assert.Equal(49, data.Length); + Assert.Equal(ExpectedCommandIPv4Pad0, Convert.ToHexStringLower(data)); + } + + [Fact] + public void CommandSection_FieldLayout_IsVersionIvKeyRespOptionPadSec() + { + byte[] data = BuildCommandSection(Padding15, DomainHost, DomainPort); + + Assert.Equal(0x01, data[0]); // version + Assert.Equal(BodyIv, data[1..17]); // requestBodyIV + Assert.Equal(BodyKey, data[17..33]); // requestBodyKey + Assert.Equal(RespV, data[33]); // response verifier + Assert.Equal(Option, data[34]); // option flags + Assert.Equal(0xF3, data[35]); // (15 << 4) | 3 + Assert.Equal(15, data[35] >> 4); // padding nibble + Assert.Equal(Security, (byte)(data[35] & 0x0F)); // security nibble + Assert.Equal(0x00, data[36]); // reserved + Assert.Equal(Command, data[37]); // command + } + + [Fact] + public void CommandSection_WritesPortBeforeAddress() + { + // VMess is PortThenAddress — the opposite of SOCKS5/Trojan. + byte[] data = BuildCommandSection(Padding0, DomainHost, DomainPort); + + Assert.Equal(DomainPort, BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(38, 2))); + Assert.Equal(0x63, data[38]); // 25565 = 0x63DD + Assert.Equal(0xDD, data[39]); + Assert.Equal(0x02, data[40]); // atyp = domain + Assert.Equal(DomainHost.Length, data[41]); // 1-byte length prefix + Assert.Equal(DomainHost, System.Text.Encoding.ASCII.GetString(data, 42, DomainHost.Length)); + } + + [Fact] + public void CommandSection_IPv4_UsesAtyp01AndRawBytes() + { + byte[] data = BuildCommandSection(Padding0, IPv4Host, IPv4Port); + + Assert.Equal(IPv4Port, BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(38, 2))); + Assert.Equal(0x01, data[40]); // atyp = IPv4 + Assert.Equal(Hex("c000020a"), data[41..45]); // 192.0.2.10 + } + + [Fact] + public void CommandSection_IPv6_UsesAtyp03And16RawBytes() + { + byte[] data = BuildCommandSection(Padding0, "2001:db8::1", 8080); + + Assert.Equal(8080, BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(38, 2))); + Assert.Equal(0x03, data[40]); // atyp = IPv6 + Assert.Equal(Hex("20010db8000000000000000000000001"), data[41..57]); + Assert.Equal(40 + 1 + 16 + 4, data.Length); + } + + [Fact] + public void CommandSection_PaddingPrecedesChecksum_AndIsCovered() + { + byte[] data = BuildCommandSection(Padding15, DomainHost, DomainPort); + + // padding sits immediately before the 4-byte checksum + Assert.Equal(Padding15, data[^19..^4]); + + // FNV-1a-32 big-endian over everything preceding it, padding included + Assert.Equal(Fnv1a32.Compute(data.AsSpan(0, data.Length - 4)), + BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(data.Length - 4))); + Assert.Equal(0xbfbe1759u, Fnv1a32.Compute(data.AsSpan(0, data.Length - 4))); + } + + [Fact] + public void CommandSection_ChecksumChangesWithPadding() + { + byte[] withPadding = BuildCommandSection(Padding15, DomainHost, DomainPort); + byte[] withoutPadding = BuildCommandSection(Padding0, DomainHost, DomainPort); + + Assert.NotEqual(withPadding[^4..], withoutPadding[^4..]); + Assert.Equal(0x2a3bdef2u, Fnv1a32.Compute(withoutPadding.AsSpan(0, withoutPadding.Length - 4))); + } + + [Fact] + public void CommandSection_PaddingTooLong_Throws() + { + Assert.Throws(() => + { + var buffer = new byte[VmessRequest.MaxCommandSectionSize]; + VmessRequest.WriteCommandSection( + buffer, Material(new byte[16]), Option, Security, Command, DomainHost, DomainPort); + }); + } + + [Fact] + public void CommandSection_BodyKeyWrongLength_Throws() + { + Assert.Throws(() => + { + var buffer = new byte[VmessRequest.MaxCommandSectionSize]; + var material = new VmessRequestMaterial + { + AuthIdTimestamp = Timestamp, + AuthIdRandom = Random4, + ConnectionNonce = ConnectionNonce, + BodyKey = new byte[15], + BodyIv = BodyIv, + ResponseVerifier = RespV, + Padding = Padding0, + }; + VmessRequest.WriteCommandSection( + buffer, material, Option, Security, Command, DomainHost, DomainPort); + }); + } + + [Fact] + public void CommandSection_BodyIvWrongLength_Throws() + { + Assert.Throws(() => + { + var buffer = new byte[VmessRequest.MaxCommandSectionSize]; + var material = new VmessRequestMaterial + { + AuthIdTimestamp = Timestamp, + AuthIdRandom = Random4, + ConnectionNonce = ConnectionNonce, + BodyKey = BodyKey, + BodyIv = new byte[17], + ResponseVerifier = RespV, + Padding = Padding0, + }; + VmessRequest.WriteCommandSection( + buffer, material, Option, Security, Command, DomainHost, DomainPort); + }); + } + + [Fact] + public void CommandSection_SecurityOutOfRange_Throws() + { + Assert.Throws(() => + { + var buffer = new byte[VmessRequest.MaxCommandSectionSize]; + VmessRequest.WriteCommandSection( + buffer, Material(Padding0), Option, 0x10, Command, DomainHost, DomainPort); + }); + } + + [Fact] + public void CommandSection_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + var buffer = new byte[VmessRequest.MaxCommandSectionSize - 1]; + VmessRequest.WriteCommandSection( + buffer, Material(Padding0), Option, Security, Command, DomainHost, DomainPort); + }); + } + + // ========================= §4 sealed envelope ========================= + + [Fact] + public void Wire_Domain_Padding15_MatchesGroundTruth() + { + byte[] wire = BuildWire(Padding15, DomainHost, DomainPort); + Assert.Equal(58 + 75, wire.Length); + Assert.Equal(ExpectedWireDomainPad15, Convert.ToHexStringLower(wire)); + } + + [Fact] + public void Wire_Domain_Padding0_MatchesGroundTruth() + { + byte[] wire = BuildWire(Padding0, DomainHost, DomainPort); + Assert.Equal(58 + 60, wire.Length); + Assert.Equal(ExpectedWireDomainPad0, Convert.ToHexStringLower(wire)); + } + + [Fact] + public void Wire_IPv4_Padding15_MatchesGroundTruth() + { + byte[] wire = BuildWire(Padding15, IPv4Host, IPv4Port); + Assert.Equal(58 + 64, wire.Length); + Assert.Equal(ExpectedWireIPv4Pad15, Convert.ToHexStringLower(wire)); + } + + [Fact] + public void Wire_IPv4_Padding0_MatchesGroundTruth() + { + byte[] wire = BuildWire(Padding0, IPv4Host, IPv4Port); + Assert.Equal(58 + 49, wire.Length); + Assert.Equal(ExpectedWireIPv4Pad0, Convert.ToHexStringLower(wire)); + } + + [Fact] + public void Wire_FieldOrder_IsAuthIdLengthNoncePayload() + { + byte[] wire = BuildWire(Padding15, DomainHost, DomainPort); + + Assert.Equal(AuthId(), wire[..16]); // [0..16) authid + Assert.Equal(ConnectionNonce, wire[34..42]); // [34..42) connection nonce + Assert.Equal(58 + 75, wire.Length); // 16 + 18 + 8 + (L + 16) + } + + [Fact] + public void Wire_LengthAead_DecryptsToCommandSectionLength() + { + // Acts as the server: re-derive the length key/nonce and open [16..34) with the + // AuthID as associated data. + byte[] wire = BuildWire(Padding15, DomainHost, DomainPort); + byte[] authId = wire[..16]; + + byte[] key = new byte[16]; + byte[] nonce = new byte[12]; + VmessKdf.Kdf16(CmdKey(), "VMess Header AEAD Key_Length"u8, authId, ConnectionNonce, key); + VmessKdf.Kdf12(CmdKey(), "VMess Header AEAD Nonce_Length"u8, authId, ConnectionNonce, nonce); + + byte[] plaintext = new byte[2]; + using var gcm = new AesGcm(key, 16); + gcm.Decrypt(nonce, wire.AsSpan(16, 2), wire.AsSpan(18, 16), plaintext, authId); + + Assert.Equal(75, BinaryPrimitives.ReadUInt16BigEndian(plaintext)); + } + + [Fact] + public void Wire_PayloadAead_DecryptsToCommandSection() + { + byte[] wire = BuildWire(Padding15, DomainHost, DomainPort); + byte[] authId = wire[..16]; + + byte[] key = new byte[16]; + byte[] nonce = new byte[12]; + VmessKdf.Kdf16(CmdKey(), "VMess Header AEAD Key"u8, authId, ConnectionNonce, key); + VmessKdf.Kdf12(CmdKey(), "VMess Header AEAD Nonce"u8, authId, ConnectionNonce, nonce); + + int length = wire.Length - 58; + byte[] plaintext = new byte[length]; + using var gcm = new AesGcm(key, 16); + gcm.Decrypt(nonce, wire.AsSpan(42, length), wire.AsSpan(42 + length, 16), plaintext, authId); + + Assert.Equal(ExpectedCommandDomainPad15, Convert.ToHexStringLower(plaintext)); + } + + [Fact] + public void Wire_AuthIdIsAssociatedData_TamperingIsDetected() + { + byte[] wire = BuildWire(Padding0, IPv4Host, IPv4Port); + byte[] tamperedAuthId = wire[..16]; + tamperedAuthId[0] ^= 0xFF; + + byte[] key = new byte[16]; + byte[] nonce = new byte[12]; + VmessKdf.Kdf16(CmdKey(), "VMess Header AEAD Key"u8, wire.AsSpan(0, 16), ConnectionNonce, key); + VmessKdf.Kdf12(CmdKey(), "VMess Header AEAD Nonce"u8, wire.AsSpan(0, 16), ConnectionNonce, nonce); + + int length = wire.Length - 58; + byte[] plaintext = new byte[length]; + using var gcm = new AesGcm(key, 16); + + // Correct key/nonce but the wrong AAD must fail the tag check. + Assert.Throws(() => + gcm.Decrypt(nonce, wire.AsSpan(42, length), wire.AsSpan(42 + length, 16), plaintext, tamperedAuthId)); + } + + [Fact] + public void Seal_ComposesWithWriteCommandSection() + { + byte[] data = BuildCommandSection(Padding15, DomainHost, DomainPort); + byte[] sealed_ = new byte[VmessRequest.MaxRequestSize]; + int length = VmessRequest.Seal(sealed_, CmdKey(), AuthId(), ConnectionNonce, data); + + Assert.Equal(58 + data.Length, length); + Assert.Equal(ExpectedWireDomainPad15, Convert.ToHexStringLower(sealed_.AsSpan(0, length))); + } + + [Fact] + public void Seal_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + byte[] data = BuildCommandSection(Padding0, IPv4Host, IPv4Port); + byte[] tooSmall = new byte[58 + data.Length - 1]; + VmessRequest.Seal(tooSmall, CmdKey(), AuthId(), ConnectionNonce, data); + }); + } + + [Fact] + public void Seal_CmdKeyWrongLength_Throws() + { + Assert.Throws(() => + VmessRequest.Seal(new byte[VmessRequest.MaxRequestSize], new byte[15], AuthId(), + ConnectionNonce, BuildCommandSection(Padding0, IPv4Host, IPv4Port))); + } + + [Fact] + public void Seal_AuthIdWrongLength_Throws() + { + Assert.Throws(() => + VmessRequest.Seal(new byte[VmessRequest.MaxRequestSize], CmdKey(), new byte[15], + ConnectionNonce, BuildCommandSection(Padding0, IPv4Host, IPv4Port))); + } + + [Fact] + public void Seal_ConnectionNonceWrongLength_Throws() + { + Assert.Throws(() => + VmessRequest.Seal(new byte[VmessRequest.MaxRequestSize], CmdKey(), AuthId(), + new byte[7], BuildCommandSection(Padding0, IPv4Host, IPv4Port))); + } + + // ========================= sizes & production material ========================= + + [Fact] + public void Constants_MatchSpecSizes() + { + Assert.Equal(58, VmessRequest.SealOverhead); + Assert.Equal(15, VmessRequest.MaxPaddingLength); + Assert.Equal(60, VmessRequest.MaterialScratchSize); + Assert.Equal(316, VmessRequest.MaxCommandSectionSize); + Assert.Equal(374, VmessRequest.MaxRequestSize); + Assert.Equal(0x1D, VmessRequest.DefaultOption); + } + + [Fact] + public void CreateMaterial_FillsEveryFieldWithTheRightSize() + { + Span scratch = stackalloc byte[VmessRequest.MaterialScratchSize]; + var material = VmessRequest.CreateMaterial(scratch); + + Assert.Equal(VmessAuthId.RandomSize, material.AuthIdRandom.Length); + Assert.Equal(VmessRequest.ConnectionNonceSize, material.ConnectionNonce.Length); + Assert.Equal(VmessRequest.BodyKeySize, material.BodyKey.Length); + Assert.Equal(VmessRequest.BodyKeySize, material.BodyIv.Length); + Assert.InRange(material.Padding.Length, 0, VmessRequest.MaxPaddingLength); + Assert.InRange(material.AuthIdTimestamp, + DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 5, + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 5); + + // The body key and IV must be independent random values, not the same slice. + Assert.NotEqual(material.BodyKey.ToArray(), material.BodyIv.ToArray()); + } + + [Fact] + public void CreateMaterial_ProducesAValidHeaderOfTheExpectedLength() + { + Span scratch = stackalloc byte[VmessRequest.MaterialScratchSize]; + var material = VmessRequest.CreateMaterial(scratch); + + Span destination = stackalloc byte[VmessRequest.MaxRequestSize]; + Span cmdKey = stackalloc byte[VmessCmdKey.Size]; + VmessCmdKey.Derive(Uuid, cmdKey); + + int length = VmessRequest.Build(destination, cmdKey, material, VmessRequest.DefaultOption, + VmessRequest.SecurityAes128Gcm, VmessRequest.CommandTcp, DomainHost, DomainPort); + + // 58 + version(1)+iv(16)+key(16)+respV(1)+opt(1)+padSec(1)+rsv(1)+cmd(1)+port(2) + // + atyp(1)+len(1)+host(14) + padding + fnv(4) + Assert.Equal(58 + 60 + material.Padding.Length, length); + Assert.Equal(material.ConnectionNonce.ToArray(), destination.Slice(34, 8).ToArray()); + } + + [Fact] + public void CreateMaterial_ScratchTooSmall_Throws() + { + Assert.Throws(() => + { + Span scratch = stackalloc byte[VmessRequest.MaterialScratchSize - 1]; + VmessRequest.CreateMaterial(scratch); + }); + } + + [Fact] + public void Build_DestinationTooSmall_Throws() + { + Assert.Throws(() => + { + byte[] tooSmall = new byte[58 + 60 - 1]; + VmessRequest.Build(tooSmall, CmdKey(), Material(Padding0), Option, Security, Command, + DomainHost, DomainPort); + }); + } +} diff --git a/QuickProxyNet.Tests/X25519Test.cs b/QuickProxyNet.Tests/X25519Test.cs new file mode 100644 index 0000000..b16f544 --- /dev/null +++ b/QuickProxyNet.Tests/X25519Test.cs @@ -0,0 +1,282 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet.Tests; + +/// +/// Tests for the managed X25519 used by the REALITY key exchange. +/// +/// +/// The RFC 7748 vectors prove the arithmetic against the specification. The Xray keypair proves +/// it against the implementation we actually have to interoperate with — a self-consistent +/// curve implementation that disagrees with Go would pass every vector we invented ourselves. +/// +public class X25519Test +{ + private static byte[] Hex(string hex) + { + byte[] bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + + return bytes; + } + + /// Decodes the base64url form share links and Xray's own tooling use for keys. + private static byte[] Base64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; + return Convert.FromBase64String(padded); + } + + // RFC 7748 §5.2. + [Theory] + [InlineData( + "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", + "e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c", + "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552")] + [InlineData( + "4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d", + "e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493", + "95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957")] + public void Rfc7748_ScalarMultiplication(string scalar, string u, string expected) + { + byte[] result = new byte[32]; + X25519.Agree(result, Hex(scalar), Hex(u)); + + Assert.Equal(expected, Convert.ToHexString(result).ToLowerInvariant()); + } + + /// + /// RFC 7748 §5.2, the iterated vector: k = u = 9, then k, u = X25519(k, u), k for each + /// round. One round and a thousand rounds have published results. A single-vector test + /// exercises one set of limb values; a thousand chained ones exercise a thousand, which is + /// what finds a carry that is wrong for a few inputs in 2^255. + /// + [Theory] + [InlineData(1, "422c8e7a6227d7bca1350b3e2bb7279f7897b87bb6854b783c60e80311ae3079")] + [InlineData(1000, "684cf59ba83309552800ef566f2f4d3c1c3887c49360e3875f2eb94d99532c51")] + public void Rfc7748_IteratedScalarMultiplication(int iterations, string expected) + { + byte[] k = new byte[32]; + byte[] u = new byte[32]; + k[0] = 9; + u[0] = 9; + + byte[] result = new byte[32]; + for (int i = 0; i < iterations; i++) + { + X25519.Agree(result, k, u); + Array.Copy(k, u, 32); + Array.Copy(result, k, 32); + } + + Assert.Equal(expected, Convert.ToHexString(k).ToLowerInvariant()); + } + + // RFC 7748 §6.1. + [Fact] + public void Rfc7748_DiffieHellman() + { + byte[] alicePrivate = Hex("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"); + byte[] bobPrivate = Hex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"); + + byte[] alicePublic = new byte[32]; + byte[] bobPublic = new byte[32]; + X25519.GetPublicKey(alicePublic, alicePrivate); + X25519.GetPublicKey(bobPublic, bobPrivate); + + Assert.Equal( + "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + Convert.ToHexString(alicePublic).ToLowerInvariant()); + Assert.Equal( + "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", + Convert.ToHexString(bobPublic).ToLowerInvariant()); + + byte[] fromAlice = new byte[32]; + byte[] fromBob = new byte[32]; + X25519.Agree(fromAlice, alicePrivate, bobPublic); + X25519.Agree(fromBob, bobPrivate, alicePublic); + + Assert.Equal( + "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742", + Convert.ToHexString(fromAlice).ToLowerInvariant()); + Assert.Equal(fromAlice, fromBob); + } + + /// + /// The keypair below came out of xray x25519. Deriving the same public key from the + /// same private key is a direct cross-check against Go's curve25519. + /// + [Fact] + public void XrayGeneratedKeypair_DerivesTheSamePublicKey() + { + byte[] privateKey = Base64Url(Integration.LocalRealityServer.PrivateKey); + byte[] expected = Base64Url(Integration.LocalRealityServer.PublicKey); + + byte[] actual = new byte[32]; + X25519.GetPublicKey(actual, privateKey); + + Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual)); + } + + [Fact] + public void GeneratedKeyPairs_Agree() + { + byte[] privateA = new byte[32], publicA = new byte[32]; + byte[] privateB = new byte[32], publicB = new byte[32]; + X25519.GenerateKeyPair(privateA, publicA); + X25519.GenerateKeyPair(privateB, publicB); + + byte[] sharedA = new byte[32], sharedB = new byte[32]; + X25519.Agree(sharedA, privateA, publicB); + X25519.Agree(sharedB, privateB, publicA); + + Assert.Equal(sharedA, sharedB); + } + + /// + /// RFC 7748 §6.1 requires rejecting an all-zero result: it means the peer sent a low-order + /// point, and the "shared" secret would be one the attacker chose. + /// + [Theory] + [InlineData("0000000000000000000000000000000000000000000000000000000000000000")] + [InlineData("0100000000000000000000000000000000000000000000000000000000000000")] + [InlineData("e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800")] + public void LowOrderPoint_IsRejected(string peerPublicKey) + { + byte[] privateKey = new byte[32]; + byte[] publicKey = new byte[32]; + X25519.GenerateKeyPair(privateKey, publicKey); + + byte[] shared = new byte[32]; + Assert.Throws(() => X25519.Agree(shared, privateKey, Hex(peerPublicKey))); + } + + /// 2^255 - 19, the field the limbs represent residues in. + private static readonly System.Numerics.BigInteger Prime = + (System.Numerics.BigInteger.One << 255) - 19; + + private static System.Numerics.BigInteger ToInteger(ReadOnlySpan limbs) + { + System.Numerics.BigInteger value = 0; + for (int i = limbs.Length - 1; i >= 0; i--) + value = (value << 51) + limbs[i]; + + return value % Prime; + } + + /// + /// Field multiplication against arbitrary-precision arithmetic, on limb patterns chosen to + /// stress the carry chain. + /// + /// + /// The scalar-multiplication vectors above exercise the field ops only through whatever limb + /// values the ladder happens to produce. A dropped carry mask is wrong by exactly one limb + /// weight and fires for a vanishing fraction of inputs, so it can hide behind every RFC + /// vector and every random handshake while still breaking one connection in a billion. These + /// compare the arithmetic directly, with the maximum limb values deliberately included. + /// + /// + /// Limb patterns that sit on the carry boundaries, plus a fixed pseudo-random spread. + /// + /// + /// The upper bound is 2^52, not 2^51: the ladder adds field elements without carrying, so the + /// field operations really are handed limbs above the mask, and an implementation that only + /// survives canonical inputs would pass a narrower set of cases and still fail in the ladder. + /// + private static List CarryStressCases() + { + const ulong mask51 = (1UL << 51) - 1; + + ulong[][] adversarial = + [ + [mask51, mask51, mask51, mask51, mask51], + [mask51, 0, 0, 0, 0], + [0, 0, 0, 0, mask51], + [1, 0, 0, 0, 0], + [(1UL << 52) - 1, (1UL << 52) - 1, (1UL << 52) - 1, (1UL << 52) - 1, (1UL << 52) - 1], + [mask51 - 1, 1, mask51, 2, mask51] + ]; + + var random = new Random(20260820); + var cases = new List(adversarial); + for (int i = 0; i < 200; i++) + { + cases.Add( + [ + (ulong)random.NextInt64(0, 1L << 52), + (ulong)random.NextInt64(0, 1L << 52), + (ulong)random.NextInt64(0, 1L << 52), + (ulong)random.NextInt64(0, 1L << 52), + (ulong)random.NextInt64(0, 1L << 52) + ]); + } + + return cases; + } + + [Fact] + public void Multiply_MatchesArbitraryPrecision() + { + List cases = CarryStressCases(); + ulong[] result = new ulong[5]; + + foreach (ulong[] left in cases) + { + foreach (ulong[] right in cases) + { + X25519.MultiplyForTests(result, left, right); + + Assert.Equal( + ToInteger(left) * ToInteger(right) % Prime, + ToInteger(result)); + } + + // 121665 is the only scalar the ladder ever passes to MulSmall. + X25519.MulSmall(result, left, 121665); + + Assert.Equal( + ToInteger(left) * 121665 % Prime, + ToInteger(result)); + } + } + + /// + /// Squaring against both arbitrary-precision arithmetic and the general multiply. + /// + /// + /// Sqr exists only as a faster Mul(r, v, v): it folds the pairs of equal cross + /// products into single doubled ones, which is where a squaring routine goes wrong — a + /// doubling missed on one term is a result that is wrong by a limb and right everywhere the + /// term happens to be zero. Checking it against both references pins the shortcut to the + /// thing it is a shortcut for. + /// + [Fact] + public void Square_MatchesMultiplyAndArbitraryPrecision() + { + List cases = CarryStressCases(); + ulong[] squared = new ulong[5]; + ulong[] multiplied = new ulong[5]; + + foreach (ulong[] value in cases) + { + X25519.Sqr(squared, value); + X25519.MultiplyForTests(multiplied, value, value); + + Assert.Equal(ToInteger(value) * ToInteger(value) % Prime, ToInteger(squared)); + Assert.Equal(ToInteger(multiplied), ToInteger(squared)); + } + } + + [Fact] + public void Clamp_MatchesRfc7748() + { + byte[] scalar = new byte[32]; + Array.Fill(scalar, (byte)0xFF); + X25519.Clamp(scalar); + + Assert.Equal(0xF8, scalar[0]); + Assert.Equal(0x7F, scalar[31]); + } +} diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index d48080f..a9ef0ab 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -39,7 +39,7 @@ protected ProxyClient(string protocol, string host, int port) ProxyHost = host; ProxyPort = port == 0 ? 1080 : port; - ProxyUri = new Uri($"{protocol}://{host}:{port}"); + ProxyUri = new Uri($"{protocol}://{FormatUriHost(host)}:{port}"); } protected ProxyClient(string protocol, string host, int port, NetworkCredential credentials) @@ -60,10 +60,18 @@ protected ProxyClient(string protocol, string host, int port, NetworkCredential ProxyHost = host; ProxyPort = port == 0 ? 1080 : port; - ProxyUri = new Uri($"{protocol}://{credentials.UserName}:{credentials.Password}@{host}:{port}"); + ProxyUri = new Uri($"{protocol}://{credentials.UserName}:{credentials.Password}@{FormatUriHost(host)}:{port}"); ProxyCredentials = credentials; } + // An IPv6 literal must be bracketed in a URI ("[2001:db8::1]"), otherwise the Uri + // parser reads the address's colons as a port separator and throws. Host names and + // IPv4 literals never contain ':', so this only affects IPv6 endpoints. + // An IPv6 literal needs brackets inside a URI; one that already has them (a hand-built + // options object may carry "[::1]") must not get a second pair. + private static string FormatUriHost(string host) => + host.Contains(':') && !host.StartsWith('[') ? $"[{host}]" : host; + public Uri ProxyUri { get; private set; } public abstract ProxyType Type { get; } @@ -120,6 +128,16 @@ public async ValueTask ConnectAsync(string host, int port, CancellationT { 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); + } catch { await stream.DisposeAsync(); @@ -171,6 +189,9 @@ public virtual async ValueTask ConnectAsync(string host, int port, TimeS 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; } } diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs new file mode 100644 index 0000000..8fde059 --- /dev/null +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -0,0 +1,138 @@ +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +namespace QuickProxyNet; + +/// +/// Connects to a target host through a Trojan proxy. Trojan is TLS-mandatory: the request +/// header is written inside an session, over the tcp/raw, +/// ws or httpupgrade transport. The remaining transports are rejected with +/// . +/// +/// +/// A wrong password is not reported as one, by design of the protocol. A Trojan server +/// that does not recognise the password does not refuse: it forwards the connection to the +/// ordinary web site it is disguised as, so the request header this client wrote is answered +/// by that site. From here the symptom is a tunnel that opens normally and then carries the +/// decoy's bytes — typically an HTTP response from a server you did not ask for, or a close. +/// There is nothing on the wire that distinguishes that from a working tunnel, so no exception +/// is raised; a caller that can recognise its own protocol's first bytes is the only place this +/// can be caught. +/// +public sealed class TrojanClient : ProxyClient +{ + private readonly List? _alpn; + + /// Creates a Trojan client from strongly-typed options. + /// is null. + /// The password is empty. + public TrojanClient(TrojanOptions options) + : base("trojan", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) + { + // Fail fast: an empty password would authenticate as hex(SHA224("")), which no + // server accepts, so reject it at construction rather than mid-connect. + if (string.IsNullOrEmpty(options.Password)) + throw new ArgumentException("Trojan password must not be empty.", nameof(options)); + + Options = options; + _alpn = BuildAlpn(options.Alpn); + } + + /// Creates a Trojan client by parsing a trojan:// share link. + /// The link is malformed. + public static TrojanClient FromShareLink(string shareLink) => new(TrojanShareLink.Parse(shareLink)); + + /// The parsed Trojan configuration this client connects with. + public TrojanOptions Options { get; } + + /// + public override ProxyType Type => ProxyType.Trojan; + + /// + /// Overrides validation of the proxy server's TLS certificate. Ignored when + /// is true (all certificates are then accepted). + /// + public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } + + /// TLS protocol versions offered to the proxy. Defaults to TLS 1.2 and 1.3. + public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls12 | SslProtocols.Tls13; + + /// + 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. + TransportKind transport = EnsureSupported(); + + // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too, and every layer + // above it likewise owns the one below — so unwinding the outermost unwinds all of them. + Stream layered = new SslStream(stream, leaveInnerStreamOpen: false); + try + { + await ((SslStream)layered).AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken) + .ConfigureAwait(false); + + layered = await ProxyTransport.ApplyAsync( + transport, + layered, + Options.Path, + ProxyTransport.ResolveHostHeader(Options.HostHeader, Options.Sni, Options.Host), + cancellationToken).ConfigureAwait(false); + + await TrojanHelper.EstablishTrojanTunnelAsync(layered, Options, host, port, cancellationToken) + .ConfigureAwait(false); + return layered; + } + catch + { + await layered.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private TransportKind EnsureSupported() + { + TransportKind transport = Options.TransportKind; + if (transport == TransportKind.Unsupported) + throw new NotSupportedException( + $"Trojan transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + + "'httpupgrade' are implemented."); + + return transport; + } + + 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, + EnabledSslProtocols = SslProtocols, + RemoteCertificateValidationCallback = Options.AllowInsecure + ? static (_, _, _, _) => true + : ServerCertificateValidationCallback, + ApplicationProtocols = _alpn + }; + + // Built once per client from immutable options. Common ALPN ids map to the + // allocation-free static instances instead of encoding a fresh byte[] each time. + private static List? BuildAlpn(IReadOnlyList? alpn) + { + if (alpn is not { Count: > 0 }) + return null; + + var list = new List(alpn.Count); + for (int i = 0; i < alpn.Count; i++) + { + string p = alpn[i]; + list.Add(p switch + { + "h2" => SslApplicationProtocol.Http2, + "http/1.1" => SslApplicationProtocol.Http11, + "h3" => SslApplicationProtocol.Http3, + _ => new SslApplicationProtocol(p) + }); + } + return list; + } +} diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs new file mode 100644 index 0000000..e7509c7 --- /dev/null +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -0,0 +1,206 @@ +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +namespace QuickProxyNet; + +/// +/// Connects to a target host through a VLESS proxy. Supports security=none (plain TCP), +/// security=tls (over ) and security=reality (over this +/// library's own TLS 1.3), with or without flow=xtls-rprx-vision, each over the +/// tcp/raw, ws or httpupgrade transport. Other flows and the +/// remaining transports are rejected with . +/// +/// +/// REALITY needs no external process and no Xray binary: the handshake is +/// , in-process. What it does not yet do is look like a +/// browser on the wire — see TlsClientHello for why that matters and what is missing. +/// +public sealed class VlessClient : ProxyClient +{ + private readonly List? _alpn; + + /// Creates a VLESS client from strongly-typed options. + /// is null. + /// The options carry an invalid UUID. + public VlessClient(VlessOptions options) + : base("vless", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) + { + // Validate the id up front so a bad one fails at construction rather than mid-connect + // (the share-link path already validated it, but a directly-built VlessOptions may not + // have). This must use the same rule as the wire encoder: Guid.TryParse alone would + // reject the short non-UUID ids that UuidCodec — and Xray — map to a derived UUID. + Span probe = stackalloc byte[UuidCodec.Size]; + if (!UuidCodec.TryWriteBigEndian(options.Id, probe)) + throw new ArgumentException( + $"VLESS user id is unusable ({options.Id.Length} characters): it is neither a canonical UUID nor " + + "a string of 1..30 characters (which would be mapped to a UUID).", + nameof(options)); + + Options = options; + _alpn = BuildAlpn(options.Alpn); + } + + /// Creates a VLESS client by parsing a vless:// share link. + /// The link is malformed. + public static VlessClient FromShareLink(string shareLink) => new(VlessShareLink.Parse(shareLink)); + + /// The parsed VLESS configuration this client connects with. + public VlessOptions Options { get; } + + public override ProxyType Type => ProxyType.Vless; + + /// + /// Overrides validation of the proxy server's TLS certificate (only used when + /// is ). + /// + public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } + + /// TLS protocol versions offered to the proxy. Defaults to TLS 1.2 and 1.3. + public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls12 | SslProtocols.Tls13; + + /// + /// Writes the VLESS request header over (inside TLS when + /// is ) and returns the + /// tunnel to :. + /// + /// + /// Only the request header is written here. The server response header is validated lazily + /// on the first read (see VlessResponseStream), because neither Xray nor sing-box + /// flushes it until the target produces data — reading it eagerly would deadlock every + /// client-speaks-first protocol. + /// + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + TransportKind transport = EnsureSupported(); + + // Each layer takes ownership of the one below it, so tracking the outermost stream is + // enough to unwind the whole stack on failure. + Stream layered = stream; + try + { + if (Options.Security == VlessSecurity.Tls) + { + // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. + var ssl = new SslStream(layered, leaveInnerStreamOpen: false); + layered = ssl; + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + } + else if (Options.Security == VlessSecurity.Reality) + { + layered = await RealityTlsClient + .HandshakeAsync(layered, BuildRealityOptions(), cancellationToken) + .ConfigureAwait(false); + } + + layered = await ProxyTransport.ApplyAsync( + transport, + layered, + Options.Path, + ProxyTransport.ResolveHostHeader(Options.HostHeader, Options.Sni, Options.Host), + cancellationToken).ConfigureAwait(false); + + return await VlessHelper.EstablishVlessTunnelAsync(layered, Options, host, port, cancellationToken) + .ConfigureAwait(false); + } + catch + { + await layered.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private TransportKind EnsureSupported() + { + TransportKind transport = Options.TransportKind; + if (transport == TransportKind.Unsupported) + throw new NotSupportedException( + $"VLESS transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + + "'httpupgrade' are implemented."); + + if (Options.Security == VlessSecurity.Reality && string.IsNullOrEmpty(Options.RealityPublicKey)) + throw new NotSupportedException( + "VLESS REALITY needs the server's public key ('pbk' in the share link); this configuration has none."); + + if (!string.IsNullOrEmpty(Options.Flow) && !VlessHelper.IsVision(Options.Flow)) + throw new NotSupportedException( + $"VLESS flow '{Options.Flow}' is not supported; '{VisionStream.FlowName}' is the only XTLS flow implemented."); + + return transport; + } + + /// + /// Translates the share link's REALITY fields into handshake options. + /// + /// + /// The ALPN default matches what Xray's own client offers when a link names none. It is not + /// cosmetic: the value is covered by the ClientHello the server authenticates against, and a + /// list nobody else sends is one more way to stand out. + /// + private RealityTlsOptions BuildRealityOptions() => new() + { + ServerName = Options.Sni ?? Options.HostHeader ?? Options.Host, + PublicKey = DecodeBase64Url(Options.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, + EnabledSslProtocols = SslProtocols, + RemoteCertificateValidationCallback = ServerCertificateValidationCallback, + ApplicationProtocols = _alpn + }; + + // Built once per client from immutable options. Common ALPN ids map to the + // allocation-free static instances instead of encoding a fresh byte[] each time. + private static List? BuildAlpn(IReadOnlyList? alpn) + { + if (alpn is not { Count: > 0 }) + return null; + + var list = new List(alpn.Count); + for (int i = 0; i < alpn.Count; i++) + { + string p = alpn[i]; + list.Add(p switch + { + "h2" => SslApplicationProtocol.Http2, + "http/1.1" => SslApplicationProtocol.Http11, + "h3" => SslApplicationProtocol.Http3, + _ => new SslApplicationProtocol(p) + }); + } + return list; + } +} diff --git a/QuickProxyNet/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs new file mode 100644 index 0000000..1f9faae --- /dev/null +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -0,0 +1,303 @@ +using System.Buffers; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// Connects to a target host through a VMess proxy, speaking VMessAEAD +/// (alterId = 0) with an aes-128-gcm or chacha20-poly1305 body cipher, +/// optionally inside TLS. +/// +/// +/// +/// Unlike VLESS and Trojan, VMess encrypts the payload as well as the header, so +/// does not return the +/// transport — it returns a that seals everything written and +/// opens everything read. Closing that stream emits the authenticated empty chunk that +/// signals end-of-stream in band. +/// +/// +/// The tcp/raw, ws and httpupgrade transports are supported; +/// grpc and h2 are rejected with before +/// any bytes are written. +/// +/// +/// VMess is time-sensitive: the AuthID embeds the current UTC second and servers reject +/// anything more than ~120 s away from their own clock. +/// +/// +public sealed class VmessClient : ProxyClient +{ + /// + /// The option bitflags sent in the request header — S only + /// (). + /// + /// + /// This is deliberately not (0x1D + /// = S|M|P|A). implements the baseline body framing only: a + /// plain uint16 chunk length, no padding, no authenticated length. Announcing M + /// (chunk masking) would make the server XOR every length with a SHAKE128 keystream, + /// P would make it append random padding, and A would change the length encoding — + /// each of which desynchronizes the reader immediately. The option byte must describe + /// what this client can actually parse. + /// + internal const byte RequestOption = VmessRequest.OptionChunkStream; + + // Layout of the session scratch buffer: the two request values then the two derived + // response values, all 16 bytes. + private const int RequestKeyOffset = 0; + private const int RequestIvOffset = RequestKeyOffset + VmessRequest.BodyKeySize; + private const int ResponseKeyOffset = RequestIvOffset + VmessRequest.BodyKeySize; + private const int ResponseIvOffset = ResponseKeyOffset + VmessResponse.KeySize; + private const int SessionSize = ResponseIvOffset + VmessResponse.KeySize; + + private readonly List? _alpn; + + /// Creates a VMess client from strongly-typed options. + /// is null. + /// + /// The options carry an invalid UUID or a non-zero . + /// + public VmessClient(VmessOptions options) + : base("vmess", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) + { + // Validate up front so a bad configuration fails at construction rather than + // mid-connect (the share-link path already checked both, but a directly-built + // VmessOptions may not have). + // Must use the same rule as the wire encoder: Guid.TryParse alone would reject the + // short non-UUID ids that UuidCodec — and Xray — map to a derived UUID. + Span probe = stackalloc byte[UuidCodec.Size]; + if (!UuidCodec.TryWriteBigEndian(options.Id, probe)) + throw new ArgumentException( + $"VMess user id is unusable ({options.Id.Length} characters): it is neither a canonical UUID nor " + + "a string of 1..30 characters (which would be mapped to a UUID).", + nameof(options)); + + if (options.AlterId != 0) + throw new ArgumentException( + $"VMess alterId {options.AlterId} is not supported: only alterId 0 (VMessAEAD) is " + + "implemented, and a non-zero value selects the legacy MD5 authentication format.", + nameof(options)); + + Options = options; + _alpn = BuildAlpn(options.Alpn); + } + + /// Creates a VMess client by parsing a vmess:// share link. + /// The link is malformed. + public static VmessClient FromShareLink(string shareLink) => new(VmessShareLink.Parse(shareLink)); + + /// The parsed VMess configuration this client connects with. + public VmessOptions Options { get; } + + /// + public override ProxyType Type => ProxyType.Vmess; + + /// + /// Overrides validation of the proxy server's TLS certificate (only used when + /// is true). Ignored when + /// is true. + /// + public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; } + + /// TLS protocol versions offered to the proxy. Defaults to TLS 1.2 and 1.3. + public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls12 | SslProtocols.Tls13; + + /// + /// Performs the VMessAEAD handshake over and returns the + /// encrypted body stream for :. + /// + /// + /// Only the request header is written here. The server response header is verified + /// lazily on the first read (see ), because a real + /// VMess server does not flush it until the target produces data — reading it eagerly + /// would deadlock every client-speaks-first protocol. + /// + /// + /// The configured transport or body cipher is not supported. + /// + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + + // Reject unsupported transports and ciphers before writing any bytes or starting TLS. + VmessSecurity security = EnsureSupported(out TransportKind transportKind); + + // Each layer owns the one below it, so tracking the outermost stream is enough to + // unwind the whole stack on failure. + Stream layered = stream; + try + { + if (Options.UseTls) + { + // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. + var ssl = new SslStream(layered, leaveInnerStreamOpen: false); + layered = ssl; + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + } + + layered = await ProxyTransport.ApplyAsync( + transportKind, + layered, + Options.Path, + ProxyTransport.ResolveHostHeader(Options.HostHeader, Options.Sni, Options.Host), + cancellationToken).ConfigureAwait(false); + } + catch + { + await layered.DisposeAsync().ConfigureAwait(false); + throw; + } + + byte[] request = ArrayPool.Shared.Rent(VmessRequest.MaxRequestSize); + byte[] session = ArrayPool.Shared.Rent(SessionSize); + try + { + int length = BuildHandshake(request, session, security, host, port, out byte responseVerifier); + + await layered.WriteAsync(request.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + await layered.FlushAsync(cancellationToken).ConfigureAwait(false); + + return CreateBodyStream(layered, session, responseVerifier, security); + } + catch + { + // Owns the TLS session and the transport layer as well. A half-built + // VmessResponseStream holds no unmanaged state, so disposing the stack is + // enough to release everything. + await layered.DisposeAsync().ConfigureAwait(false); + throw; + } + finally + { + // Both buffers held key material. + ArrayPool.Shared.Return(request, clearArray: true); + ArrayPool.Shared.Return(session, clearArray: true); + } + } + + /// + /// Builds the sealed request header into and the four body + /// key/IV values into , returning the header length. + /// + /// + /// Kept synchronous because stackalloc and ref struct locals cannot live + /// across an await; the transient cmdKey and material scratch are zeroed here. + /// + private int BuildHandshake( + byte[] request, byte[] session, VmessSecurity security, string host, int port, + out byte responseVerifier) + { + Span cmdKey = stackalloc byte[VmessCmdKey.Size]; + Span scratch = stackalloc byte[VmessRequest.MaterialScratchSize]; + try + { + VmessCmdKey.Derive(Options.Id, cmdKey); + VmessRequestMaterial material = VmessRequest.CreateMaterial(scratch); + + int length = VmessRequest.Build( + request, cmdKey, material, RequestOption, (byte)security, VmessRequest.CommandTcp, host, port); + + material.BodyKey.CopyTo(session.AsSpan(RequestKeyOffset, VmessRequest.BodyKeySize)); + material.BodyIv.CopyTo(session.AsSpan(RequestIvOffset, VmessRequest.BodyKeySize)); + + // responseBodyKey/IV = SHA256(requestBodyKey/IV)[0:16]. + VmessResponse.DeriveBodyKeys( + material.BodyKey, + material.BodyIv, + session.AsSpan(ResponseKeyOffset, VmessResponse.KeySize), + session.AsSpan(ResponseIvOffset, VmessResponse.KeySize)); + + responseVerifier = material.ResponseVerifier; + return length; + } + finally + { + CryptographicOperations.ZeroMemory(cmdKey); + CryptographicOperations.ZeroMemory(scratch); + } + } + + /// + /// Layers the response-header reader and the body cipher over the transport: + /// transport → VmessResponseStream → VmessStream. + /// + private static Stream CreateBodyStream( + Stream transport, byte[] session, byte responseVerifier, VmessSecurity security) + { + var deferred = new VmessResponseStream( + transport, + session.AsSpan(ResponseKeyOffset, VmessResponse.KeySize), + session.AsSpan(ResponseIvOffset, VmessResponse.KeySize), + responseVerifier); + + // Both constructors copy the key material out of the session buffer, so the caller + // is free to clear and return it as soon as this returns. + return new VmessStream( + deferred, + session.AsSpan(RequestKeyOffset, VmessRequest.BodyKeySize), + session.AsSpan(RequestIvOffset, VmessRequest.BodyKeySize), + session.AsSpan(ResponseKeyOffset, VmessResponse.KeySize), + session.AsSpan(ResponseIvOffset, VmessResponse.KeySize), + security); + } + + /// + /// Validates everything that cannot be expressed in the type system, and resolves the + /// body cipher. Runs before any byte is written or any TLS handshake is started. + /// + private VmessSecurity EnsureSupported(out TransportKind transportKind) + { + transportKind = Options.TransportKind; + if (transportKind == TransportKind.Unsupported) + throw new NotSupportedException( + $"VMess transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + + "'httpupgrade' are implemented."); + + VmessSecurity security = Options.ResolveSecurity(); + + if (security == VmessSecurity.ChaCha20Poly1305 && !ChaCha20Poly1305.IsSupported) + throw new NotSupportedException( + "VMess security 'chacha20-poly1305' requires ChaCha20-Poly1305, which this platform " + + "does not provide. Use 'aes-128-gcm' instead."); + + return security; + } + + 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, + EnabledSslProtocols = SslProtocols, + RemoteCertificateValidationCallback = Options.AllowInsecure + ? static (_, _, _, _) => true + : ServerCertificateValidationCallback, + ApplicationProtocols = _alpn + }; + + // Built once per client from immutable options. Common ALPN ids map to the + // allocation-free static instances instead of encoding a fresh byte[] each time. + private static List? BuildAlpn(IReadOnlyList? alpn) + { + if (alpn is not { Count: > 0 }) + return null; + + var list = new List(alpn.Count); + for (int i = 0; i < alpn.Count; i++) + { + string p = alpn[i]; + list.Add(p switch + { + "h2" => SslApplicationProtocol.Http2, + "http/1.1" => SslApplicationProtocol.Http11, + "h3" => SslApplicationProtocol.Http3, + _ => new SslApplicationProtocol(p) + }); + } + return list; + } +} diff --git a/QuickProxyNet/Configs/TrojanOptions.cs b/QuickProxyNet/Configs/TrojanOptions.cs new file mode 100644 index 0000000..82e419c --- /dev/null +++ b/QuickProxyNet/Configs/TrojanOptions.cs @@ -0,0 +1,59 @@ +namespace QuickProxyNet; + +/// +/// Strongly-typed configuration for a Trojan outbound, produced by +/// or built directly. +/// +/// +/// Trojan is TLS-mandatory: the request header is written inside the TLS session. The +/// tcp/raw, ws and httpupgrade transports are supported at +/// connect time; others (grpc, …) are parsed so callers can inspect them, but +/// connecting with them throws . +/// +public sealed class TrojanOptions +{ + /// The Trojan password. Authenticated as hex(SHA224(password)). + public required string Password { get; init; } + + /// Proxy server host name or IP address. + public required string Host { get; init; } + + /// Proxy server port. + public required int Port { get; init; } + + /// + /// Transport network: tcp/raw (raw TCP), ws/websocket, or + /// httpupgrade. Others (grpc, …) are unsupported. + /// + public string Transport { get; init; } = "tcp"; + + /// + /// Request path for the ws/httpupgrade transports (path). Defaults to + /// /. Sent verbatim, including any query. + /// + public string? Path { get; init; } + + /// + /// Host header for the ws/httpupgrade transports (host). + /// Falls back to , then to . + /// + public string? HostHeader { get; init; } + + /// TLS server name (SNI). Falls back to when null. + public string? Sni { get; init; } + + /// ALPN protocol identifiers for the TLS handshake, if specified. + public IReadOnlyList? Alpn { get; init; } + + /// + /// When true, the proxy server's TLS certificate is accepted unconditionally + /// (allowInsecure). Use only against known servers with self-signed certs. + /// + public bool AllowInsecure { get; init; } + + /// Human-readable label from the share-link fragment (#name). + public string? Remark { get; init; } + + /// The resolved transport layer this configuration selects. + internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); +} diff --git a/QuickProxyNet/Configs/TrojanShareLink.cs b/QuickProxyNet/Configs/TrojanShareLink.cs new file mode 100644 index 0000000..c991391 --- /dev/null +++ b/QuickProxyNet/Configs/TrojanShareLink.cs @@ -0,0 +1,169 @@ +using System.Diagnostics.CodeAnalysis; + +namespace QuickProxyNet; + +/// +/// Parses trojan:// share links into . +/// +/// +/// Grammar: trojan://{password}@{host}:{port}?{query}#{remark}. The query is +/// scanned with a single-pass span parser (no NameValueCollection allocation); +/// only the recognized keys are materialized. Unknown keys are ignored. +/// +public static class TrojanShareLink +{ + /// + /// Parses a trojan:// share link. + /// + /// The link is malformed or missing the password. + public static TrojanOptions Parse(string shareLink) + { + if (!TryParse(shareLink, out var options, out var error)) + throw new FormatException(error); + return options; + } + + /// + /// Attempts to parse a trojan:// share link, returning + /// instead of throwing on malformed input. + /// + public static bool TryParse(string shareLink, [NotNullWhen(true)] out TrojanOptions? options) + => TryParse(shareLink, out options, out _); + + private static bool TryParse( + string shareLink, + [NotNullWhen(true)] out TrojanOptions? options, + [NotNullWhen(false)] out string? error) + { + options = null; + + if (string.IsNullOrWhiteSpace(shareLink)) + { + error = "Trojan share link is empty."; + return false; + } + + string trimmed = shareLink.Trim(); + if (!trimmed.StartsWith("trojan://", StringComparison.OrdinalIgnoreCase)) + { + error = "Trojan share link must start with 'trojan://'."; + return false; + } + + // Say what is actually wrong rather than blaming the scheme, which is plainly right. + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + error = + "Trojan share link is not a well-formed URI. Expected " + + "'trojan://{password}@{host}:{port}?{query}#{remark}'; check for stray " + + "characters in the host:port part."; + return false; + } + + string password = Uri.UnescapeDataString(uri.UserInfo); + if (password.Length == 0) + { + error = "Trojan share link is missing the password."; + return false; + } + + // Uri.Host keeps the brackets on an IPv6 literal ("[2001:db8::1]"), which would + // then fail to resolve at socket.ConnectAsync. Strip them so the raw address flows through. + string host = uri.Host; + if (host.Length > 1 && host[0] == '[' && host[^1] == ']') + host = host.Substring(1, host.Length - 2); + if (host.Length == 0) + { + error = "Trojan share link is missing the server host."; + return false; + } + + int port = uri.Port; + if (port <= 0 || port > 65535) + { + error = "Trojan share link is missing a valid server port."; + return false; + } + + // Defaults. + string transport = "tcp"; + string? sni = null, path = null, hostHeader = null; + IReadOnlyList? alpn = null; + bool allowInsecure = false; + + // Single-pass query scan. uri.Query includes a leading '?'. + ReadOnlySpan query = uri.Query; + if (query.Length > 1) + { + query = query.Slice(1); + 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("type", StringComparison.OrdinalIgnoreCase) || + key.Equals("network", StringComparison.OrdinalIgnoreCase)) + transport = rawVal.ToString(); + else if (key.Equals("sni", StringComparison.OrdinalIgnoreCase) || + key.Equals("serverName", StringComparison.OrdinalIgnoreCase) || + key.Equals("peer", StringComparison.OrdinalIgnoreCase)) + sni = Decode(rawVal); + else if (key.Equals("alpn", StringComparison.OrdinalIgnoreCase)) + alpn = ParseAlpn(rawVal); + else if (key.Equals("allowInsecure", StringComparison.OrdinalIgnoreCase) || + key.Equals("insecure", StringComparison.OrdinalIgnoreCase)) + allowInsecure = IsTruthy(rawVal); + else if (key.Equals("path", StringComparison.OrdinalIgnoreCase)) + path = Decode(rawVal); + else if (key.Equals("host", StringComparison.OrdinalIgnoreCase)) + hostHeader = Decode(rawVal); + } + } + + string? remark = uri.Fragment.Length > 1 + ? Uri.UnescapeDataString(uri.Fragment.Substring(1)) + : null; + + options = new TrojanOptions + { + Password = password, + Host = host, + Port = port, + Transport = transport, + Sni = sni, + Alpn = alpn, + Path = path, + HostHeader = hostHeader, + AllowInsecure = allowInsecure, + Remark = remark + }; + error = null; + return true; + } + + private static bool IsTruthy(ReadOnlySpan value) + => value.Equals("1", StringComparison.Ordinal) || + value.Equals("true", StringComparison.OrdinalIgnoreCase); + + private static string[] ParseAlpn(ReadOnlySpan value) + { + string decoded = Decode(value); + return decoded.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + 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/VlessOptions.cs b/QuickProxyNet/Configs/VlessOptions.cs new file mode 100644 index 0000000..e018b43 --- /dev/null +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -0,0 +1,89 @@ +namespace QuickProxyNet; + +/// +/// Transport security layer negotiated underneath the VLESS request header. +/// +public enum VlessSecurity +{ + /// Plain TCP, no encryption (security=none). + None, + + /// Standard TLS via (security=tls). + Tls, + + /// + /// REALITY transport security (security=reality), spoken by this library's own + /// TLS 1.3 client with no external process. Requires . + /// The ClientHello is not yet a browser fingerprint; see docs/reality-fingerprint-plan.md. + /// + Reality +} + +/// +/// Strongly-typed configuration for a VLESS outbound, produced by +/// or built directly. +/// +/// +/// Supported at connect time: the tcp/raw, ws and httpupgrade +/// transports with any of , or +/// , with or without xtls-rprx-vision in +/// . The rest (any other flow, the grpc/xhttp transports) is +/// parsed so callers can inspect it, but connecting throws +/// . +/// +public sealed class VlessOptions +{ + /// The VLESS user id — a canonical UUID. + public required string Id { get; init; } + + /// Proxy server host name or IP address. + public required string Host { get; init; } + + /// Proxy server port. + public required int Port { get; init; } + + /// Transport security layer. Defaults to . + public VlessSecurity Security { get; init; } = VlessSecurity.None; + + /// + /// Transport network: tcp/raw (raw TCP), ws/websocket, or + /// httpupgrade. Others (grpc, xhttp, h2) are unsupported. + /// + public string Transport { get; init; } = "tcp"; + + /// + /// Request path for the ws/httpupgrade transports (path). Defaults to + /// /. Sent verbatim, including any query such as ?ed=2048. + /// + public string? Path { get; init; } + + /// + /// Host header for the ws/httpupgrade transports (host). + /// Falls back to , then to . + /// + public string? HostHeader { get; init; } + + /// TLS/REALITY server name (SNI). Falls back to when null. + public string? Sni { get; init; } + + /// ALPN protocol identifiers for the TLS handshake, if specified. + public IReadOnlyList? Alpn { get; init; } + + /// XTLS flow control mode (e.g. xtls-rprx-vision). Empty/null for plain VLESS. + public string? Flow { get; init; } + + /// uTLS / browser fingerprint hint (fp), e.g. chrome. Advisory only. + public string? Fingerprint { get; init; } + + /// REALITY public key (pbk). Set only when is REALITY. + public string? RealityPublicKey { get; init; } + + /// REALITY short id (sid). + public string? RealityShortId { get; init; } + + /// Human-readable label from the share-link fragment (#name). + public string? Remark { get; init; } + + /// The resolved transport layer this configuration selects. + internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); +} diff --git a/QuickProxyNet/Configs/VlessShareLink.cs b/QuickProxyNet/Configs/VlessShareLink.cs new file mode 100644 index 0000000..b5e5b4b --- /dev/null +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -0,0 +1,214 @@ +using System.Diagnostics.CodeAnalysis; + +namespace QuickProxyNet; + +/// +/// Parses vless:// share links into . +/// +/// +/// Grammar: vless://{uuid}@{host}:{port}?{query}#{remark}. The query is scanned +/// with a single-pass span parser (no NameValueCollection allocation); only the +/// recognized keys are materialized. Unknown keys are ignored. +/// +public static class VlessShareLink +{ + /// + /// Parses a vless:// share link. + /// + /// The link is malformed or the id is not a valid UUID. + public static VlessOptions Parse(string shareLink) + { + if (!TryParse(shareLink, out var options, out var error)) + throw new FormatException(error); + return options; + } + + /// + /// Attempts to parse a vless:// share link, returning + /// instead of throwing on malformed input. + /// + public static bool TryParse(string shareLink, [NotNullWhen(true)] out VlessOptions? options) + => TryParse(shareLink, out options, out _); + + private static bool TryParse( + string shareLink, + [NotNullWhen(true)] out VlessOptions? options, + [NotNullWhen(false)] out string? error) + { + options = null; + + if (string.IsNullOrWhiteSpace(shareLink)) + { + error = "VLESS share link is empty."; + return false; + } + + string trimmed = shareLink.Trim(); + if (!trimmed.StartsWith("vless://", StringComparison.OrdinalIgnoreCase)) + { + error = "VLESS share link must start with 'vless://'."; + return false; + } + + // Report the real problem. Claiming the scheme is wrong when it plainly is not + // sends whoever reads the message hunting in the wrong place; in the wild these + // are links whose generator left "&key=value" in the authority before the '?', or + // bracketed a host that is not an IPv6 literal. + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + error = + "VLESS share link is not a well-formed URI. Expected " + + "'vless://{id}@{host}:{port}?{query}#{remark}'; check for stray characters " + + "in the host:port part (a '&' before the first '?' is the usual cause)."; + return false; + } + + string id = Uri.UnescapeDataString(uri.UserInfo); + if (id.Length == 0) + { + error = "VLESS share link is missing the user id."; + return false; + } + + Span probe = stackalloc byte[UuidCodec.Size]; + if (!UuidCodec.TryWriteBigEndian(id, probe)) + { + error = + $"VLESS user id is unusable ({id.Length} characters): it is neither a canonical UUID nor a " + + "string of 1..30 characters (which would be mapped to a UUID)."; + return false; + } + + // Uri.Host keeps the brackets on an IPv6 literal ("[2001:db8::1]"), which would + // then fail to resolve at socket.ConnectAsync. Strip them so the raw address flows through. + string host = uri.Host; + if (host.Length > 1 && host[0] == '[' && host[^1] == ']') + host = host.Substring(1, host.Length - 2); + if (host.Length == 0) + { + error = "VLESS share link is missing the server host."; + return false; + } + + int port = uri.Port; + if (port <= 0 || port > 65535) + { + error = "VLESS share link is missing a valid server port."; + return false; + } + + // Defaults. + var security = VlessSecurity.None; + string transport = "tcp"; + string? sni = null, flow = null, fp = null, pbk = null, sid = null, path = null, hostHeader = null; + IReadOnlyList? alpn = null; + + // Single-pass query scan. uri.Query includes a leading '?'. + ReadOnlySpan query = uri.Query; + if (query.Length > 1) + { + query = query.Slice(1); + 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("type", StringComparison.OrdinalIgnoreCase) || + key.Equals("network", StringComparison.OrdinalIgnoreCase)) + transport = rawVal.ToString(); + else if (key.Equals("security", StringComparison.OrdinalIgnoreCase)) + { + if (!TryParseSecurity(rawVal, out security)) + { + // Do NOT default an unknown value to None — that would silently send + // the VLESS header (with the UUID) in cleartext to a TLS/REALITY server. + error = + $"Unrecognized VLESS security '{rawVal.ToString()}': expected " + + "'none', 'tls' or 'reality'."; + return false; + } + } + else if (key.Equals("sni", StringComparison.OrdinalIgnoreCase) || + key.Equals("serverName", StringComparison.OrdinalIgnoreCase) || + key.Equals("peer", StringComparison.OrdinalIgnoreCase)) + sni = Decode(rawVal); + else if (key.Equals("alpn", StringComparison.OrdinalIgnoreCase)) + alpn = ParseAlpn(rawVal); + else if (key.Equals("flow", StringComparison.OrdinalIgnoreCase)) + flow = Decode(rawVal); + else if (key.Equals("fp", StringComparison.OrdinalIgnoreCase)) + fp = Decode(rawVal); + else if (key.Equals("pbk", StringComparison.OrdinalIgnoreCase)) + pbk = Decode(rawVal); + else if (key.Equals("sid", StringComparison.OrdinalIgnoreCase)) + sid = Decode(rawVal); + else if (key.Equals("path", StringComparison.OrdinalIgnoreCase)) + path = Decode(rawVal); + else if (key.Equals("host", StringComparison.OrdinalIgnoreCase)) + hostHeader = Decode(rawVal); + } + } + + string? remark = uri.Fragment.Length > 1 + ? Uri.UnescapeDataString(uri.Fragment.Substring(1)) + : null; + + options = new VlessOptions + { + Id = id, + Host = host, + Port = port, + Security = security, + Transport = transport, + Sni = sni, + Alpn = alpn, + Path = path, + HostHeader = hostHeader, + Flow = string.IsNullOrEmpty(flow) ? null : flow, + Fingerprint = fp, + RealityPublicKey = pbk, + RealityShortId = sid, + Remark = remark + }; + error = null; + return true; + } + + private static bool TryParseSecurity(ReadOnlySpan value, out VlessSecurity security) + { + if (value.IsEmpty || value.Equals("none", StringComparison.OrdinalIgnoreCase)) + security = VlessSecurity.None; + else if (value.Equals("tls", StringComparison.OrdinalIgnoreCase)) + security = VlessSecurity.Tls; + else if (value.Equals("reality", StringComparison.OrdinalIgnoreCase)) + security = VlessSecurity.Reality; + else + { + security = VlessSecurity.None; + return false; + } + return true; + } + + private static string[] ParseAlpn(ReadOnlySpan value) + { + string decoded = Decode(value); + return decoded.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + 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/VmessOptions.cs b/QuickProxyNet/Configs/VmessOptions.cs new file mode 100644 index 0000000..07322b1 --- /dev/null +++ b/QuickProxyNet/Configs/VmessOptions.cs @@ -0,0 +1,154 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// Body cipher requested for a VMess outbound — the share-link scy/security +/// field. +/// +/// +/// Only the two AEAD ciphers of modern VMessAEAD are offered. The legacy values +/// (aes-128-cfb), the unauthenticated ones (none, zero) and the +/// on-wire placeholder auto never reach the wire: is resolved to +/// a concrete cipher before the request header is serialized, exactly as v2ray does. +/// +public enum VmessSecurityKind +{ + /// + /// Let the client pick (auto). Resolved to on CPUs with + /// an AES instruction set and to otherwise. + /// + Auto, + + /// AES-128-GCM (aes-128-gcm, security type 3). + Aes128Gcm, + + /// ChaCha20-Poly1305 (chacha20-poly1305, security type 4). + ChaCha20Poly1305 +} + +/// +/// Strongly-typed configuration for a VMess outbound, produced by +/// or built directly. +/// +/// +/// +/// This is a VMessAEAD-only configuration: must be 0. +/// A non-zero alterId selects the legacy MD5-authenticated header format, which is +/// deliberately not implemented, so it is rejected rather than silently downgraded. +/// +/// +/// The tcp/raw, ws and httpupgrade transports are supported at +/// connect time, with or without TLS. Others (grpc, h2, …) are parsed so +/// callers can inspect them, but connecting with them throws +/// . +/// +/// +public sealed class VmessOptions +{ + /// The VMess user id — a canonical UUID. + public required string Id { get; init; } + + /// Proxy server host name or IP address (the share-link add field). + public required string Host { get; init; } + + /// Proxy server port. + public required int Port { get; init; } + + /// + /// Body cipher. Defaults to , which resolves to a + /// concrete AEAD at connect time. + /// + public VmessSecurityKind Security { get; init; } = VmessSecurityKind.Auto; + + /// + /// Legacy alterId. Must be 0: this implementation speaks VMessAEAD only, and a + /// non-zero value means the legacy MD5-authenticated format. + /// + public int AlterId { get; init; } + + /// + /// Transport network (the share-link net field): tcp/raw (raw TCP), + /// ws/websocket, or httpupgrade. Others are unsupported. + /// + public string Transport { get; init; } = "tcp"; + + /// + /// Request path for the ws/httpupgrade transports (the share-link + /// path field). Defaults to /. Sent verbatim, including any query. + /// + public string? Path { get; init; } + + /// + /// Host header for the ws/httpupgrade transports (the share-link + /// host field). Falls back to , then to . + /// + public string? HostHeader { get; init; } + + /// + /// When true the VMess session runs inside TLS (the share-link tls field). + /// + public bool UseTls { get; init; } + + /// TLS server name (SNI). Falls back to when null. + public string? Sni { get; init; } + + /// ALPN protocol identifiers for the TLS handshake, if specified. + public IReadOnlyList? Alpn { get; init; } + + /// + /// When true, the proxy server's TLS certificate is accepted unconditionally + /// (allowInsecure). Use only against known servers with self-signed certs. + /// + public bool AllowInsecure { get; init; } + + /// Human-readable label from the share-link ps field. + public string? Remark { get; init; } + + /// The resolved transport layer this configuration selects. + internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); + + /// + /// Maps onto the concrete body cipher written into the request + /// header's security nibble, resolving . + /// + /// + /// The platform provides neither AES-GCM nor ChaCha20-Poly1305. + /// + internal VmessSecurity ResolveSecurity() => Security switch + { + VmessSecurityKind.Aes128Gcm => VmessSecurity.Aes128Gcm, + VmessSecurityKind.ChaCha20Poly1305 => VmessSecurity.ChaCha20Poly1305, + _ => ResolveAuto() + }; + + /// + /// Resolves auto the way v2ray does: AES-128-GCM when the CPU can do AES in + /// hardware, ChaCha20-Poly1305 otherwise (it is the faster software cipher). + /// + /// + /// Both ciphers are equally interoperable — the server simply follows the security + /// nibble — so this is purely a local performance choice. AES-GCM is still preferred + /// over an unavailable ChaCha20-Poly1305, because + /// is false on + /// some platforms. + /// + private static VmessSecurity ResolveAuto() + { + if (AesGcm.IsSupported && HasHardwareAes) + return VmessSecurity.Aes128Gcm; + + if (ChaCha20Poly1305.IsSupported) + return VmessSecurity.ChaCha20Poly1305; + + if (AesGcm.IsSupported) + return VmessSecurity.Aes128Gcm; + + throw new NotSupportedException( + "VMess requires AES-128-GCM or ChaCha20-Poly1305, and this platform provides neither."); + } + + private static bool HasHardwareAes => + System.Runtime.Intrinsics.X86.Aes.IsSupported || + System.Runtime.Intrinsics.Arm.Aes.IsSupported; +} diff --git a/QuickProxyNet/Configs/VmessShareLink.cs b/QuickProxyNet/Configs/VmessShareLink.cs new file mode 100644 index 0000000..1769e93 --- /dev/null +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -0,0 +1,767 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; + +namespace QuickProxyNet; + +/// +/// Parses vmess:// share links into . +/// +/// +/// +/// Two grammars exist in the wild and both are accepted: +/// +/// +/// +/// v2rayN base64-JSONvmess:// followed by base64-encoded UTF-8 JSON. +/// Both the standard and the URL-safe alphabets are accepted, with or without padding, +/// embedded whitespace is ignored, and a #remark fragment appended after +/// the base64 is treated as the remark rather than as payload. Real-world links violate +/// all four rules. +/// +/// +/// Standard URIvmess://{uuid}@{host}:{port}?{query}#{remark}. Note that +/// the query keys carry different meanings from the JSON fields: type is the +/// transport, headerType the obfuscation, encryption the body cipher and +/// security the transport security. +/// +/// +/// +/// A payload containing @ selects the second grammar; that character occurs in +/// neither base64 alphabet, so the choice is unambiguous. +/// +/// +/// Recognized JSON fields: add, port, id, aid/alterId, +/// scy/security, net, type, tls, sni, +/// host, alpn, allowInsecure/skip-cert-verify and ps. +/// Numbers may be encoded as JSON numbers or as JSON strings; both are handled. Unknown +/// fields are ignored. +/// +/// +/// The parser fails loudly rather than silently downgrading: a non-zero +/// alterId (legacy MD5 authentication), an unrecognized scy, a +/// reality transport security, or a header-obfuscation type are all +/// rejected, because accepting them would produce a connection that cannot work — or, for +/// reality, one that leaks the request to a server expecting a different handshake. +/// +/// +public static class VmessShareLink +{ + private const string Scheme = "vmess://"; + + /// + /// Parses a vmess:// share link. + /// + /// + /// The link is malformed, the payload is not valid base64 JSON, a required field is + /// missing or invalid, or the configuration is outside VMessAEAD. + /// + public static VmessOptions Parse(string shareLink) + { + if (!TryParse(shareLink, out var options, out var error)) + throw new FormatException(error); + return options; + } + + /// + /// Attempts to parse a vmess:// share link, returning + /// instead of throwing on malformed input. + /// + public static bool TryParse(string shareLink, [NotNullWhen(true)] out VmessOptions? options) + => TryParse(shareLink, out options, out _); + + private static bool TryParse( + string shareLink, + [NotNullWhen(true)] out VmessOptions? options, + [NotNullWhen(false)] out string? error) + { + options = null; + + if (string.IsNullOrWhiteSpace(shareLink)) + { + error = "VMess share link is empty."; + return false; + } + + ReadOnlySpan link = shareLink.AsSpan().Trim(); + if (!link.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase)) + { + error = "VMess share link must start with 'vmess://'."; + return false; + } + + ReadOnlySpan payload = link[Scheme.Length..]; + if (payload.IsEmpty) + { + error = "VMess share link has no payload."; + 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) + 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)..]; + if (!fragment.IsEmpty) + fragmentRemark = Uri.UnescapeDataString(fragment.ToString()); + payload = payload[..hash]; + } + + if (payload.IsEmpty) + { + error = "VMess share link has no base64 payload."; + return false; + } + + byte[] json = ArrayPool.Shared.Rent((payload.Length + 3) / 4 * 3); + try + { + // Fast path: standard, correctly padded base64 (what v2rayN emits). Whitespace + // is tolerated by the BCL decoder, so only the URL-safe alphabet and missing + // padding need the normalization pass below. + if (!Convert.TryFromBase64Chars(payload, json, out int jsonLength) && + !TryDecodeRelaxed(payload, json, out jsonLength)) + { + error = "VMess share link payload is not valid base64."; + return false; + } + + return TryParseJson(json.AsMemory(0, jsonLength), fragmentRemark, out options, out error); + } + finally + { + ArrayPool.Shared.Return(json, clearArray: true); + } + } + + /// + /// Parses the standard URI grammar + /// vmess://{uuid}@{host}:{port}?{query}#{remark}. + /// + /// + /// The query keys do not mean the same thing as the JSON fields: here + /// type is the transport (what JSON calls net), + /// headerType is the header obfuscation (what JSON calls type), + /// encryption is the VMess body cipher (what JSON calls scy), and + /// security is the transport security (what JSON calls tls). Getting + /// this mapping backwards silently produces a client that negotiates the wrong cipher. + /// + private static bool TryParseStandardUri( + string shareLink, + [NotNullWhen(true)] out VmessOptions? options, + [NotNullWhen(false)] out string? error) + { + options = null; + + if (!Uri.TryCreate(shareLink, UriKind.Absolute, out var uri)) + { + error = + "VMess share link is not a well-formed URI. Expected either base64-encoded " + + "JSON or 'vmess://{id}@{host}:{port}?{query}#{remark}'."; + return false; + } + + string id = Uri.UnescapeDataString(uri.UserInfo); + if (id.Length == 0) + { + error = "VMess share link is missing the user id."; + return false; + } + + Span probe = stackalloc byte[UuidCodec.Size]; + if (!UuidCodec.TryWriteBigEndian(id, probe)) + { + error = + $"VMess user id is unusable ({id.Length} characters): it is neither a canonical UUID nor a " + + "string of 1..30 characters (which would be mapped to a UUID)."; + return false; + } + + // Uri.Host keeps the brackets on an IPv6 literal, which would then fail to resolve. + string host = uri.Host; + if (host.Length > 1 && host[0] == '[' && host[^1] == ']') + host = host.Substring(1, host.Length - 2); + if (host.Length == 0) + { + error = "VMess share link is missing the server address."; + return false; + } + + int port = uri.Port; + if (port <= 0 || port > 65535) + { + error = "VMess share link is missing a valid server port."; + return false; + } + + string? encryption = null, transportSecurity = null, headerType = null; + string transport = "tcp"; + string? sni = null, transportHost = null, path = null; + string[]? alpn = null; + bool allowInsecure = false; + + ReadOnlySpan query = uri.Query; + if (query.Length > 1) + { + query = query[1..]; + while (!query.IsEmpty) + { + int amp = query.IndexOf('&'); + ReadOnlySpan pair = amp < 0 ? query : query[..amp]; + query = amp < 0 ? default : query[(amp + 1)..]; + + int eq = pair.IndexOf('='); + if (eq < 0) + continue; + + ReadOnlySpan key = ShareLinkQuery.StripHtmlAmpPrefix(pair[..eq]); + ReadOnlySpan rawVal = pair[(eq + 1)..]; + if (rawVal.IsEmpty) + continue; + + if (key.Equals("type", StringComparison.OrdinalIgnoreCase) || + key.Equals("network", StringComparison.OrdinalIgnoreCase)) + transport = Decode(rawVal); + else if (key.Equals("encryption", StringComparison.OrdinalIgnoreCase)) + encryption = Decode(rawVal); + else if (key.Equals("security", StringComparison.OrdinalIgnoreCase)) + transportSecurity = Decode(rawVal); + else if (key.Equals("headerType", StringComparison.OrdinalIgnoreCase)) + headerType = Decode(rawVal); + else if (key.Equals("sni", StringComparison.OrdinalIgnoreCase) || + key.Equals("serverName", StringComparison.OrdinalIgnoreCase) || + key.Equals("peer", StringComparison.OrdinalIgnoreCase)) + sni = Decode(rawVal); + else if (key.Equals("host", StringComparison.OrdinalIgnoreCase)) + transportHost = Decode(rawVal); + else if (key.Equals("path", StringComparison.OrdinalIgnoreCase)) + path = Decode(rawVal); + else if (key.Equals("alpn", StringComparison.OrdinalIgnoreCase)) + alpn = SplitAlpn(Decode(rawVal)); + else if (key.Equals("allowInsecure", StringComparison.OrdinalIgnoreCase) || + key.Equals("insecure", StringComparison.OrdinalIgnoreCase) || + key.Equals("skip-cert-verify", StringComparison.OrdinalIgnoreCase)) + allowInsecure = IsTruthy(Decode(rawVal)); + } + } + + // 'security' means two different things in the wild, and which one is meant can be + // recovered from the value instead of guessed. The URI grammar defines it as the + // transport security (what the JSON form calls 'tls'), and links pairing it with a + // VLESS-style 'encryption=none' do use it that way. But 611 links — 27% of every + // vmess link in a 17k real-world corpus — put the *body cipher* there instead, which + // the JSON form calls 'scy'. The two value sets are disjoint apart from 'none': + // + // auto | aes-128-gcm | chacha20-poly1305 -> body cipher + // tls | reality | none -> transport security + // + // 'none' stays transport security. That is its documented meaning, and both readings + // agree the connection is not TLS, so nothing is downgraded by keeping it. + // + // Reading 'security=auto' as "no TLS" cannot leak a credential the way the VLESS + // downgrade did: VMessAEAD seals the request header under a key derived from the id, + // so the id never reaches the wire in cleartext. A wrong guess costs a failed + // handshake — the server cannot parse a plaintext header and drops the connection. + if (LooksLikeBodyCipher(transportSecurity)) + { + encryption ??= transportSecurity; + transportSecurity = null; + } + + // VMess has no 'encryption' key of its own; producers copy it from the VLESS grammar, + // where 'encryption=none' is mandatory boilerplate. Treat it as unspecified rather + // than as a request for VMess's unencrypted body mode, which no real server runs. + if (encryption is not null && encryption.Equals("none", StringComparison.OrdinalIgnoreCase)) + encryption = null; + + if (!TryParseSecurity(encryption, out VmessSecurityKind security)) + { + error = UnsupportedSecurityMessage(encryption); + return false; + } + + if (!TryValidateHeaderType(headerType, transport, out error)) + return false; + + bool useTls = false; + if (!string.IsNullOrEmpty(transportSecurity) && + !transportSecurity.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + if (transportSecurity.Equals("reality", StringComparison.OrdinalIgnoreCase)) + { + error = "VMess over REALITY is not supported: it requires a uTLS ClientHello fingerprint."; + return false; + } + + if (!transportSecurity.Equals("tls", StringComparison.OrdinalIgnoreCase)) + { + // Never default an unknown value to plaintext — that would send the sealed + // request header to a server expecting TLS. + error = + $"Unrecognized VMess transport security '{transportSecurity}': expected " + + "'none' or 'tls'."; + return false; + } + + useTls = true; + } + + if (string.IsNullOrEmpty(sni)) + sni = transportHost; + if (string.IsNullOrEmpty(sni)) + sni = host; + + options = new VmessOptions + { + Id = id, + Host = host, + Port = port, + Security = security, + AlterId = 0, + Transport = transport, + UseTls = useTls, + Sni = sni, + Alpn = alpn, + Path = path, + HostHeader = transportHost, + AllowInsecure = allowInsecure, + Remark = uri.Fragment.Length > 1 + ? Uri.UnescapeDataString(uri.Fragment[1..]) + : null + }; + error = null; + return true; + } + + /// + /// Validates the header-obfuscation field, which is only meaningful on the raw TCP + /// transport. Every real client ignores it on ws, httpupgrade and + /// grpc, where producers routinely leave junk in it, so rejecting it there would + /// reject otherwise-valid links. + /// + private static bool TryValidateHeaderType( + string? headerType, string transport, [NotNullWhen(false)] out string? error) + { + error = null; + + if (string.IsNullOrEmpty(headerType) || + headerType.Equals("none", StringComparison.OrdinalIgnoreCase)) + return true; + + // "raw" is Xray's current name for the plain TCP transport. + bool isTcp = transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || + transport.Equals("raw", StringComparison.OrdinalIgnoreCase); + if (!isTcp) + return true; + + error = + $"VMess header obfuscation type '{headerType}' is not supported on the " + + $"'{transport}' transport; only 'none' is."; + return false; + } + + private static string UnsupportedSecurityMessage(string? value) => + $"Unrecognized VMess security '{value}': only 'auto', 'aes-128-gcm' and " + + "'chacha20-poly1305' are supported."; + + private static string[]? SplitAlpn(string value) + { + string[] parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length == 0 ? null : parts; + } + + 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. + /// + 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); + try + { + if (!TryNormalizeBase64(payload, chars, out int charCount)) + { + length = 0; + return false; + } + + return Convert.TryFromBase64Chars(chars.AsSpan(0, charCount), destination, out length); + } + finally + { + 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, + [NotNullWhen(true)] out VmessOptions? options, + [NotNullWhen(false)] out string? error) + { + options = null; + + JsonDocument document; + try + { + document = JsonDocument.Parse(utf8Json); + } + catch (JsonException) + { + error = "VMess share link payload is not valid JSON."; + return false; + } + + using (document) + { + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + error = "VMess share link payload must be a JSON object."; + return false; + } + + // Single pass over the object. JsonElement.TryGetProperty rescans the whole + // document (and re-encodes the name to UTF-8) on every call, so the recognized + // fields are captured once here instead. NameEquals is given UTF-8 literals so + // each key is compared as raw bytes — no transcoding, no string materialized. + // Unrecognized fields are ignored. + JsonElement idField = default, addField = default, portField = default; + JsonElement aidField = default, alterIdField = default; + JsonElement scyField = default, securityField = default; + JsonElement netField = default, typeField = default, tlsField = default; + JsonElement sniField = default, hostField = default, pathField = default; + JsonElement alpnField = default, psField = default; + JsonElement allowInsecureField = default, skipCertVerifyField = default; + + foreach (JsonProperty property in root.EnumerateObject()) + { + if (property.NameEquals("id"u8)) idField = property.Value; + else if (property.NameEquals("add"u8)) addField = property.Value; + else if (property.NameEquals("port"u8)) portField = property.Value; + else if (property.NameEquals("aid"u8)) aidField = property.Value; + else if (property.NameEquals("alterId"u8)) alterIdField = property.Value; + else if (property.NameEquals("scy"u8)) scyField = property.Value; + else if (property.NameEquals("security"u8)) securityField = property.Value; + else if (property.NameEquals("net"u8)) netField = property.Value; + else if (property.NameEquals("type"u8)) typeField = property.Value; + else if (property.NameEquals("tls"u8)) tlsField = property.Value; + else if (property.NameEquals("sni"u8)) sniField = property.Value; + else if (property.NameEquals("host"u8)) hostField = property.Value; + else if (property.NameEquals("path"u8)) pathField = property.Value; + else if (property.NameEquals("alpn"u8)) alpnField = property.Value; + else if (property.NameEquals("ps"u8)) psField = property.Value; + else if (property.NameEquals("allowInsecure"u8)) allowInsecureField = property.Value; + else if (property.NameEquals("skip-cert-verify"u8)) skipCertVerifyField = property.Value; + } + + // ---- id: a canonical UUID is mandatory ---- + string? id = GetString(idField); + if (string.IsNullOrEmpty(id)) + { + error = "VMess share link is missing the user id."; + return false; + } + + Span probe = stackalloc byte[UuidCodec.Size]; + if (!UuidCodec.TryWriteBigEndian(id, probe)) + { + error = + $"VMess user id is unusable ({id.Length} characters): it is neither a canonical UUID nor a " + + "string of 1..30 characters (which would be mapped to a UUID)."; + return false; + } + + // ---- add / port ---- + string? host = GetString(addField); + if (string.IsNullOrEmpty(host)) + { + error = "VMess share link is missing the server address."; + return false; + } + + // Uri-style IPv6 literals keep their brackets, which would then fail to resolve + // at socket.ConnectAsync. Strip them so the raw address flows through. + if (host.Length > 1 && host[0] == '[' && host[^1] == ']') + host = host.Substring(1, host.Length - 2); + if (host.Length == 0) + { + error = "VMess share link is missing the server address."; + return false; + } + + if (GetInt32(portField, out int port) != FieldState.Ok || port <= 0 || port > 65535) + { + error = "VMess share link is missing a valid server port."; + return false; + } + + // ---- alterId: AEAD only ---- + FieldState alterState = GetInt32(aidField, out int alterId); + if (alterState == FieldState.Missing) + alterState = GetInt32(alterIdField, out alterId); + + if (alterState == FieldState.Invalid) + { + error = "VMess share link has an invalid 'aid' (alterId) value."; + return false; + } + + if (alterId != 0) + { + // Never pretend: alterId > 0 selects the legacy MD5-authenticated header, + // which this implementation does not speak at all. + error = + $"VMess alterId {alterId} is not supported: only alterId 0 (VMessAEAD) is " + + "implemented, and a non-zero value selects the legacy MD5 authentication format."; + return false; + } + + // ---- scy / security ---- + string? scy = GetString(scyField) ?? GetString(securityField); + if (!TryParseSecurity(scy, out VmessSecurityKind security)) + { + error = UnsupportedSecurityMessage(scy); + return false; + } + + // ---- net / type ---- + // 'net' is the transport; 'type' is header obfuscation (e.g. "http"), which + // only applies to the raw TCP transport. + string? net = GetString(netField); + string transport = string.IsNullOrEmpty(net) ? "tcp" : net; + + if (!TryValidateHeaderType(GetString(typeField), transport, out error)) + return false; + + // ---- tls ---- + string? tls = GetString(tlsField); + bool useTls = false; + if (!string.IsNullOrEmpty(tls) && !tls.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + if (tls.Equals("reality", StringComparison.OrdinalIgnoreCase)) + { + // Connecting with a plain SslStream would send the sealed request header + // to a server expecting a uTLS ClientHello fingerprint. Fail instead. + error = "VMess over REALITY is not supported: it requires a uTLS ClientHello fingerprint."; + return false; + } + + useTls = true; + } + + // ---- sni: explicit, else the transport 'host' header, else the server address ---- + string? sni = GetString(sniField); + if (string.IsNullOrEmpty(sni)) + sni = GetString(hostField); + if (string.IsNullOrEmpty(sni)) + sni = host; + + options = new VmessOptions + { + Id = id, + Host = host, + Port = port, + Security = security, + AlterId = 0, + Transport = transport, + UseTls = useTls, + Sni = sni, + Alpn = GetAlpn(alpnField), + 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 + }; + error = null; + return true; + } + } + + /// + /// True when a value found in the URI's security key is unambiguously a body + /// cipher rather than a transport security mode. none is excluded on purpose: it + /// is valid in both vocabularies. + /// + private static bool LooksLikeBodyCipher(string? value) => + value is not null && + (value.Equals("auto", StringComparison.OrdinalIgnoreCase) || + value.Equals("aes-128-gcm", StringComparison.OrdinalIgnoreCase) || + value.Equals("chacha20-poly1305", StringComparison.OrdinalIgnoreCase)); + + private static bool TryParseSecurity(string? value, out VmessSecurityKind security) + { + if (string.IsNullOrEmpty(value) || value.Equals("auto", StringComparison.OrdinalIgnoreCase)) + security = VmessSecurityKind.Auto; + else if (value.Equals("aes-128-gcm", StringComparison.OrdinalIgnoreCase)) + security = VmessSecurityKind.Aes128Gcm; + else if (value.Equals("chacha20-poly1305", StringComparison.OrdinalIgnoreCase)) + security = VmessSecurityKind.ChaCha20Poly1305; + else + { + // 'none', 'zero' and 'aes-128-cfb' are deliberately rejected rather than + // defaulted: they would silently change how the body is protected. + security = VmessSecurityKind.Auto; + return false; + } + + return true; + } + + // ================================ JSON helpers ================================ + + private enum FieldState + { + /// The property is absent, null, or an empty string. + Missing, + + /// The property is present but cannot be read as the requested type. + Invalid, + + /// The property was read successfully. + Ok + } + + /// + /// Reads a string value. JSON numbers are accepted and returned verbatim, because + /// producers disagree about whether e.g. aid is a string or a number. An absent + /// field (default(JsonElement), i.e. ) + /// yields . + /// + private static string? GetString(JsonElement element) + => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => null + }; + + /// + /// Reads an integer value encoded either as a JSON number or as a JSON string. + /// + private static FieldState GetInt32(JsonElement element, out int value) + { + value = 0; + + switch (element.ValueKind) + { + case JsonValueKind.Number: + return element.TryGetInt32(out value) ? FieldState.Ok : FieldState.Invalid; + + case JsonValueKind.String: + string? text = element.GetString(); + if (string.IsNullOrWhiteSpace(text)) + return FieldState.Missing; + return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) + ? FieldState.Ok + : FieldState.Invalid; + + case JsonValueKind.Null or JsonValueKind.Undefined: + return FieldState.Missing; + + default: + return FieldState.Invalid; + } + } + + private static bool GetBoolean(JsonElement element) + => element.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.Number => element.TryGetInt32(out int n) && n != 0, + JsonValueKind.String => IsTruthy(element.GetString()), + _ => false + }; + + private static bool IsTruthy(string? value) => + value is not null && + (value.Equals("1", StringComparison.Ordinal) || + value.Equals("true", StringComparison.OrdinalIgnoreCase)); + + /// + /// Reads alpn, which producers encode either as a comma-separated string or as + /// a JSON array of strings. + /// + private static string[]? GetAlpn(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Array) + { + var list = new List(element.GetArrayLength()); + foreach (JsonElement item in element.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + continue; + + string? value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + list.Add(value.Trim()); + } + + return list.Count == 0 ? null : list.ToArray(); + } + + if (element.ValueKind != JsonValueKind.String) + return null; + + string? raw = element.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + string[] parts = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length == 0 ? null : parts; + } +} diff --git a/QuickProxyNet/Internal/Crypto/Crc32.cs b/QuickProxyNet/Internal/Crypto/Crc32.cs new file mode 100644 index 0000000..35ae9f5 --- /dev/null +++ b/QuickProxyNet/Internal/Crypto/Crc32.cs @@ -0,0 +1,50 @@ +using System.Buffers.Binary; + +namespace QuickProxyNet; + +/// +/// CRC-32/IEEE (the zlib / PKZIP CRC, reflected polynomial 0xEDB88320, init +/// 0xFFFFFFFF, input and output reflected, final XOR 0xFFFFFFFF) — the +/// value produced by Go's crc32.ChecksumIEEE. VMess uses it in the AuthID +/// plaintext and serialises the result big-endian. +/// +internal static class Crc32 +{ + private const uint ReflectedPolynomial = 0xEDB88320u; + + // 256-entry lookup table, computed once at first use. + private static readonly uint[] Table = BuildTable(); + + private static uint[] BuildTable() + { + var table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint crc = i; + for (int bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (ReflectedPolynomial & (uint)(-(int)(crc & 1))); + + table[i] = crc; + } + + return table; + } + + /// Computes the CRC-32/IEEE checksum of . + public static uint Compute(ReadOnlySpan data) + { + uint crc = 0xFFFFFFFFu; + foreach (byte b in data) + crc = (crc >> 8) ^ Table[(crc ^ b) & 0xFF]; + + return crc ^ 0xFFFFFFFFu; + } + + /// + /// Computes the CRC-32/IEEE checksum of and writes it + /// big-endian (as VMess emits it) into . + /// + /// is shorter than 4 bytes. + public static void WriteBigEndian(ReadOnlySpan data, Span destination) + => BinaryPrimitives.WriteUInt32BigEndian(destination, Compute(data)); +} diff --git a/QuickProxyNet/Internal/Crypto/Fnv1a32.cs b/QuickProxyNet/Internal/Crypto/Fnv1a32.cs new file mode 100644 index 0000000..4b0e070 --- /dev/null +++ b/QuickProxyNet/Internal/Crypto/Fnv1a32.cs @@ -0,0 +1,38 @@ +using System.Buffers.Binary; + +namespace QuickProxyNet; + +/// +/// FNV-1a 32-bit hash. VMess uses it as the inner integrity checksum of the request +/// command section (fnv.New32a in encoding/client.go) and serialises the +/// result big-endian. +/// +internal static class Fnv1a32 +{ + /// FNV-1a 32-bit offset basis (2166136261). + public const uint OffsetBasis = 2166136261u; + + /// FNV-1a 32-bit prime (16777619). + public const uint Prime = 16777619u; + + /// Computes the FNV-1a-32 hash of (xor then multiply). + public static uint Compute(ReadOnlySpan data) + { + uint hash = OffsetBasis; + foreach (byte b in data) + { + hash ^= b; + hash *= Prime; + } + + return hash; + } + + /// + /// Computes the FNV-1a-32 hash of and writes it big-endian + /// (network order, as VMess emits it) into . + /// + /// is shorter than 4 bytes. + public static void WriteBigEndian(ReadOnlySpan data, Span destination) + => BinaryPrimitives.WriteUInt32BigEndian(destination, Compute(data)); +} diff --git a/QuickProxyNet/Internal/Crypto/Sha224.cs b/QuickProxyNet/Internal/Crypto/Sha224.cs new file mode 100644 index 0000000..769b79f --- /dev/null +++ b/QuickProxyNet/Internal/Crypto/Sha224.cs @@ -0,0 +1,73 @@ +using System.Runtime.Intrinsics; + +namespace QuickProxyNet; + +/// +/// Self-contained SHA-224 (FIPS 180-4) over a single contiguous input. The BCL has no +/// SHA-224, but the Trojan protocol authenticates with hex(SHA224(password)). +/// SHA-224 is SHA-256 with different initial hash values and the digest truncated to +/// the first 28 bytes, so everything but the IV and the output length comes from +/// . Allocation-free: state, schedule and padding are stack-allocated. +/// +internal static class Sha224 +{ + /// Digest size in bytes (224 bits). + public const int HashSize = 28; + + /// Digest size in lowercase-hex ASCII bytes. + public const int HexSize = HashSize * 2; + + /// + /// Computes SHA224() and writes the 28-byte digest into + /// . + /// + /// + /// is shorter than 28 bytes. + /// + public static void ComputeHash(ReadOnlySpan data, Span destination) + => ComputeHashCore(data, destination, Vector128.IsHardwareAccelerated); + + /// + /// Scalar-only variant of . Exists so tests can verify the + /// fallback path on hardware where the vector path would normally be selected, and + /// so benchmarks can compare the two. + /// + internal static void ComputeHashScalar(ReadOnlySpan data, Span destination) + => ComputeHashCore(data, destination, vectorize: false); + + /// + /// Computes SHA224() and writes the digest as exactly 56 + /// lowercase-hex ASCII bytes into . Used by the + /// Trojan request builder to emit the auth prefix directly into a wire buffer. + /// + /// + /// is shorter than 56 bytes. + /// + public static void WriteHexLower(ReadOnlySpan data, Span destinationAscii) + { + if (destinationAscii.Length < HexSize) + throw new ArgumentException( + $"Destination must be at least {HexSize} bytes.", nameof(destinationAscii)); + + Span digest = stackalloc byte[HashSize]; + ComputeHash(data, digest); + + for (int i = 0; i < HashSize; i++) + { + destinationAscii[2 * i] = HexDigit(digest[i] >> 4); + destinationAscii[2 * i + 1] = HexDigit(digest[i] & 0xF); + } + } + + private static byte HexDigit(int nibble) + => (byte)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); + + private static void ComputeHashCore(ReadOnlySpan data, Span destination, bool vectorize) + { + if (destination.Length < HashSize) + throw new ArgumentException( + $"Destination must be at least {HashSize} bytes.", nameof(destination)); + + Sha256Core.ComputeHash(Sha256Core.Sha224Iv, data, destination, HashSize, vectorize); + } +} diff --git a/QuickProxyNet/Internal/Crypto/Sha256.cs b/QuickProxyNet/Internal/Crypto/Sha256.cs new file mode 100644 index 0000000..97d1ea1 --- /dev/null +++ b/QuickProxyNet/Internal/Crypto/Sha256.cs @@ -0,0 +1,46 @@ +using System.Runtime.Intrinsics; + +namespace QuickProxyNet; + +/// +/// Self-contained SHA-256 (FIPS 180-4) over a single contiguous input, sharing +/// with . +/// +/// +/// This is not a replacement for : +/// for a plain hash the BCL delegates to the OS, which is roughly 1.6x faster per block. It +/// exists so the shared core can be cross-checked against an independent implementation — +/// the BCL has no SHA-224, so alone cannot be diffed against anything +/// the runtime ships. +/// +internal static class Sha256 +{ + /// Digest size in bytes (256 bits). + public const int HashSize = Sha256Core.DigestSize; + + /// + /// Computes SHA256() and writes the 32-byte digest into + /// . + /// + /// + /// is shorter than 32 bytes. + /// + public static void ComputeHash(ReadOnlySpan data, Span destination) + => ComputeHashCore(data, destination, Vector128.IsHardwareAccelerated); + + /// + /// Scalar-only variant of , so tests can exercise the fallback + /// schedule on hardware where the vector path would normally be selected. + /// + internal static void ComputeHashScalar(ReadOnlySpan data, Span destination) + => ComputeHashCore(data, destination, vectorize: false); + + private static void ComputeHashCore(ReadOnlySpan data, Span destination, bool vectorize) + { + if (destination.Length < HashSize) + throw new ArgumentException( + $"Destination must be at least {HashSize} bytes.", nameof(destination)); + + Sha256Core.ComputeHash(Sha256Core.Sha256Iv, data, destination, HashSize, vectorize); + } +} diff --git a/QuickProxyNet/Internal/Crypto/Sha256Core.cs b/QuickProxyNet/Internal/Crypto/Sha256Core.cs new file mode 100644 index 0000000..7e9cd2d --- /dev/null +++ b/QuickProxyNet/Internal/Crypto/Sha256Core.cs @@ -0,0 +1,295 @@ +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; + +namespace QuickProxyNet; + +/// +/// The shared FIPS 180-4 SHA-2/32 engine behind and . +/// SHA-224 and SHA-256 differ only in their initial hash values and in how much of the final +/// state is emitted, so the block compression, the message schedule and the padding rules live +/// here exactly once. +/// +/// +/// +/// Unlike the BCL hash types this exposes the state: a caller can absorb some blocks, +/// keep the eight-word midstate, and later resume from a copy of it, which no platform hash +/// API allows. Nothing in the library needs that today — the one candidate, a VMessAEAD KDF +/// built on precomputed HMAC ipad/opad midstates, was measured and lost to the platform HMAC +/// (see the remarks on VmessKdf). The surface is kept, and tested against the BCL, +/// because it is what lets MidstateVmessKdf in the benchmark project reproduce that +/// comparison on other hardware. +/// +/// +/// .NET exposes no x86 SHA-NI intrinsics (dotnet/runtime#256 is unimplemented), and the +/// dedicated ARM64 Sha256 intrinsics cannot be exercised on x64 CI. When +/// is hardware accelerated (SSE2 / AdvSimd) the message schedule is +/// expanded four words at a time; the compression rounds are inherently serial and stay +/// scalar, but they are unrolled eight at a time so the eight-way state rotation is +/// expressed by renaming registers instead of by moving them. A pure scalar path always +/// exists and is used on any CPU without acceleration. +/// +/// +internal static class Sha256Core +{ + /// Compression block size in bytes. + internal const int BlockSize = 64; + + /// Number of 32-bit words in the chaining state. + internal const int StateWords = 8; + + /// Full (untruncated) digest size in bytes. + internal const int DigestSize = 32; + + /// Number of 32-bit words in one expanded message schedule. + internal const int ScheduleWords = 64; + + /// SHA-256 initial hash values (fractional parts of the square roots of the first eight primes). + internal static ReadOnlySpan Sha256Iv => + [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + ]; + + /// SHA-224 initial hash values (second 32 bits of the square roots of the 9th..16th primes). + internal static ReadOnlySpan Sha224Iv => + [ + 0xc1059ed8, 0x367cd507, 0x3070dd17, 0xf70e5939, + 0xffc00b31, 0x68581511, 0x64f98fa7, 0xbefa4fa4 + ]; + + // SHA-256 round constants (fractional parts of cube roots of the first 64 primes). + private static ReadOnlySpan K => + [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + + /// + /// One-shot hash over a single contiguous input. + /// + /// Initial hash values: or . + /// The complete message. + /// Receives bytes of digest. + /// Digest bytes to emit; must be a multiple of four and at most 32. + /// Whether the schedule path may be used. + internal static void ComputeHash( + ReadOnlySpan iv, ReadOnlySpan data, Span destination, int outputBytes, bool vectorize) + { + Span state = stackalloc uint[StateWords]; + iv.CopyTo(state); + + Span schedule = stackalloc uint[ScheduleWords]; + Finish(state, data, (ulong)data.Length, schedule, vectorize); + WriteDigest(state, destination, outputBytes); + } + + /// + /// Absorbs into . The length must be a + /// whole multiple of ; any remainder is silently ignored, so this + /// is only for the "complete blocks" part of a message. + /// + internal static void Absorb(Span state, ReadOnlySpan blocks, Span schedule, bool vectorize) + { + while (blocks.Length >= BlockSize) + { + ProcessBlock(blocks, state, schedule, vectorize); + blocks = blocks.Slice(BlockSize); + } + } + + /// + /// Absorbs (any length) and then the FIPS 180-4 padding for a + /// message of bytes in total — which includes everything + /// already folded into before this call. After this the state + /// holds the final chaining value. + /// + internal static void Finish( + Span state, ReadOnlySpan tail, ulong totalBytes, Span schedule, bool vectorize) + { + while (tail.Length >= BlockSize) + { + ProcessBlock(tail, state, schedule, vectorize); + tail = tail.Slice(BlockSize); + } + + // Final block(s): tail + 0x80 + zero pad + 64-bit big-endian bit length. Fits in one + // block when the tail is <= 55 bytes, otherwise spills into a second block. Only the + // bytes after the tail are cleared — clearing all 128 doubled the memset on the KDF's + // hottest call shape, where the tail is a 32-byte digest. + int padded = tail.Length + 1 + 8 <= BlockSize ? BlockSize : 2 * BlockSize; + + Span pad = stackalloc byte[2 * BlockSize]; + tail.CopyTo(pad); + pad.Slice(tail.Length, padded - tail.Length).Clear(); + pad[tail.Length] = 0x80; + + BinaryPrimitives.WriteUInt64BigEndian(pad.Slice(padded - 8), totalBytes * 8); + + ProcessBlock(pad, state, schedule, vectorize); + if (padded == 2 * BlockSize) + ProcessBlock(pad.Slice(BlockSize), state, schedule, vectorize); + } + + /// + /// Serializes the first bytes of the state big-endian. + /// SHA-224 keeps seven words, SHA-256 all eight. + /// + internal static void WriteDigest(ReadOnlySpan state, Span destination, int outputBytes) + { + for (int i = 0; i < outputBytes / 4; i++) + BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(i * 4), state[i]); + } + + private static void ProcessBlock(ReadOnlySpan block, Span state, Span w, bool vectorize) + { + for (int i = 0; i < 16; i++) + w[i] = BinaryPrimitives.ReadUInt32BigEndian(block.Slice(i * 4)); + + if (vectorize && Vector128.IsHardwareAccelerated) + ExpandScheduleVector128(w); + else + ExpandScheduleScalar(w); + + uint a = state[0], b = state[1], c = state[2], d = state[3]; + uint e = state[4], f = state[5], g = state[6], h = state[7]; + + // Folding K[i] into w[i] in a separate pass before the rounds was tried and did not + // pay: it left the vectorized path unchanged and made the scalar path measurably + // worse, so the constant load stays inside the round. + ReadOnlySpan constants = K; + ref uint kp = ref MemoryMarshal.GetReference(constants); + ref uint wp = ref MemoryMarshal.GetReference(w); + + // Eight rounds per iteration. A SHA-256 round shifts the whole state by one slot; + // doing eight at a time lets the shift be expressed by which variable each round + // reads, so none of the 8 x 64 register moves of the naive loop are emitted. After + // the eighth round the names line up with the state again. + for (int i = 0; i < 64; i += 8) + { + uint t1, t2; + + t1 = h + Sum1(e) + Ch(e, f, g) + Unsafe.Add(ref kp, i) + Unsafe.Add(ref wp, i); + t2 = Sum0(a) + Maj(a, b, c); + d += t1; + h = t1 + t2; + + t1 = g + Sum1(d) + Ch(d, e, f) + Unsafe.Add(ref kp, i + 1) + Unsafe.Add(ref wp, i + 1); + t2 = Sum0(h) + Maj(h, a, b); + c += t1; + g = t1 + t2; + + t1 = f + Sum1(c) + Ch(c, d, e) + Unsafe.Add(ref kp, i + 2) + Unsafe.Add(ref wp, i + 2); + t2 = Sum0(g) + Maj(g, h, a); + b += t1; + f = t1 + t2; + + t1 = e + Sum1(b) + Ch(b, c, d) + Unsafe.Add(ref kp, i + 3) + Unsafe.Add(ref wp, i + 3); + t2 = Sum0(f) + Maj(f, g, h); + a += t1; + e = t1 + t2; + + t1 = d + Sum1(a) + Ch(a, b, c) + Unsafe.Add(ref kp, i + 4) + Unsafe.Add(ref wp, i + 4); + t2 = Sum0(e) + Maj(e, f, g); + h += t1; + d = t1 + t2; + + t1 = c + Sum1(h) + Ch(h, a, b) + Unsafe.Add(ref kp, i + 5) + Unsafe.Add(ref wp, i + 5); + t2 = Sum0(d) + Maj(d, e, f); + g += t1; + c = t1 + t2; + + t1 = b + Sum1(g) + Ch(g, h, a) + Unsafe.Add(ref kp, i + 6) + Unsafe.Add(ref wp, i + 6); + t2 = Sum0(c) + Maj(c, d, e); + f += t1; + b = t1 + t2; + + t1 = a + Sum1(f) + Ch(f, g, h) + Unsafe.Add(ref kp, i + 7) + Unsafe.Add(ref wp, i + 7); + t2 = Sum0(b) + Maj(b, c, d); + e += t1; + a = t1 + t2; + } + + state[0] += a; + state[1] += b; + state[2] += c; + state[3] += d; + state[4] += e; + state[5] += f; + state[6] += g; + state[7] += h; + } + + private static void ExpandScheduleScalar(Span w) + { + ref uint p = ref MemoryMarshal.GetReference(w); + for (int i = 16; i < 64; i++) + { + Unsafe.Add(ref p, i) = + Unsafe.Add(ref p, i - 16) + + Sigma0(Unsafe.Add(ref p, i - 15)) + + Unsafe.Add(ref p, i - 7) + + Sigma1(Unsafe.Add(ref p, i - 2)); + } + } + + /// + /// Expands w[16..63] four words per iteration using portable + /// operations (SSE2 on x64, AdvSimd on arm64). The sigma-0 term and the three-way add are + /// vectorized; sigma-1 stays scalar because w[i+2] and w[i+3] depend on the just-computed + /// w[i] and w[i+1]. + /// + private static void ExpandScheduleVector128(Span w) + { + ref uint p = ref MemoryMarshal.GetReference(w); + for (int i = 16; i < 64; i += 4) + { + var wm15 = Vector128.LoadUnsafe(ref p, (nuint)(i - 15)); + var s0 = RotateRight(wm15, 7) ^ RotateRight(wm15, 18) ^ (wm15 >>> 3); + var partial = Vector128.LoadUnsafe(ref p, (nuint)(i - 16)) + s0 + + Vector128.LoadUnsafe(ref p, (nuint)(i - 7)); + + uint w0 = partial.GetElement(0) + Sigma1(Unsafe.Add(ref p, i - 2)); + uint w1 = partial.GetElement(1) + Sigma1(Unsafe.Add(ref p, i - 1)); + Unsafe.Add(ref p, i) = w0; + Unsafe.Add(ref p, i + 1) = w1; + Unsafe.Add(ref p, i + 2) = partial.GetElement(2) + Sigma1(w0); + Unsafe.Add(ref p, i + 3) = partial.GetElement(3) + Sigma1(w1); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector128 RotateRight(Vector128 v, int n) + => (v >>> n) | (v << (32 - n)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Sum0(uint x) + => uint.RotateRight(x, 2) ^ uint.RotateRight(x, 13) ^ uint.RotateRight(x, 22); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Sum1(uint x) + => uint.RotateRight(x, 6) ^ uint.RotateRight(x, 11) ^ uint.RotateRight(x, 25); + + // Ch(x,y,z) = (x & y) ^ (~x & z), rewritten to three operations. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Ch(uint x, uint y, uint z) => z ^ (x & (y ^ z)); + + // Maj(x,y,z) = (x & y) ^ (x & z) ^ (y & z), rewritten to four operations. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Maj(uint x, uint y, uint z) => (x & y) | (z & (x ^ y)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Sigma0(uint x) + => uint.RotateRight(x, 7) ^ uint.RotateRight(x, 18) ^ (x >> 3); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Sigma1(uint x) + => uint.RotateRight(x, 17) ^ uint.RotateRight(x, 19) ^ (x >> 10); +} diff --git a/QuickProxyNet/Internal/Crypto/UuidCodec.cs b/QuickProxyNet/Internal/Crypto/UuidCodec.cs new file mode 100644 index 0000000..e437fc9 --- /dev/null +++ b/QuickProxyNet/Internal/Crypto/UuidCodec.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet; + +/// +/// Encodes a VLESS/VMess user id into its 16-byte RFC 4122 (network / big-endian) +/// representation, mirroring Xray's common/uuid.ParseString byte for byte. +/// +/// +/// +/// The legacy overload emits the first three +/// fields in little-endian on all platforms, which is the wrong order for these +/// protocols. This codec always produces big-endian bytes and allocates nothing. +/// +/// +/// An id is not required to be a UUID. Xray maps any id of length 1..30 to +/// UUIDv5(nil-namespace, utf8(id)) — that is, SHA1(16 zero bytes || id) +/// truncated to 16 bytes with the version nibble set to 5 and the RFC 4122 variant bits +/// set — and both endpoints derive the same value, so such ids work end to end. Rejecting +/// them would break configurations that Xray and sing-box accept; about 0.3% of +/// real-world VLESS links use one. Lengths 32..36 are parsed as canonical hex; length 0, +/// length 31 and lengths above 36 are errors, exactly as upstream. +/// +/// +internal static class UuidCodec +{ + public const int Size = 16; + + /// Longest id Xray will derive a UUID from. + private const int MaxDerivedLength = 30; + + /// Shortest and longest id Xray parses as canonical hex. + private const int MinCanonicalLength = 32; + private const int MaxCanonicalLength = 36; + + /// + /// Writes the 16 big-endian bytes of into . + /// + /// is not a usable user id. + public static void WriteBigEndian(ReadOnlySpan id, Span dest) + { + if (!TryWriteBigEndian(id, dest)) + throw new FormatException( + $"VLESS/VMess user id '{id.ToString()}' is neither a canonical UUID nor a " + + "string of 1..30 characters (which would be mapped to a UUID)."); + } + + /// + /// Attempts to write the 16 big-endian bytes of into + /// . Returns without throwing if the + /// id is unusable or the destination is too small. + /// + public static bool TryWriteBigEndian(ReadOnlySpan id, Span dest) + { + if (dest.Length < Size) + return false; + + // Length decides the branch, and the two ranges cannot overlap: no canonical Guid + // format is 30 characters or shorter ("N" is 32, "D" 36, "B"/"P" 38). + if (id.Length >= MinCanonicalLength && id.Length <= MaxCanonicalLength) + { + // Guid is a struct — TryParse + big-endian TryWriteBytes is fully zero-allocation. + // bigEndian:true (net8+) yields RFC 4122 order == the canonical string byte order. + return Guid.TryParse(id, out var guid) && + guid.TryWriteBytes(dest, bigEndian: true, out _); + } + + if (id.Length is > 0 and <= MaxDerivedLength) + return TryDerive(id, dest); + + return false; + } + + /// + /// Derives a UUID from a non-UUID id the way Xray does: + /// u = SHA1(nil-namespace || utf8(id))[0..16], then version 5 and the RFC 4122 + /// variant are stamped into u[6] and u[8]. + /// + private static bool TryDerive(ReadOnlySpan id, Span dest) + { + // At most 30 chars; 4 bytes each is the worst case UTF-8 can produce. + Span input = stackalloc byte[Size + MaxDerivedLength * 4]; + input[..Size].Clear(); // the nil namespace: 16 zero bytes + + if (!Encoding.UTF8.TryGetBytes(id, input[Size..], out int nameLength)) + return false; + + Span hash = stackalloc byte[20]; // SHA-1 digest + if (!SHA1.TryHashData(input[..(Size + nameLength)], hash, out _)) + return false; + + hash[..Size].CopyTo(dest); + dest[6] = (byte)((dest[6] & 0x0F) | 0x50); // version 5 + dest[8] = (byte)((dest[8] & 0x3F) | 0x80); // RFC 4122 variant + return true; + } +} diff --git a/QuickProxyNet/Internal/HttpHelper.cs b/QuickProxyNet/Internal/HttpHelper.cs index 478c9c0..8e583ed 100644 --- a/QuickProxyNet/Internal/HttpHelper.cs +++ b/QuickProxyNet/Internal/HttpHelper.cs @@ -112,12 +112,7 @@ internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, switch (statusCode) { case 200: - if (parser.HasOverreadBytes) - { - byte[] overread = parser.OverreadBytes.ToArray(); - return new PrefixedStream(overread, stream); - } - return stream; + return PrefixedStream.WrapIfNeeded(parser.OverreadBytes, stream); case 407: throw new ProxyProtocolException(ProxyErrorCode.AuthRequired, $"Proxy authentication required (407) for {host}:{port}."); @@ -135,69 +130,4 @@ internal static async ValueTask EstablishHttpTunnelAsync(Stream stream, } } - private sealed class PrefixedStream(byte[] prefix, Stream inner) : Stream - { - private int _offset; - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => inner.CanWrite; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() => inner.Flush(); - public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct); - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - - public override int Read(Span buffer) - { - if (_offset < prefix.Length) - { - int count = Math.Min(buffer.Length, prefix.Length - _offset); - prefix.AsSpan(_offset, count).CopyTo(buffer); - _offset += count; - return count; - } - return inner.Read(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) - { - if (_offset < prefix.Length) - { - int count = Math.Min(buffer.Length, prefix.Length - _offset); - prefix.AsMemory(_offset, count).CopyTo(buffer); - _offset += count; - return count; - } - return await inner.ReadAsync(buffer, ct); - } - - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => - ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); - - public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); - public override void Write(ReadOnlySpan buffer) => inner.Write(buffer); - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => - inner.WriteAsync(buffer, ct); - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => - inner.WriteAsync(buffer, offset, count, ct); - - protected override void Dispose(bool disposing) - { - if (disposing) inner.Dispose(); - base.Dispose(disposing); - } - - public override ValueTask DisposeAsync() => inner.DisposeAsync(); - } } diff --git a/QuickProxyNet/Internal/HttpResponseParser.cs b/QuickProxyNet/Internal/HttpResponseParser.cs index 1a1220b..216cda6 100644 --- a/QuickProxyNet/Internal/HttpResponseParser.cs +++ b/QuickProxyNet/Internal/HttpResponseParser.cs @@ -87,6 +87,18 @@ public int GetStatusCode() return (code[0] - '0') * 100 + (code[1] - '0') * 10 + (code[2] - '0'); } + /// + /// The response header block, up to and including the terminating CRLFCRLF. Empty until + /// has found the terminator. + /// + /// + /// Header lookups must use this rather than : the latter also contains + /// any overread tunnel bytes, and matching a header name inside attacker-influenced + /// payload would let the peer forge a header value the parser never received. + /// + public ReadOnlySpan Headers => + _indexEnd < 0 ? ReadOnlySpan.Empty : _buffer.AsSpan(0, _indexEnd + 4); + /// /// True if bytes were read beyond the end of the HTTP response headers. /// These bytes belong to the tunneled connection and must be re-prepended to the stream. diff --git a/QuickProxyNet/Internal/PrefixedStream.cs b/QuickProxyNet/Internal/PrefixedStream.cs new file mode 100644 index 0000000..c10875c --- /dev/null +++ b/QuickProxyNet/Internal/PrefixedStream.cs @@ -0,0 +1,83 @@ +namespace QuickProxyNet; + +/// +/// Serves before delegating to . +/// +/// +/// Every handshake that reads a header block off a socket can overread: the reader asks for +/// a buffer's worth and the server has already pipelined tunnel bytes behind the header. Those +/// bytes belong to the caller and are gone once the header parser's buffer is returned to the +/// pool, so they are re-prepended here instead. +/// +internal sealed class PrefixedStream(byte[] prefix, Stream inner) : Stream +{ + private int _offset; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken ct) => inner.FlushAsync(ct); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override int Read(Span buffer) + { + if (_offset < prefix.Length) + { + int count = Math.Min(buffer.Length, prefix.Length - _offset); + prefix.AsSpan(_offset, count).CopyTo(buffer); + _offset += count; + return count; + } + return inner.Read(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) + { + if (_offset < prefix.Length) + { + int count = Math.Min(buffer.Length, prefix.Length - _offset); + prefix.AsMemory(_offset, count).CopyTo(buffer); + _offset += count; + return count; + } + return await inner.ReadAsync(buffer, ct).ConfigureAwait(false); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + public override void Write(ReadOnlySpan buffer) => inner.Write(buffer); + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => + inner.WriteAsync(buffer, ct); + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + inner.WriteAsync(buffer, offset, count, ct); + + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } + + public override ValueTask DisposeAsync() => inner.DisposeAsync(); + + /// + /// Wraps only when actually has bytes, + /// so the common case adds no layer to the stream stack. + /// + public static Stream WrapIfNeeded(ReadOnlySpan overread, Stream inner) => + overread.IsEmpty ? inner : new PrefixedStream(overread.ToArray(), inner); +} diff --git a/QuickProxyNet/Internal/ProxyAddress.cs b/QuickProxyNet/Internal/ProxyAddress.cs new file mode 100644 index 0000000..ae82d44 --- /dev/null +++ b/QuickProxyNet/Internal/ProxyAddress.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet; + +/// +/// Writes a target address in the SOCKS5-style type + address layout shared by +/// VLESS, VMess and Trojan. The numeric address-type codes differ between protocols +/// (VLESS uses 0x02 for domain, SOCKS5/Trojan use 0x03), so they are passed in by the +/// caller. Port is written separately because protocols disagree on its position. +/// +internal static class ProxyAddress +{ + /// Maximum bytes this writer can emit: type(1) + domain-len(1) + domain(255). + public const int MaxLength = 1 + 1 + 255; + + /// + /// Writes atyp(1) + address(var) for into + /// using the supplied type codes, and returns the number of + /// bytes written. A literal IPv4/IPv6 host is emitted as raw address bytes; anything + /// else is treated as a domain name with a single-byte length prefix. + /// + public static int WriteTypeAndAddress( + string host, Span dest, byte ipv4Type, byte domainType, byte ipv6Type) + { + if (IPAddress.TryParse(host, out var ip)) + { + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + dest[0] = ipv4Type; + ip.TryWriteBytes(dest.Slice(1), out var n); + Debug.Assert(n == 4); + return 1 + 4; + } + + Debug.Assert(ip.AddressFamily == AddressFamily.InterNetworkV6); + dest[0] = ipv6Type; + ip.TryWriteBytes(dest.Slice(1), out var n6); + Debug.Assert(n6 == 16); + return 1 + 16; + } + + dest[0] = domainType; + int len = EncodeDomain(host, dest.Slice(2)); + dest[1] = (byte)len; + return 2 + len; + } + + private static int EncodeDomain(ReadOnlySpan host, Span dest) + { + // The domain length is a single byte, so cap the write at 256 to distinguish an + // exactly-255-byte name from an overflow. UTF-8 is >= 1 byte/char, so a host with + // more than 255 chars can never fit and is rejected without encoding. + Span clamped = dest.Length > 256 ? dest.Slice(0, 256) : dest; + if (host.Length > 255 || !Encoding.UTF8.TryGetBytes(host, clamped, out int n) || n > 255) + throw new ProxyProtocolException(ProxyErrorCode.StringTooLong, + "Target host name exceeds the maximum of 255 bytes."); + return n; + } +} diff --git a/QuickProxyNet/Internal/Reality/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs new file mode 100644 index 0000000..a56a3aa --- /dev/null +++ b/QuickProxyNet/Internal/Reality/RealityAuth.cs @@ -0,0 +1,231 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The REALITY authentication primitives: deriving the auth key, sealing it into the TLS +/// session_id, and recognising the server's answer. +/// +/// +/// +/// This is the whole of REALITY's own cryptography, and it is small. Everything else REALITY +/// needs is ordinary TLS 1.3 — which is where the actual difficulty lives, not here. +/// +/// +/// The construction, as implemented by XTLS/REALITY and Xray-core: +/// +/// +/// +/// authKey = X25519(clientKeySharePrivate, serverPublicKey) — the private half is the +/// same ephemeral key the ClientHello offers in key_share, not a separate one. +/// The server recovers it from clientHello.keyShares, which is why no extra field has +/// to be smuggled anywhere. +/// +/// +/// authKey = HKDF-SHA256(ikm: authKey, salt: clientRandom[0..20], info: "REALITY"), 32 bytes. +/// +/// +/// session_id = AES-256-GCM(key: authKey, nonce: clientRandom[20..32], plaintext: 16-byte +/// header, aad: the raw ClientHello with session_id zeroed) — 16 bytes of ciphertext plus +/// a 16-byte tag exactly fill the 32-byte field. +/// +/// +/// +/// The AAD binding is what stops the sealed session_id from being replayed into a +/// different ClientHello: any edit to the hello invalidates the tag, so a censor cannot lift the +/// blob out of a recorded handshake and reuse it. +/// +/// +internal static class RealityAuth +{ + /// Size of the TLS session_id REALITY requires; the ciphertext exactly fills it. + public const int SessionIdSize = 32; + + /// Size of the derived auth key. 32 bytes, so the AEAD is AES-256-GCM. + public const int AuthKeySize = 32; + + /// Size of the short id, zero-padded from the hex in the share link's sid. + public const int ShortIdSize = 8; + + /// + /// Offset of session_id inside a raw ClientHello handshake message. + /// + /// + /// 4 bytes of handshake header, 2 of legacy_version, 32 of random, 1 length byte. Fixed only + /// because the length byte must read 32 — REALITY requires a full-length session id, which + /// every TLS 1.3 client sends anyway for middlebox compatibility. + /// + public const int SessionIdOffset = 39; + + private static ReadOnlySpan HkdfInfo => "REALITY"u8; + + /// + /// Derives the auth key from our ephemeral private key and the server's REALITY public key. + /// + /// Receives 32 bytes. + /// Our key_share private scalar. + /// The server's public key — pbk in the share link. + /// The ClientHello's 32-byte random. + public static void DeriveAuthKey( + Span authKey, + ReadOnlySpan clientPrivateKey, + ReadOnlySpan serverPublicKey, + ReadOnlySpan clientRandom) + { + if (authKey.Length != AuthKeySize) + throw new ArgumentException($"The auth key is {AuthKeySize} bytes.", nameof(authKey)); + if (clientRandom.Length != 32) + throw new ArgumentException("The client random is 32 bytes.", nameof(clientRandom)); + + Span shared = stackalloc byte[X25519.KeySize]; + 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); + } + finally + { + CryptographicOperations.ZeroMemory(shared); + } + } + + /// + /// Writes the sealed authentication blob into the session_id of a raw ClientHello. + /// + /// + /// The complete handshake message, starting at the handshake type byte. Modified in place: + /// bytes 39..71 are replaced with the ciphertext and tag. + /// + /// The key from . + /// The short id, 8 bytes. + /// Client timestamp; the server may reject a large skew. + /// Three version bytes the server can gate on. + public static void SealSessionId( + Span clientHello, + ReadOnlySpan authKey, + ReadOnlySpan shortId, + uint unixTime, + ReadOnlySpan clientVersion) + { + if (clientHello.Length < SessionIdOffset + SessionIdSize) + throw new ArgumentException("The ClientHello is too short to contain a session id.", nameof(clientHello)); + if (clientHello[SessionIdOffset - 1] != SessionIdSize) + throw new ArgumentException( + $"REALITY requires a {SessionIdSize}-byte session id; this ClientHello declares " + + $"{clientHello[SessionIdOffset - 1]}.", nameof(clientHello)); + if (shortId.Length != ShortIdSize) + throw new ArgumentException($"The short id is {ShortIdSize} bytes.", nameof(shortId)); + if (clientVersion.Length != 3) + throw new ArgumentException("The client version is 3 bytes.", nameof(clientVersion)); + + Span sessionId = clientHello.Slice(SessionIdOffset, SessionIdSize); + ReadOnlySpan clientRandom = clientHello.Slice(6, 32); + + Span plaintext = stackalloc byte[16]; + clientVersion.CopyTo(plaintext); + plaintext[3] = 0; // reserved + BinaryPrimitives.WriteUInt32BigEndian(plaintext[4..], unixTime); + shortId.CopyTo(plaintext[8..]); + + // The AAD is the hello with the session id zeroed — the state the server reconstructs + // before it can verify the tag. + sessionId.Clear(); + + Span nonce = stackalloc byte[12]; + clientRandom[20..].CopyTo(nonce); + + // Written to a separate buffer and copied back, rather than encrypted in place. The + // destination is a slice of the same array that is passed as additional data, and + // AesGcm does not document what it does when output and AAD overlap — it happens to + // work on the platforms tested only because GHASH consumes the AAD before any + // ciphertext is produced. Depending on that is not worth 32 bytes of stack. + Span sealedBlob = stackalloc byte[SessionIdSize]; + + try + { + using var aes = new AesGcm(authKey, tagSizeInBytes: 16); + aes.Encrypt(nonce, plaintext, sealedBlob[..16], sealedBlob[16..], clientHello); + sealedBlob.CopyTo(sessionId); + } + finally + { + CryptographicOperations.ZeroMemory(plaintext); + } + } + + /// + /// Decides whether a server certificate came from the REALITY server or from the real site + /// whose handshake it relays. + /// + /// The key from . + /// The leaf certificate's Ed25519 public key, raw 32 bytes. + /// The leaf certificate's signature field. + /// + /// True when the signature is HMAC-SHA512(authKey, publicKey) — proof the peer knows + /// the shared secret, which only the real REALITY server does. + /// + /// + /// A false result is not an error: it is the normal outcome when the handshake was relayed + /// through to the decoy site, and the caller must then fall back to ordinary X.509 validation + /// rather than treating the connection as a tunnel. Getting that branch wrong in the other + /// direction — tunnelling anyway — would send the VLESS id to whatever server answered. + /// + public static bool VerifyCertificate( + ReadOnlySpan authKey, + ReadOnlySpan ed25519PublicKey, + ReadOnlySpan certificateSignature) + { + if (certificateSignature.Length != HMACSHA512.HashSizeInBytes) + return false; + + Span expected = stackalloc byte[HMACSHA512.HashSizeInBytes]; + HMACSHA512.HashData(authKey, ed25519PublicKey, expected); + + return CryptographicOperations.FixedTimeEquals(expected, certificateSignature); + } + + /// + /// 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 (shortId.Length != ShortIdSize) + throw new ArgumentException($"The short id is {ShortIdSize} bytes.", nameof(shortId)); + + shortId.Clear(); + + if (string.IsNullOrEmpty(hex)) + return; + + if ((hex.Length & 1) != 0) + throw new FormatException($"A REALITY short id is an even number of hex digits; '{hex}' is not."); + + 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])); + + static int ParseNibble(char c) => c switch + { + >= '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.") + }; + } +} diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs new file mode 100644 index 0000000..e682ba5 --- /dev/null +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -0,0 +1,796 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Formats.Asn1; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// Settings for a managed REALITY handshake. +internal sealed class RealityTlsOptions +{ + /// SNI to present — the borrowed site's name (sni in the share link). + public required string ServerName { get; init; } + + /// The server's REALITY public key, 32 bytes (pbk, base64url in the link). + public required byte[] PublicKey { get; init; } + + /// The short id as hex (sid), or null. + public string? ShortId { get; init; } + + /// ALPN identifiers to offer, or null. + public IReadOnlyList? Alpn { get; init; } + + /// + /// The three version bytes placed in the sealed session id. Servers may set a minimum. + /// + public byte[] ClientVersion { get; init; } = [26, 3, 27]; +} + +/// +/// A TLS 1.3 client that speaks REALITY, implemented in managed code. +/// +/// +/// +/// Deliberately not a general TLS implementation. It supports exactly one handshake shape: full +/// 1-RTT, no PSK, no resumption, no client certificates, no HelloRetryRequest, no renegotiation. +/// Everything outside that shape is rejected with a message naming what happened rather than +/// worked around, because every "handle this case too" is another chance to end up in a state the +/// tests do not cover. +/// +/// +/// What is verified, and what is not. The server's Finished is checked — that is the proof +/// the peer holds the private key for the key_share it sent, and it is not optional. The +/// certificate's Ed25519 CertificateVerify signature is not checked, because REALITY's own +/// test subsumes it: the leaf certificate is generated per connection and bound to the shared +/// secret by HMAC-SHA512(authKey, publicKey) == certificate.signature, which nobody +/// without the REALITY private key can produce. Outside REALITY that omission would be a hole; +/// here the HMAC is the stronger of the two checks. See +/// . +/// +/// +/// The ClientHello it sends is not a browser fingerprint yet — see +/// for why that matters more than it might appear. +/// +/// +internal sealed class RealityTlsClient +{ + /// The fixed ServerHello.random that marks a HelloRetryRequest (RFC 8446 §4.1.3). + private static ReadOnlySpan HelloRetryRequestRandom => + [ + 0xCF, 0x21, 0xAD, 0x74, 0xE5, 0x9A, 0x61, 0x11, 0xBE, 0x1D, 0x8C, 0x02, 0x1E, 0x65, 0xB8, 0x91, + 0xC2, 0xA2, 0x11, 0x16, 0x7A, 0xBB, 0x8C, 0x5E, 0x07, 0x9E, 0x09, 0xE2, 0xC8, 0xA8, 0x33, 0x9C + ]; + + private const string Ed25519Oid = "1.3.101.112"; + + /// + /// Cap on handshake messages in the server's flight. Four is the real number; the margin is + /// for servers that are odd rather than hostile. + /// + private const int MaxFlightMessages = 8; + + /// The one-byte ChangeCipherSpec payload, which never varies. + private static readonly byte[] ChangeCipherSpecPayload = [1]; + + /// + /// Every secret the handshake derives, in one pooled buffer. + /// + /// + /// Individually these are seven allocations of 32 to 48 bytes — nothing worth chasing once + /// per connection. Together they are the reason a single finally can guarantee all of + /// them are cleared. The seven separate calls + /// this replaces were correct, and were exactly the shape a later edit forgets to extend: + /// add an eighth secret and nothing tells you the clearing did not follow. + /// + private readonly struct HandshakeSecrets(byte[] buffer, int hashLength) + { + public static HandshakeSecrets Rent(int hashLength) => + new(ArrayPool.Shared.Rent(X25519.KeySize + (6 * hashLength)), hashLength); + + /// The raw X25519 shared secret, before the key schedule touches it. + public Span Shared => buffer.AsSpan(0, X25519.KeySize); + + public Span HandshakeSecret => At(0); + public Span ClientHandshakeTraffic => At(1); + public Span ServerHandshakeTraffic => At(2); + public Span MasterSecret => At(3); + public Span ClientApplicationTraffic => At(4); + public Span ServerApplicationTraffic => At(5); + + private Span At(int index) => + 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); + } + + /// + /// Performs the handshake over and returns the tunnelled stream. + /// + /// A connected stream to the REALITY server. + /// The share link's REALITY parameters. + /// Cancels the handshake. + /// + /// The peer is not the REALITY server we authenticated to, or the handshake used something + /// this client does not implement. + /// + public static async ValueTask HandshakeAsync( + Stream transport, RealityTlsOptions options, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(transport); + ArgumentNullException.ThrowIfNull(options); + + if (options.PublicKey.Length != X25519.KeySize) + throw new ArgumentException($"A REALITY public key is {X25519.KeySize} bytes.", nameof(options)); + + var records = new TlsRecordStream(transport); + byte[] authKey = new byte[RealityAuth.AuthKeySize]; + TlsClientHello.Result hello = default; + HandshakeReader? messages = null; + + try + { + // ---- ClientHello, with the REALITY blob sealed into its session id ---- + hello = TlsClientHello.Build(options.ServerName, options.Alpn); + + RealityAuth.DeriveAuthKey(authKey, hello.PrivateKey, options.PublicKey, hello.Handshake.AsSpan(6, 32)); + + Span shortId = stackalloc byte[RealityAuth.ShortIdSize]; + RealityAuth.ParseShortId(shortId, options.ShortId); + + RealityAuth.SealSessionId( + hello.Handshake, + authKey, + shortId, + (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + options.ClientVersion); + + await records.WriteAsync(TlsContentType.Handshake, hello.Handshake, cancellationToken).ConfigureAwait(false); + + // ---- ServerHello ---- + messages = new HandshakeReader(records); + HandshakeMessage serverHello = await messages.NextAsync(cancellationToken).ConfigureAwait(false); + if (serverHello.Type != TlsHandshakeType.ServerHello) + throw new RealityHandshakeException($"Expected a ServerHello, got {serverHello.Type}."); + + ServerHello parsed = ParseServerHello(serverHello.Raw, hello.Handshake); + + // Everything after the ServerHello is encrypted, so a plaintext handshake byte still + // buffered here was never authenticated — it came from somebody on the path, not from + // the server. It would otherwise be handed out later as if it had been decrypted. + if (messages.HasBufferedBytes) + throw new RealityHandshakeException( + "The peer sent unencrypted handshake bytes after its ServerHello. They cannot be " + + "authenticated, so the connection is refused."); + + // The transcript hash cannot start until the suite names its hash, so the hello bytes + // are replayed into it here rather than fed as they were sent. + using IncrementalHash transcript = IncrementalHash.CreateHash(parsed.Suite.Hash); + transcript.AppendData(hello.Handshake); + transcript.AppendData(serverHello.Raw); + + // ---- Key schedule ---- + HandshakeSecrets secrets = HandshakeSecrets.Rent(parsed.Suite.HashLength); + + try + { + try + { + X25519.Agree(secrets.Shared, hello.PrivateKey, parsed.KeyShare); + } + catch (CryptographicException ex) + { + // A key_share that lands on a low-order point yields an all-zero shared + // secret; RFC 7748 §6.1 says abort. It is the peer's choice of key, not ours. + throw new RealityHandshakeException( + "The server's key_share is a low-order X25519 point; the handshake is refused.", ex); + } + + DeriveHandshakeSecrets( + parsed.Suite, secrets.Shared, transcript.GetCurrentHash(), + secrets.HandshakeSecret, secrets.ClientHandshakeTraffic, + secrets.ServerHandshakeTraffic, secrets.MasterSecret); + + records.Read = new TlsRecordProtection(parsed.Suite, secrets.ServerHandshakeTraffic); + + // ---- Server flight ---- + byte[]? leafCertificate = null; + bool serverFinished = false; + int flightMessages = 0; + + while (!serverFinished) + { + HandshakeMessage message = await messages.NextAsync(cancellationToken).ConfigureAwait(false); + + // A real flight is EncryptedExtensions, Certificate, CertificateVerify, + // Finished — four messages. A peer that keeps sending valid-looking ones + // and never a Finished would otherwise hold this loop open for as long as + // it cares to; nothing it sends costs us memory, only time without end. + if (++flightMessages > MaxFlightMessages) + throw new RealityHandshakeException( + $"The server sent more than {MaxFlightMessages} handshake messages without a Finished."); + + switch (message.Type) + { + case TlsHandshakeType.EncryptedExtensions: + case TlsHandshakeType.CertificateVerify: + transcript.AppendData(message.Raw); + break; + + case TlsHandshakeType.Certificate: + leafCertificate = ExtractLeafCertificate(message.Body); + transcript.AppendData(message.Raw); + break; + + case TlsHandshakeType.Finished: + // Verified against the transcript as it stood *before* this message. + VerifyServerFinished( + parsed.Suite, secrets.ServerHandshakeTraffic, + transcript.GetCurrentHash(), message.Body.Span); + serverFinished = true; + transcript.AppendData(message.Raw); + break; + + case TlsHandshakeType.CertificateRequest: + throw new RealityHandshakeException( + "The server asked for a client certificate, which this client does not implement. " + + "A REALITY server does not do this."); + + default: + throw new RealityHandshakeException( + $"Unexpected {message.Type} in the server's handshake flight."); + } + } + + if (leafCertificate is null) + throw new RealityHandshakeException("The server sent no certificate."); + + // ---- The REALITY decision ---- + AssertRealityServer(leafCertificate, authKey, options.ServerName); + + byte[] transcriptAfterServerFinished = transcript.GetCurrentHash(); + + // ---- Client Finished ---- + // The ChangeCipherSpec is meaningless in TLS 1.3 and is sent only so middleboxes + // on the path see the shape of a TLS 1.2 handshake, which is the whole point of a + // protocol designed to look unremarkable. + await records.WriteAsync(TlsContentType.ChangeCipherSpec, ChangeCipherSpecPayload, cancellationToken) + .ConfigureAwait(false); + + records.Write = new TlsRecordProtection(parsed.Suite, secrets.ClientHandshakeTraffic); + + byte[] finished = BuildFinished( + parsed.Suite, secrets.ClientHandshakeTraffic, transcriptAfterServerFinished); + await records.WriteAsync(TlsContentType.Handshake, finished, cancellationToken).ConfigureAwait(false); + + // ---- Application keys ---- + TlsKeySchedule.DeriveSecret( + parsed.Suite.Hash, secrets.MasterSecret, "c ap traffic"u8, + transcriptAfterServerFinished, secrets.ClientApplicationTraffic); + TlsKeySchedule.DeriveSecret( + parsed.Suite.Hash, secrets.MasterSecret, "s ap traffic"u8, + transcriptAfterServerFinished, secrets.ServerApplicationTraffic); + + records.Write?.Dispose(); + records.Read?.Dispose(); + records.Write = new TlsRecordProtection(parsed.Suite, secrets.ClientApplicationTraffic); + records.Read = new TlsRecordProtection(parsed.Suite, secrets.ServerApplicationTraffic); + + return new RealityTlsStream(transport, records, messages.Leftover); + } + finally + { + secrets.Return(); + } + } + catch (EndOfStreamException ex) + { + records.Dispose(); + // The peer hung up before the handshake finished. A REALITY server with no fallback + // does exactly this when it does not recognise the client, so the hint matters. + throw new RealityHandshakeException(ProxyErrorCode.ConnectionFailed, + "The server closed the connection in the middle of the TLS handshake. For a REALITY " + + "server that usually means it did not accept the client: check pbk, sid and sni, and " + + "that this machine's clock is roughly right.", ex); + } + catch + { + records.Dispose(); + throw; + } + finally + { + CryptographicOperations.ZeroMemory(authKey); + + // The ephemeral scalar is the value the whole session can be recomputed from — every + // other secret here is already cleared, and leaving this one on the heap would make + // that discipline pointless. + if (hello.PrivateKey is not null) + CryptographicOperations.ZeroMemory(hello.PrivateKey); + + // Safe here: the stream returned above has already copied whatever Leftover held. + messages?.Return(); + } + } + + private static void DeriveHandshakeSecrets( + TlsCipherSuite suite, + ReadOnlySpan sharedSecret, + ReadOnlySpan transcriptHash, + Span handshakeSecret, + Span clientTraffic, + Span serverTraffic, + Span masterSecret) + { + Span zeros = stackalloc byte[suite.HashLength]; + Span early = stackalloc byte[suite.HashLength]; + Span derived = stackalloc byte[suite.HashLength]; + Span emptyHash = stackalloc byte[suite.HashLength]; + + zeros.Clear(); + HashEmpty(suite, emptyHash); + + TlsKeySchedule.Extract(suite.Hash, zeros, zeros, early); + TlsKeySchedule.DeriveSecret(suite.Hash, early, "derived"u8, emptyHash, derived); + TlsKeySchedule.Extract(suite.Hash, derived, sharedSecret, handshakeSecret); + + TlsKeySchedule.DeriveSecret(suite.Hash, handshakeSecret, "c hs traffic"u8, transcriptHash, clientTraffic); + TlsKeySchedule.DeriveSecret(suite.Hash, handshakeSecret, "s hs traffic"u8, transcriptHash, serverTraffic); + + TlsKeySchedule.DeriveSecret(suite.Hash, handshakeSecret, "derived"u8, emptyHash, derived); + TlsKeySchedule.Extract(suite.Hash, derived, zeros, masterSecret); + + CryptographicOperations.ZeroMemory(early); + CryptographicOperations.ZeroMemory(derived); + } + + private static void HashEmpty(TlsCipherSuite suite, Span output) + { + if (suite.Hash == HashAlgorithmName.SHA384) + SHA384.HashData(ReadOnlySpan.Empty, output); + else + SHA256.HashData(ReadOnlySpan.Empty, output); + } + + private static void VerifyServerFinished( + TlsCipherSuite suite, ReadOnlySpan serverTraffic, ReadOnlySpan transcriptHash, ReadOnlySpan body) + { + Span expected = stackalloc byte[suite.HashLength]; + TlsKeySchedule.FinishedVerifyData(suite.Hash, serverTraffic, transcriptHash, expected); + + if (body.Length != expected.Length || !CryptographicOperations.FixedTimeEquals(expected, body)) + throw new RealityHandshakeException(ProxyErrorCode.AuthFailed, + "The server's Finished did not verify. The peer does not hold the private key for the " + + "key_share it sent, so the connection is not with the server we negotiated with."); + } + + private static byte[] BuildFinished( + TlsCipherSuite suite, ReadOnlySpan clientTraffic, ReadOnlySpan transcriptHash) + { + byte[] message = new byte[4 + suite.HashLength]; + message[0] = (byte)TlsHandshakeType.Finished; + message[1] = 0; + message[2] = (byte)(suite.HashLength >> 8); + message[3] = (byte)suite.HashLength; + + TlsKeySchedule.FinishedVerifyData(suite.Hash, clientTraffic, transcriptHash, message.AsSpan(4)); + return message; + } + + /// + /// Throws unless the leaf certificate proves the peer knows the REALITY shared secret. + /// + private static void AssertRealityServer(byte[] certificate, ReadOnlySpan authKey, string serverName) + { + if (!TryReadEd25519Certificate(certificate, out byte[]? publicKey, out byte[]? signature)) + throw new RealityHandshakeException(ProxyErrorCode.AuthFailed, + $"The peer presented an ordinary certificate for '{serverName}' rather than a REALITY one. " + + "The handshake was relayed to the real site, which means the server did not recognise our " + + "authentication — check the public key, the short id and the clock."); + + if (!RealityAuth.VerifyCertificate(authKey, publicKey, signature)) + throw new RealityHandshakeException(ProxyErrorCode.AuthFailed, + "The peer's certificate is not bound to our REALITY shared secret. Refusing to tunnel: " + + "sending the proxy credentials now would hand them to whoever answered."); + } + + /// + /// Pulls the Ed25519 public key and the signature out of a DER certificate. + /// + /// + /// Hand-parsed because + /// exposes no signature bytes, and REALITY's whole check is against that field. + /// + private static bool TryReadEd25519Certificate(byte[] der, out byte[] publicKey, out byte[] signature) + { + publicKey = []; + signature = []; + + try + { + AsnReader outer = new AsnReader(der, AsnEncodingRules.DER).ReadSequence(); + + AsnReader tbs = outer.ReadSequence(); + if (tbs.PeekTag().HasSameClassAndValue(new Asn1Tag(TagClass.ContextSpecific, 0, isConstructed: true))) + tbs.ReadEncodedValue(); // version + + tbs.ReadEncodedValue(); // serialNumber + tbs.ReadEncodedValue(); // signature algorithm + tbs.ReadEncodedValue(); // issuer + tbs.ReadEncodedValue(); // validity + tbs.ReadEncodedValue(); // subject + + AsnReader subjectPublicKeyInfo = tbs.ReadSequence(); + AsnReader algorithm = subjectPublicKeyInfo.ReadSequence(); + if (algorithm.ReadObjectIdentifier() != Ed25519Oid) + return false; + + publicKey = subjectPublicKeyInfo.ReadBitString(out _); + + outer.ReadEncodedValue(); // signatureAlgorithm + signature = outer.ReadBitString(out _); + + return publicKey.Length == 32; + } + catch (AsnContentException) + { + return false; + } + } + + private readonly record struct ServerHello(TlsCipherSuite Suite, byte[] KeyShare); + + /// + /// Parses a ServerHello, checking every length before it is used. + /// + /// The ServerHello handshake message. + /// + /// Our own hello, for the legacy_session_id_echo comparison RFC 8446 §4.1.3 requires. + /// + /// + /// Every malformed input has to leave as a naming what + /// was wrong. A raw escaping from here would still fail + /// closed, but it would tell whoever is debugging nothing at all about the peer. + /// + private static ServerHello ParseServerHello(byte[] raw, ReadOnlySpan clientHello) + { + ReadOnlySpan body = raw.AsSpan(4); + + ReadOnlySpan random = Take(ref body, 34, "the version and random")[2..]; + if (random.SequenceEqual(HelloRetryRequestRandom)) + throw new RealityHandshakeException( + "The server sent a HelloRetryRequest, which this client does not implement. It means the " + + "server rejected the offered X25519 group."); + + int sessionIdLength = Take(ref body, 1, "the session id length")[0]; + ReadOnlySpan sessionIdEcho = Take(ref body, sessionIdLength, "the session id"); + + // RFC 8446 §4.1.3: the client MUST verify the echo. For REALITY it is more than a + // formality — the session id is where our sealed authentication blob lives, so a + // mismatch means the hello that reached the server was not the one we sent. + ReadOnlySpan sessionIdSent = + clientHello.Slice(RealityAuth.SessionIdOffset, RealityAuth.SessionIdSize); + + if (!sessionIdEcho.SequenceEqual(sessionIdSent)) + throw new RealityHandshakeException( + "The server echoed a different session id than we sent. The ClientHello was altered in " + + "flight, or the answer came from somewhere else."); + + ushort suiteId = BinaryPrimitives.ReadUInt16BigEndian(Take(ref body, 2, "the cipher suite")); + + if (Take(ref body, 1, "the compression method")[0] != 0) + throw new RealityHandshakeException("The server selected a compression method; TLS 1.3 has none."); + + TlsCipherSuite suite = TlsCipherSuite.FromId(suiteId) + ?? throw new RealityHandshakeException($"The server chose cipher suite 0x{suiteId:X4}, which we did not offer."); + + int extensionsLength = BinaryPrimitives.ReadUInt16BigEndian(Take(ref body, 2, "the extensions length")); + ReadOnlySpan extensions = Take(ref body, extensionsLength, "the extensions"); + + byte[]? keyShare = null; + bool sawTls13 = false; + + while (!extensions.IsEmpty) + { + ushort type = BinaryPrimitives.ReadUInt16BigEndian(Take(ref extensions, 2, "an extension type")); + int length = BinaryPrimitives.ReadUInt16BigEndian(Take(ref extensions, 2, "an extension length")); + ReadOnlySpan data = Take(ref extensions, length, $"extension {type}"); + + switch (type) + { + case 43 when data.Length == 2 && BinaryPrimitives.ReadUInt16BigEndian(data) == 0x0304: + sawTls13 = true; + break; + + case 51 when data.Length >= 4: + ushort group = BinaryPrimitives.ReadUInt16BigEndian(data); + int shareLength = BinaryPrimitives.ReadUInt16BigEndian(data[2..]); + if (group == 0x001D && shareLength == X25519.KeySize && data.Length >= 4 + shareLength) + keyShare = data.Slice(4, shareLength).ToArray(); + break; + } + } + + if (!sawTls13) + throw new RealityHandshakeException( + "The server did not select TLS 1.3. REALITY exists only in TLS 1.3, so there is nothing to fall back to."); + + if (keyShare is null) + throw new RealityHandshakeException("The server's key_share is missing or is not X25519."); + + return new ServerHello(suite, keyShare); + } + + /// + /// Consumes bytes from the front of , + /// failing with a description rather than an index-out-of-range. + /// + /// The span to advance; on return it starts past the taken bytes. + /// How many bytes the field needs. + /// What the bytes are, for the failure message. + private static ReadOnlySpan Take(ref ReadOnlySpan source, int count, string what) + { + if (count < 0 || source.Length < count) + throw new RealityHandshakeException( + $"The peer's message ended before {what}: needed {count} more bytes, had {source.Length}."); + + ReadOnlySpan taken = source[..count]; + source = source[count..]; + return taken; + } + + /// Reads TLS's three-byte big-endian length. + private static int ReadUInt24(ReadOnlySpan source) => + (source[0] << 16) | (source[1] << 8) | source[2]; + + /// Reads the first certificate out of a TLS 1.3 Certificate message body. + private static byte[] ExtractLeafCertificate(ReadOnlyMemory body) + { + ReadOnlySpan span = body.Span; + + int contextLength = Take(ref span, 1, "the certificate request context length")[0]; + Take(ref span, contextLength, "the certificate request context"); + + int listLength = ReadUInt24(Take(ref span, 3, "the certificate list length")); + ReadOnlySpan list = Take(ref span, listLength, "the certificate list"); + + if (list.IsEmpty) + throw new RealityHandshakeException("The server sent an empty certificate list."); + + int certificateLength = ReadUInt24(Take(ref list, 3, "the leaf certificate length")); + return Take(ref list, certificateLength, "the leaf certificate").ToArray(); + } + + /// One complete handshake message. + /// The message type. + /// The whole message including its four-byte header — what the transcript hashes. + /// The message body. + private readonly record struct HandshakeMessage(TlsHandshakeType Type, byte[] Raw, ReadOnlyMemory Body); + + /// + /// Reassembles handshake messages out of records. + /// + /// + /// 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. + /// + 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. + /// + /// + /// RFC 8446 §5 calls for a limit: without one, a peer can hold the handshake open forever + /// by sending nothing else, and the loop below would spin on it. + /// + 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. + /// + public void Return() + { + if (_buffer.Length == 0) + return; + + // Cleared: it held the server's certificate and every other handshake message. + ArrayPool.Shared.Return(_buffer, clearArray: true); + _buffer = []; + _length = 0; + _consumed = 0; + } + + public async ValueTask NextAsync(CancellationToken cancellationToken) + { + while (true) + { + if (TryTake(out HandshakeMessage message)) + return message; + + TlsRecordStream.Record record = await records.ReadAsync(cancellationToken).ConfigureAwait(false); + + switch (record.Type) + { + case TlsContentType.ChangeCipherSpec: + if (++_changeCipherSpecSeen > MaxChangeCipherSpec) + throw new RealityHandshakeException( + "The peer sent nothing but ChangeCipherSpec records."); + + continue; + + case TlsContentType.Alert: + throw new RealityHandshakeException(DescribeAlert(record.Payload.Span)); + + case TlsContentType.ApplicationData when records.Read is null: + // Before the server's keys exist, TlsRecordStream hands records back + // verbatim — unauthenticated. Buffering one here would put bytes an + // on-path attacker chose at the head of the "authenticated" tunnel, and + // the handshake would still complete: injected records never enter the + // transcript, so Finished and the REALITY HMAC both still verify. + throw new RealityHandshakeException( + "The peer sent application data before its keys were established. " + + "Nothing can authenticate those bytes, so they are refused rather " + + "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; + + case TlsContentType.Handshake: + // RFC 8446 §5.1: zero-length handshake records are forbidden. Accepting + // one is harmless; accepting them without end is a peer's free spin. + if (record.Payload.IsEmpty) + throw new RealityHandshakeException( + "The peer sent a zero-length handshake record, which TLS 1.3 forbids."); + + Append(record.Payload.Span); + continue; + + default: + throw new RealityHandshakeException($"Unexpected record type {record.Type} during the handshake."); + } + } + } + + private void Append(ReadOnlySpan data) + { + Compact(); + + if (_length + data.Length > MaxHandshakeMessage) + throw new RealityHandshakeException( + $"The peer's handshake message exceeded {MaxHandshakeMessage} bytes."); + + if (_length + data.Length > _buffer.Length) + { + // Grown by renting, not by Array.Resize: a resized array does not come from the + // pool, and returning it later would put a foreign buffer into the shared pool. + byte[] bigger = ArrayPool.Shared.Rent(Math.Max(_buffer.Length * 2, _length + data.Length)); + _buffer.AsSpan(0, _length).CopyTo(bigger); + ArrayPool.Shared.Return(_buffer, clearArray: true); + _buffer = bigger; + } + + data.CopyTo(_buffer.AsSpan(_length)); + _length += data.Length; + } + + private void Compact() + { + if (_consumed == 0) + return; + + _buffer.AsSpan(_consumed, _length - _consumed).CopyTo(_buffer); + _length -= _consumed; + _consumed = 0; + } + + private bool TryTake(out HandshakeMessage message) + { + message = default; + + int available = _length - _consumed; + if (available < 4) + return false; + + ReadOnlySpan span = _buffer.AsSpan(_consumed, available); + int bodyLength = (span[1] << 16) | (span[2] << 8) | span[3]; + if (available < 4 + bodyLength) + return false; + + byte[] raw = span[..(4 + bodyLength)].ToArray(); + _consumed += 4 + bodyLength; + + message = new HandshakeMessage((TlsHandshakeType)raw[0], raw, raw.AsMemory(4)); + return true; + } + + private static string DescribeAlert(ReadOnlySpan payload) + { + if (payload.Length < 2) + return "The server sent a malformed alert."; + + string level = payload[0] == 2 ? "fatal" : "warning"; + return $"The server sent a {level} TLS alert, description {payload[1]}."; + } + } +} + +/// Raised when a managed REALITY handshake cannot be completed. +/// +/// A , so a caller that already catches those and branches +/// on sees REALITY failures too. Two codes are +/// used: when the peer did not prove it is the server +/// we configured — it relayed us to the decoy site, or its certificate is not bound to our +/// shared secret — and for everything else, which +/// is the peer breaking TLS or sending something this client does not implement. The first is +/// "check pbk, sid and sni"; the second is not something the caller can fix by reconfiguring. +/// +public sealed class RealityHandshakeException : ProxyProtocolException +{ + /// Creates the exception with . + /// What went wrong. + public RealityHandshakeException(string message) : base(ProxyErrorCode.InvalidResponse, message) + { + } + + /// Creates the exception with an explicit code. + /// Why, in terms a caller can branch on. + /// What went wrong. + public RealityHandshakeException(ProxyErrorCode errorCode, string message) : base(errorCode, message) + { + } + + /// Creates the exception with . + /// What went wrong. + /// The underlying failure. + public RealityHandshakeException(string message, Exception innerException) + : base(ProxyErrorCode.InvalidResponse, message, innerException) + { + } + + /// Creates the exception with an explicit code and an underlying failure. + /// Why, in terms a caller can branch on. + /// What went wrong. + /// The underlying failure. + public RealityHandshakeException(ProxyErrorCode errorCode, string message, Exception innerException) + : base(errorCode, message, innerException) + { + } +} diff --git a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs new file mode 100644 index 0000000..adc3396 --- /dev/null +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -0,0 +1,273 @@ +using System.Runtime.CompilerServices; + +namespace QuickProxyNet; + +/// +/// The application-data stream of a completed managed REALITY handshake. +/// +/// +/// +/// Record boundaries are not message boundaries: a read returns whatever one record held, and a +/// write may become several records. Callers that need framing bring their own — which VLESS, +/// the only consumer here, does. +/// +/// +/// Post-handshake handshake messages are not an error. A TLS 1.3 server may send NewSessionTicket +/// at any time, and a client that treats one as data corrupts the stream at a point that looks +/// random from the outside. +/// +/// +internal sealed class RealityTlsStream : Stream +{ + private readonly Stream _transport; + private readonly TlsRecordStream _records; + + /// + /// What is left of the last record read, as a slice of the record layer's own buffer. + /// + /// + /// Not a copy. promises its payload stays valid until + /// the next read, and the read loop only reads again once this is + /// empty — so the lifetime already lines up exactly, and copying each record cost a full + /// memcpy of the payload for nothing. + /// + private ReadOnlyMemory _pending; + private bool _receivedCloseNotify; + private bool _disposed; + + /// Consecutive records without application data before the read gives up. + private const int MaxRecordsWithoutData = 64; + + private int _recordsWithoutData; + + internal RealityTlsStream(Stream transport, TlsRecordStream records, List leftover) + { + _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; + public override bool CanWrite => !_disposed; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// Reads decrypted application data. + /// Receives the data. + /// Cancels the read. + /// + /// Not an async method, so that draining a record already in hand costs a copy and a + /// return rather than an async state machine. A 16 KiB record read 4 KiB at a time takes that + /// path three times out of four, and the record layer underneath takes its own synchronous + /// path whenever the next record is already buffered. + /// + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (buffer.IsEmpty) + return new ValueTask(0); + + if (!_pending.IsEmpty) + return new ValueTask(TakePending(buffer)); + + if (_receivedCloseNotify) + return new ValueTask(0); + + return ReadFromRecordsAsync(buffer, cancellationToken); + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + private async ValueTask ReadFromRecordsAsync(Memory buffer, CancellationToken cancellationToken) + { + while (_pending.IsEmpty) + { + if (_receivedCloseNotify) + return 0; + + if (!await FillAsync(cancellationToken).ConfigureAwait(false)) + return 0; + } + + return TakePending(buffer); + } + + /// Copies out of the record in hand and advances past what was taken. + private int TakePending(Memory buffer) + { + int count = Math.Min(buffer.Length, _pending.Length); + _pending.Span[..count].CopyTo(buffer.Span); + _pending = _pending[count..]; + + return count; + } + + /// Reads records until one yields application data. Returns false at end of stream. + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + private async ValueTask FillAsync(CancellationToken cancellationToken) + { + while (true) + { + TlsRecordStream.Record record; + try + { + record = await _records.ReadAsync(cancellationToken).ConfigureAwait(false); + } + catch (EndOfStreamException) + { + // The peer vanished without a close_notify. Common enough in practice that it is + // reported as end of stream rather than as an error. + _receivedCloseNotify = true; + return false; + } + + switch (record.Type) + { + case TlsContentType.ApplicationData when !record.Payload.IsEmpty: + _recordsWithoutData = 0; + _pending = record.Payload; + return true; + + case TlsContentType.ApplicationData: + case TlsContentType.ChangeCipherSpec: + CountRecordWithoutData(); + continue; + + case TlsContentType.Handshake: + SkipPostHandshakeMessage(record.Payload.Span); + CountRecordWithoutData(); + continue; + + case TlsContentType.Alert: + // description 0 is close_notify: an orderly shutdown, not a failure. + if (record.Payload.Length >= 2 && record.Payload.Span[1] == 0) + { + _receivedCloseNotify = true; + return false; + } + + throw new RealityHandshakeException( + record.Payload.Length >= 2 + ? $"The server sent a TLS alert, description {record.Payload.Span[1]}." + : "The server sent a malformed TLS alert."); + + default: + throw new RealityHandshakeException($"Unexpected record type {record.Type} after the handshake."); + } + } + } + + /// + /// Empty records, ChangeCipherSpec and post-handshake tickets are each legal and each + /// carries no application data. A peer that sends nothing else would otherwise keep + /// ReadAsync from ever returning — not a leak, just a read that never ends. + /// + private void CountRecordWithoutData() + { + if (++_recordsWithoutData > MaxRecordsWithoutData) + throw new RealityHandshakeException( + $"The server sent {MaxRecordsWithoutData} consecutive TLS records carrying no application data."); + } + + private static void SkipPostHandshakeMessage(ReadOnlySpan payload) + { + if (payload.Length < 4) + return; + + var type = (TlsHandshakeType)payload[0]; + if (type is TlsHandshakeType.NewSessionTicket) + return; + + if (type is TlsHandshakeType.KeyUpdate) + throw new RealityHandshakeException( + "The server asked for a key update, which this client does not implement yet. Continuing " + + "would send every later record under keys the server has already retired."); + + throw new RealityHandshakeException($"Unexpected post-handshake message {type}."); + } + + /// Writes application data, splitting it across records as RFC 8446 §5.1 requires. + /// The data to send. + /// Cancels the write. + /// + /// The split into records happens inside the record layer rather than here, so that a write + /// larger than one record still reaches the transport as few writes — the loop this replaces + /// handed down one record at a time, and each of those was its own write and its own flush. + /// + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + return buffer.IsEmpty + ? ValueTask.CompletedTask + : _records.WriteApplicationDataAsync(buffer, cancellationToken); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + 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 void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override void Flush() => _transport.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => _transport.FlushAsync(cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + _disposed = true; + + // Dropped before the record layer returns its buffers to the pool: this aliases one of + // them, and a slice outliving its array is how a pooled buffer ends up shared. + _pending = ReadOnlyMemory.Empty; + + if (disposing) + { + _transport.Dispose(); + _records.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + _pending = ReadOnlyMemory.Empty; + + // Transport first: the record layer's inbound buffer may be the target of a read still + // in flight on another thread, and closing the transport is what ends that read. Only + // then can the buffers go back to the pool without a late completion landing in + // someone else's rental. + await _transport.DisposeAsync().ConfigureAwait(false); + _records.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/QuickProxyNet/Internal/Reality/TlsClientHello.cs b/QuickProxyNet/Internal/Reality/TlsClientHello.cs new file mode 100644 index 0000000..c011cf0 --- /dev/null +++ b/QuickProxyNet/Internal/Reality/TlsClientHello.cs @@ -0,0 +1,268 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet; + +/// +/// Builds the TLS 1.3 ClientHello that carries REALITY's authentication. +/// +/// +/// +/// This is not yet a browser fingerprint, and must not be shipped as one. The hello below +/// is a correct, minimal TLS 1.3 hello: enough for a server to accept, and enough to prove the +/// REALITY authentication is right. It is not byte-identical to any real browser — no GREASE, no +/// padding, a short extension list in the wrong order, and a bare X25519 key_share where +/// current Chrome sends X25519MLKEM768. +/// +/// +/// That distinction is the whole point of REALITY, so it is worth stating plainly: a client whose +/// hello merely works is more identifiable than one that fails, because it presents a +/// handshake that matches no deployed browser while claiming a browser's certificate. Shipping +/// this as a censorship-resistant client would put users in a smaller, stranger bucket than not +/// shipping it at all. Fingerprint fidelity is a separate piece of work, and until it lands this +/// type is a protocol test harness. +/// +/// +internal static class TlsClientHello +{ + private const ushort LegacyVersion = 0x0303; // TLS 1.2, as TLS 1.3 requires + private const ushort ExtensionServerName = 0; + private const ushort ExtensionSupportedGroups = 10; + private const ushort ExtensionSignatureAlgorithms = 13; + private const ushort ExtensionAlpn = 16; + private const ushort ExtensionSupportedVersions = 43; + private const ushort ExtensionPskKeyExchangeModes = 45; + private const ushort ExtensionKeyShare = 51; + + private const ushort GroupX25519 = 0x001D; + private const ushort Tls13 = 0x0304; + + /// The result of building a hello: the message and the key material behind it. + /// + /// The complete handshake message, starting at the handshake type byte. This is exactly the + /// buffer REALITY seals over — hello.Raw on the Go side. + /// + /// The X25519 private key offered in key_share. + /// The matching public key. + internal readonly record struct Result(byte[] Handshake, byte[] PrivateKey, byte[] PublicKey); + + /// + /// Builds a ClientHello offering a fresh X25519 key_share. + /// + /// SNI to present — for REALITY, the borrowed site's name. + /// ALPN identifiers, or null to omit the extension. + public static Result Build(string serverName, IReadOnlyList? alpn = null) + { + byte[] privateKey = new byte[X25519.KeySize]; + byte[] publicKey = new byte[X25519.KeySize]; + X25519.GenerateKeyPair(privateKey, publicKey); + + // A hello lands around 300-600 bytes; sizing for it avoids the one resize the default + // 512-byte buffer would always need. + var writer = new TlsWriter(1024); + + writer.WriteByte(1); // handshake type: client_hello + int body = writer.BeginVector24(); + + writer.WriteUInt16(LegacyVersion); + + Span random = stackalloc byte[32]; + RandomNumberGenerator.Fill(random); + writer.Write(random); + + // A full-length session id. TLS 1.3 clients send one for middlebox compatibility anyway, + // and REALITY requires exactly 32 bytes because that is where the sealed blob goes. + writer.WriteByte(RealityAuth.SessionIdSize); + writer.WriteZeros(RealityAuth.SessionIdSize); + + int cipherSuites = writer.BeginVector16(); + writer.WriteUInt16(0x1301); // TLS_AES_128_GCM_SHA256 + writer.WriteUInt16(0x1302); // TLS_AES_256_GCM_SHA384 + + // Offered only where the platform can actually do it. Advertising a suite the record + // layer cannot build means a server may select it and the connection then fails with + // "the server chose a suite we did not offer", which is both wrong and unhelpful. + if (ChaCha20Poly1305.IsSupported) + writer.WriteUInt16(0x1303); // TLS_CHACHA20_POLY1305_SHA256 + + writer.EndVector(cipherSuites, 2); + + int compression = writer.BeginVector8(); + writer.WriteByte(0); // null + writer.EndVector(compression, 1); + + int extensions = writer.BeginVector16(); + + WriteServerName(writer, serverName); + WriteSupportedGroups(writer); + WriteSignatureAlgorithms(writer); + WriteSupportedVersions(writer); + WritePskKeyExchangeModes(writer); + WriteKeyShare(writer, publicKey); + + if (alpn is { Count: > 0 }) + WriteAlpn(writer, alpn); + + writer.EndVector(extensions, 2); + writer.EndVector(body, 3); + + return new Result(writer.ToArray(), privateKey, publicKey); + } + + private static void WriteServerName(TlsWriter writer, string serverName) + { + writer.WriteUInt16(ExtensionServerName); + int extension = writer.BeginVector16(); + + int list = writer.BeginVector16(); + writer.WriteByte(0); // host_name + int name = writer.BeginVector16(); + writer.Write(Encoding.ASCII.GetBytes(ToALabel(serverName))); + writer.EndVector(name, 2); + writer.EndVector(list, 2); + + writer.EndVector(extension, 2); + } + + /// + /// Converts a host name to the ASCII form SNI requires (RFC 6066: A-labels only). + /// + /// + /// maps anything outside ASCII to ?, so encoding an + /// internationalised name directly would put a host nobody owns into the ClientHello and + /// send it without a word. Punycode is the specified answer, and a name that cannot be + /// converted is an error rather than something to approximate. + /// + private static string ToALabel(string serverName) + { + foreach (char c in serverName) + { + if (c > 127) + { + try + { + return new IdnMapping().GetAscii(serverName); + } + catch (ArgumentException ex) + { + throw new ArgumentException( + $"'{serverName}' is not a host name that can be encoded for SNI.", nameof(serverName), ex); + } + } + } + + return serverName; + } + + private static void WriteSupportedGroups(TlsWriter writer) + { + writer.WriteUInt16(ExtensionSupportedGroups); + int extension = writer.BeginVector16(); + + int groups = writer.BeginVector16(); + writer.WriteUInt16(GroupX25519); + writer.EndVector(groups, 2); + + writer.EndVector(extension, 2); + } + + private static void WriteSignatureAlgorithms(TlsWriter writer) + { + writer.WriteUInt16(ExtensionSignatureAlgorithms); + int extension = writer.BeginVector16(); + + int algorithms = writer.BeginVector16(); + // Chrome's list, in Chrome's order. Both matter: JA4 appends the signature algorithms + // unsorted, so a reordering changes the hash even though TLS does not care. + // + // ed25519 (0x0807) is deliberately absent, and it took an experiment to be sure. An + // earlier comment here claimed it was load-bearing, on the reasoning that a REALITY + // server answers with an Ed25519 certificate. It is not: the server generates that + // certificate only after it has authenticated the client, and never consults + // signature_algorithms for it. Removing it leaves the real-server handshake and tunnel + // tests green, and Chrome does not send it. + writer.WriteUInt16(0x0403); // ecdsa_secp256r1_sha256 + writer.WriteUInt16(0x0804); // rsa_pss_rsae_sha256 + writer.WriteUInt16(0x0401); // rsa_pkcs1_sha256 + writer.WriteUInt16(0x0503); // ecdsa_secp384r1_sha384 + writer.WriteUInt16(0x0805); // rsa_pss_rsae_sha384 + writer.WriteUInt16(0x0501); // rsa_pkcs1_sha384 + writer.WriteUInt16(0x0806); // rsa_pss_rsae_sha512 + writer.WriteUInt16(0x0601); // rsa_pkcs1_sha512 + writer.EndVector(algorithms, 2); + + writer.EndVector(extension, 2); + } + + private static void WriteSupportedVersions(TlsWriter writer) + { + writer.WriteUInt16(ExtensionSupportedVersions); + int extension = writer.BeginVector16(); + + int versions = writer.BeginVector8(); + writer.WriteUInt16(Tls13); + writer.EndVector(versions, 1); + + writer.EndVector(extension, 2); + } + + private static void WritePskKeyExchangeModes(TlsWriter writer) + { + writer.WriteUInt16(ExtensionPskKeyExchangeModes); + int extension = writer.BeginVector16(); + + int modes = writer.BeginVector8(); + writer.WriteByte(1); // psk_dhe_ke + writer.EndVector(modes, 1); + + writer.EndVector(extension, 2); + } + + private static void WriteKeyShare(TlsWriter writer, ReadOnlySpan publicKey) + { + writer.WriteUInt16(ExtensionKeyShare); + int extension = writer.BeginVector16(); + + int shares = writer.BeginVector16(); + writer.WriteUInt16(GroupX25519); + int share = writer.BeginVector16(); + writer.Write(publicKey); + writer.EndVector(share, 2); + writer.EndVector(shares, 2); + + writer.EndVector(extension, 2); + } + + private static void WriteAlpn(TlsWriter writer, IReadOnlyList alpn) + { + writer.WriteUInt16(ExtensionAlpn); + int extension = writer.BeginVector16(); + + int list = writer.BeginVector16(); + foreach (string protocol in alpn) + { + int entry = writer.BeginVector8(); + writer.Write(Encoding.ASCII.GetBytes(protocol)); + writer.EndVector(entry, 1); + } + + writer.EndVector(list, 2); + writer.EndVector(extension, 2); + } + + /// Wraps a handshake message in a TLS plaintext record. + /// The handshake message. + public static byte[] ToRecord(ReadOnlySpan handshake) + { + byte[] record = new byte[5 + handshake.Length]; + record[0] = 0x16; // handshake + record[1] = 0x03; + record[2] = 0x01; // legacy record version + record[3] = (byte)(handshake.Length >> 8); + record[4] = (byte)handshake.Length; + handshake.CopyTo(record.AsSpan(5)); + + return record; + } +} diff --git a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs new file mode 100644 index 0000000..b2fff0a --- /dev/null +++ b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs @@ -0,0 +1,134 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet; + +/// +/// The TLS 1.3 key schedule (RFC 8446 §7.1) and its traffic-key derivation (§7.3). +/// +/// +/// Nothing here is REALITY-specific — it is the ordinary TLS 1.3 schedule, which is exactly why +/// it can be tested against RFC 8448's published traces rather than against our own expectations. +/// Every method is a pure function of its inputs. +/// +internal static class TlsKeySchedule +{ + /// The "tls13 " prefix every HkdfLabel carries. + private static ReadOnlySpan LabelPrefix => "tls13 "u8; + + /// HKDF-Extract. + /// The hash of the negotiated cipher suite. + /// The salt; an empty span means Hash.length zero bytes. + /// The IKM. + /// Receives Hash.length bytes. + public static void Extract( + HashAlgorithmName hash, ReadOnlySpan salt, ReadOnlySpan inputKeyMaterial, Span prk) => + HKDF.Extract(hash, inputKeyMaterial, salt, prk); + + /// + /// HKDF-Expand-Label: HKDF-Expand(secret, HkdfLabel, output.Length). + /// + /// The hash of the negotiated cipher suite. + /// The secret to expand. + /// The label, without the tls13 prefix. + /// The context — usually a transcript hash, sometimes empty. + /// Receives the expanded key material; its length is encoded into the label. + public static void ExpandLabel( + HashAlgorithmName hash, + ReadOnlySpan secret, + ReadOnlySpan label, + ReadOnlySpan context, + Span output) + { + // HkdfLabel = uint16 length || opaque label<7..255> || opaque context<0..255> + int labelLength = LabelPrefix.Length + label.Length; + Span info = stackalloc byte[2 + 1 + labelLength + 1 + context.Length]; + + info[0] = (byte)(output.Length >> 8); + info[1] = (byte)output.Length; + info[2] = (byte)labelLength; + LabelPrefix.CopyTo(info[3..]); + label.CopyTo(info[(3 + LabelPrefix.Length)..]); + info[3 + labelLength] = (byte)context.Length; + context.CopyTo(info[(4 + labelLength)..]); + + HKDF.Expand(hash, secret, output, info); + } + + /// + /// Derive-Secret: with a transcript hash as the context. + /// + /// The hash of the negotiated cipher suite. + /// The secret to derive from. + /// The label, without the tls13 prefix. + /// The hash of the handshake messages so far. + /// Receives Hash.length bytes. + public static void DeriveSecret( + HashAlgorithmName hash, + ReadOnlySpan secret, + ReadOnlySpan label, + ReadOnlySpan transcriptHash, + Span output) => + ExpandLabel(hash, secret, label, transcriptHash, output); + + /// Derives the record-protection key and IV for a traffic secret (RFC 8446 §7.3). + /// The hash of the negotiated cipher suite. + /// The traffic secret. + /// Receives the AEAD key. + /// Receives the 12-byte static IV. + public static void TrafficKeys( + HashAlgorithmName hash, ReadOnlySpan trafficSecret, Span key, Span iv) + { + ExpandLabel(hash, trafficSecret, "key"u8, default, key); + ExpandLabel(hash, trafficSecret, "iv"u8, default, iv); + } + + /// + /// Computes a Finished message's verify_data (RFC 8446 §4.4.4). + /// + /// The hash of the negotiated cipher suite. + /// The sender's handshake traffic secret. + /// The transcript hash up to but excluding this Finished. + /// Receives Hash.length bytes. + public static void FinishedVerifyData( + HashAlgorithmName hash, ReadOnlySpan baseKey, ReadOnlySpan transcriptHash, Span verifyData) + { + Span finishedKey = stackalloc byte[verifyData.Length]; + try + { + ExpandLabel(hash, baseKey, "finished"u8, default, finishedKey); + + if (hash == HashAlgorithmName.SHA384) + HMACSHA384.HashData(finishedKey, transcriptHash, verifyData); + else + HMACSHA256.HashData(finishedKey, transcriptHash, verifyData); + } + finally + { + CryptographicOperations.ZeroMemory(finishedKey); + } + } + + /// + /// Builds the per-record nonce: the static IV xored with the sequence number, right-aligned + /// (RFC 8446 §5.3). + /// + /// Receives the 12-byte nonce. + /// The static IV from . + /// The record sequence number, which starts at zero per key. + public static void BuildNonce(Span nonce, ReadOnlySpan iv, ulong sequenceNumber) + { + iv.CopyTo(nonce); + + // The byte loop this replaces did the same thing eight times over: the sequence number is + // xored in big-endian order into the last eight bytes, which is one read, one xor and one + // write once it is named as such. It runs on every single record, in both directions. + Span tail = nonce[^sizeof(ulong)..]; + BinaryPrimitives.WriteUInt64BigEndian( + tail, BinaryPrimitives.ReadUInt64BigEndian(tail) ^ sequenceNumber); + } + + /// Converts a label to bytes; for callers that do not have a UTF-8 literal. + public static byte[] Label(string label) => Encoding.ASCII.GetBytes(label); +} diff --git a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs new file mode 100644 index 0000000..caf92c8 --- /dev/null +++ b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs @@ -0,0 +1,499 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// TLS record content types (RFC 8446 §5.1). +internal enum TlsContentType : byte +{ + ChangeCipherSpec = 20, + Alert = 21, + Handshake = 22, + ApplicationData = 23 +} + +/// TLS 1.3 handshake message types (RFC 8446 §4). +internal enum TlsHandshakeType : byte +{ + ClientHello = 1, + ServerHello = 2, + NewSessionTicket = 4, + EncryptedExtensions = 8, + Certificate = 11, + CertificateRequest = 13, + CertificateVerify = 15, + Finished = 20, + KeyUpdate = 24 +} + +/// +/// A negotiated TLS 1.3 cipher suite: its hash, its key length, and how to build its AEAD. +/// +internal sealed class TlsCipherSuite +{ + private TlsCipherSuite(ushort id, string name, HashAlgorithmName hash, int hashLength, int keyLength, bool chaCha) + { + Id = id; + Name = name; + Hash = hash; + HashLength = hashLength; + KeyLength = keyLength; + IsChaCha = chaCha; + } + + public ushort Id { get; } + public string Name { get; } + public HashAlgorithmName Hash { get; } + public int HashLength { get; } + public int KeyLength { get; } + public bool IsChaCha { get; } + + /// All TLS 1.3 AEADs are 16-byte-tag AEADs with a 12-byte nonce. + public const int TagLength = 16; + + /// Nonce length, fixed by RFC 8446 §5.3. + public const int NonceLength = 12; + + /// Resolves a suite by its wire id, or null when we did not offer it. + /// + /// A server that answers with a suite outside this set has either misbehaved or we offered + /// something we cannot implement — either way, failing loudly beats guessing. + /// + public static TlsCipherSuite? FromId(ushort id) => id switch + { + 0x1301 => new TlsCipherSuite(id, "TLS_AES_128_GCM_SHA256", HashAlgorithmName.SHA256, 32, 16, chaCha: false), + 0x1302 => new TlsCipherSuite(id, "TLS_AES_256_GCM_SHA384", HashAlgorithmName.SHA384, 48, 32, chaCha: false), + 0x1303 when ChaCha20Poly1305.IsSupported => + new TlsCipherSuite(id, "TLS_CHACHA20_POLY1305_SHA256", HashAlgorithmName.SHA256, 32, 32, chaCha: true), + _ => null + }; +} + +/// +/// One direction's record protection: a key, a static IV, and the sequence number that turns +/// them into a per-record nonce. +/// +/// +/// +/// The sequence number restarts at zero every time the keys change, which is why this type is +/// replaced wholesale at each epoch rather than reset — a stale counter surviving a key change is +/// a bug that shows up as "the first record after the handshake fails to decrypt". +/// +/// +internal sealed class TlsRecordProtection : IDisposable +{ + private readonly byte[] _iv; + private readonly AesGcm? _aes; + private readonly ChaCha20Poly1305? _chaCha; + private ulong _sequenceNumber; + + public TlsRecordProtection(TlsCipherSuite suite, ReadOnlySpan trafficSecret) + { + _iv = new byte[TlsCipherSuite.NonceLength]; + + // On the stack rather than the heap: this is a record-protection key, and the largest a + // TLS 1.3 suite uses is 32 bytes. A fixed frame keeps it out of the GC heap entirely, so + // there is no copy for a collection to move and no window before the clearing below. + Span key = stackalloc byte[32]; + key = key[..suite.KeyLength]; + + try + { + TlsKeySchedule.TrafficKeys(suite.Hash, trafficSecret, key, _iv); + + if (suite.IsChaCha) + _chaCha = new ChaCha20Poly1305(key); + else + _aes = new AesGcm(key, TlsCipherSuite.TagLength); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + /// + /// Encrypts one record's inner plaintext, producing the ciphertext and tag. + /// + /// Content plus the one-byte real content type. + /// Receives the ciphertext; same length as the plaintext. + /// Receives the 16-byte tag. + /// The outer record header, which is the additional data. + public void Protect( + ReadOnlySpan plaintext, Span ciphertext, Span tag, ReadOnlySpan header) + { + Span nonce = stackalloc byte[TlsCipherSuite.NonceLength]; + TlsKeySchedule.BuildNonce(nonce, _iv, _sequenceNumber++); + + if (_chaCha is not null) + _chaCha.Encrypt(nonce, plaintext, ciphertext, tag, header); + else + _aes!.Encrypt(nonce, plaintext, ciphertext, tag, header); + } + + /// Decrypts one record. + /// The ciphertext, without the tag. + /// The 16-byte tag. + /// Receives the inner plaintext. + /// The outer record header, which is the additional data. + public void Unprotect( + ReadOnlySpan ciphertext, ReadOnlySpan tag, Span plaintext, ReadOnlySpan header) + { + Span nonce = stackalloc byte[TlsCipherSuite.NonceLength]; + TlsKeySchedule.BuildNonce(nonce, _iv, _sequenceNumber++); + + try + { + if (_chaCha is not null) + _chaCha.Decrypt(nonce, ciphertext, tag, plaintext, header); + else + _aes!.Decrypt(nonce, ciphertext, tag, plaintext, header); + } + catch (CryptographicException ex) + { + // A record that does not authenticate is either corruption or a peer writing under + // keys we do not share. Either way the bytes are not from the session we set up. + throw new RealityHandshakeException( + "A TLS record from the peer failed authentication, so it was not produced under " + + "this session's keys. The connection cannot be trusted past this point.", ex); + } + } + + public void Dispose() + { + _aes?.Dispose(); + _chaCha?.Dispose(); + CryptographicOperations.ZeroMemory(_iv); + } +} + +/// +/// Reads and writes TLS records over a transport stream, applying protection once keys exist. +/// +/// +/// +/// Both directions are buffered, and for the same reason: a TLS record is a 5-byte header +/// followed by a body, and a transport asked for those separately pays two reads per record. +/// Reads pull as much as the transport has and hand out whole records from the buffer, so a +/// segment carrying four records costs one read rather than eight. Writes stage several records +/// into one buffer and hand the transport a single contiguous write. +/// +/// +/// Neither direction is safe for concurrent use — see . +/// +/// +internal sealed class TlsRecordStream(Stream transport) : IDisposable +{ + /// Largest plaintext a record may carry (RFC 8446 §5.1). + public const int MaxPlaintext = 16384; + + /// Largest ciphertext a record may carry: plaintext, content type, tag and slack. + public const int MaxCiphertext = MaxPlaintext + 256; + + private const int HeaderLength = 5; + + /// Bytes a record costs beyond its plaintext: header, inner content type, tag. + private const int RecordOverhead = HeaderLength + 1 + TlsCipherSuite.TagLength; + + /// + /// Capacity of each staging buffer, chosen so several full-size records fit in one. + /// + /// + /// 64 KiB is a pool bucket exactly, and it holds three maximum-size records with room to + /// spare — which is what makes one write per caller's write, and one read per several + /// records, the normal case rather than the lucky one. It must stay at or above + /// plus a header, or a maximum-size record could never be + /// assembled at all. + /// + private const int BufferCapacity = 64 * 1024; + + /// Bytes received from the transport and not yet handed out as records. + private readonly byte[] _inbound = ArrayPool.Shared.Rent(BufferCapacity); + + /// Records being staged for the next write: headers, ciphertexts and tags. + private readonly byte[] _outbound = ArrayPool.Shared.Rent(BufferCapacity); + + /// Where an inbound record is opened: the plaintext handed back to the caller. + /// + /// + /// Records could be opened in place, over the ciphertext in — an AEAD + /// writes one byte of output per byte of input, and .NET's implementations do tolerate an + /// output span that is exactly the input. They are not documented to: + /// specifies only that plaintext and ciphertext are the + /// same length and says nothing about overlap. That is a bet on an implementation detail of + /// every platform's crypto library, and measured against this record layer it bought about + /// one percent — the AEAD itself is the other ninety-nine. + /// + /// + /// It also keeps the promise on cheap: the payload points here, and here + /// is not the buffer that the next transport read compacts. + /// + /// + private readonly byte[] _plaintext = ArrayPool.Shared.Rent(MaxCiphertext); + + /// Where an outbound record's plaintext is staged before it is sealed. + /// + /// Separate from rather than one shared scratch buffer, because the + /// two directions are independent: a relay writes while a record it has read is still being + /// consumed, and sharing would let a write overwrite a payload the reader is holding — data + /// corruption on exactly the traffic pattern a proxy exists to serve. + /// + private readonly byte[] _outboundPlain = ArrayPool.Shared.Rent(MaxPlaintext + 1); + + /// Start of the unconsumed span of . + private int _start; + + /// End of the unconsumed span of . + private int _end; + + private bool _disposed; + + /// Protection for outgoing records, or null while still in the clear. + public TlsRecordProtection? Write { get; set; } + + /// Protection for incoming records, or null while still in the clear. + public TlsRecordProtection? Read { get; set; } + + /// One record's worth of content. + /// The real content type, after any inner-type unwrapping. + /// The content; a slice of an internal buffer, valid until the next read. + public readonly record struct Record(TlsContentType Type, ReadOnlyMemory Payload); + + /// Reads one record, decrypting it when read protection is installed. + /// Cancels the read. + /// + /// Completes synchronously whenever the record is already buffered, which within a burst it + /// usually is: that path never touches the transport and never builds an async state machine, + /// so a segment carrying four records costs one awaited read and three returns that do not + /// yield. + /// + public ValueTask ReadAsync(CancellationToken cancellationToken) + { + if (TryReadBuffered(out Record record)) + return new ValueTask(record); + + return ReadFromTransportAsync(cancellationToken); + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] + private async ValueTask ReadFromTransportAsync(CancellationToken cancellationToken) + { + while (true) + { + // Compacted before a transport read, never after handing a record out: the payload + // just returned aliases this buffer, and moving it early would pull the ground out + // from under a caller that has not finished with it. + if (_start > 0) + { + _inbound.AsSpan(_start, _end - _start).CopyTo(_inbound); + _end -= _start; + _start = 0; + } + + int read = await transport + .ReadAsync(_inbound.AsMemory(_end, _inbound.Length - _end), cancellationToken) + .ConfigureAwait(false); + + // Reported the way ReadExactlyAsync used to report it, because the layer above treats + // EndOfStreamException as an orderly end of stream. + if (read == 0) + throw new EndOfStreamException("The peer closed the connection mid-record."); + + _end += read; + + if (TryReadBuffered(out Record record)) + return record; + } + } + + /// Takes one whole record out of the inbound buffer, if a whole one is there. + private bool TryReadBuffered(out Record record) + { + record = default; + + int available = _end - _start; + if (available < HeaderLength) + return false; + + ReadOnlySpan header = _inbound.AsSpan(_start, HeaderLength); + int length = (header[3] << 8) | header[4]; + + // Refused as soon as the header is readable rather than after the body arrives: an + // oversized record is the peer's error either way, and waiting for bytes we would throw + // away only delays the failure — and, on a hostile peer, only buys it more of our time. + if (length > MaxCiphertext) + throw new RealityHandshakeException( + $"The peer sent a {length}-byte TLS record, over the {MaxCiphertext}-byte limit of RFC 8446."); + + if (available < HeaderLength + length) + return false; + + var type = (TlsContentType)header[0]; + int bodyStart = _start + HeaderLength; + _start += HeaderLength + length; + + // ChangeCipherSpec is never encrypted and carries no meaning in TLS 1.3; it exists only so + // middleboxes see a familiar handshake. Passing it through as content would corrupt the + // handshake transcript, so it is surfaced as-is for the caller to drop. + if (Read is null || type == TlsContentType.ChangeCipherSpec) + { + record = new Record(type, _inbound.AsMemory(bodyStart, length)); + return true; + } + + if (length < TlsCipherSuite.TagLength) + throw new RealityHandshakeException("The peer sent an encrypted TLS record shorter than its own tag."); + + int contentLength = length - TlsCipherSuite.TagLength; + + Read.Unprotect( + _inbound.AsSpan(bodyStart, contentLength), + _inbound.AsSpan(bodyStart + contentLength, TlsCipherSuite.TagLength), + _plaintext.AsSpan(0, contentLength), + header); + + // The real content type is the last non-zero byte: TLS 1.3 hides it behind zero padding. + // LastIndexOfAnyExcept is vectorised in the BCL; the byte loop it replaces was O(padding), + // which a peer could make 16 KiB long. Returns -1 for an all-zero record, so the + // no-content-type case below is reached identically. + int end = _plaintext.AsSpan(0, contentLength).LastIndexOfAnyExcept((byte)0) + 1; + + if (end == 0) + throw new RealityHandshakeException("The peer sent a TLS record with no content type."); + + record = new Record((TlsContentType)_plaintext[end - 1], _plaintext.AsMemory(0, end - 1)); + return true; + } + + /// Writes one record, encrypting it when write protection is installed. + /// The content type. + /// The content. + /// Cancels the write. + /// + /// Not reentrant: records are staged in a shared buffer, and the protection's sequence + /// number advances per record. Two concurrent writers already produced records the peer could + /// not order, so this narrows an existing hazard rather than adding one — but it is worth + /// stating, because the symptom changes from a bad sequence number to interleaved plaintext. + /// + public ValueTask WriteAsync( + TlsContentType type, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + // The staging buffer holds exactly one record of this size. A larger payload would + // silently truncate the length field, so it is refused rather than corrected. + if (payload.Length > MaxPlaintext) + throw new ArgumentOutOfRangeException( + nameof(payload), payload.Length, $"A TLS record carries at most {MaxPlaintext} bytes."); + + int staged = StageRecord(type, payload.Span, _outbound); + + return SendStagedAsync(staged, cancellationToken); + } + + /// + /// Writes application data as as few transport writes as the staging buffer allows. + /// + /// The content, split across records when it exceeds one. + /// Cancels the write. + /// + /// The record split is forced by RFC 8446 §5.1; the write split is not. A 64 KiB write becomes + /// four records, and handing those to the transport one at a time is four writes and four + /// flushes to satisfy one caller — so they are staged contiguously and sent together. + /// + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + public async ValueTask WriteApplicationDataAsync( + ReadOnlyMemory payload, CancellationToken cancellationToken) + { + while (!payload.IsEmpty) + { + int staged = 0; + + while (!payload.IsEmpty && _outbound.Length - staged > RecordOverhead) + { + int chunk = Math.Min( + Math.Min(payload.Length, MaxPlaintext), + _outbound.Length - staged - RecordOverhead); + + staged += StageRecord( + TlsContentType.ApplicationData, payload.Span[..chunk], _outbound.AsSpan(staged)); + + payload = payload[chunk..]; + } + + await transport.WriteAsync(_outbound.AsMemory(0, staged), cancellationToken).ConfigureAwait(false); + } + + await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))] + private async ValueTask SendStagedAsync(int staged, CancellationToken cancellationToken) + { + await transport.WriteAsync(_outbound.AsMemory(0, staged), cancellationToken).ConfigureAwait(false); + await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + /// Frames one record into ; returns the bytes written. + private int StageRecord(TlsContentType type, ReadOnlySpan payload, Span destination) + { + if (Write is null) + { + destination[0] = (byte)type; + destination[1] = 3; + destination[2] = payload.Length > 0 && type == TlsContentType.Handshake ? (byte)1 : (byte)3; + destination[3] = (byte)(payload.Length >> 8); + destination[4] = (byte)payload.Length; + payload.CopyTo(destination[HeaderLength..]); + + return HeaderLength + payload.Length; + } + + // An encrypted record always announces itself as application_data; the real type rides + // inside, after the content. + int inner = payload.Length + 1; + destination[0] = (byte)TlsContentType.ApplicationData; + destination[1] = 3; + destination[2] = 3; + destination[3] = (byte)((inner + TlsCipherSuite.TagLength) >> 8); + destination[4] = (byte)(inner + TlsCipherSuite.TagLength); + + // Staged where the AEAD's input and output cannot overlap — see _plaintext for why that + // is worth a copy — and sealed straight into the outbound buffer at the offset this + // record occupies, so the batch stays contiguous for one write. + payload.CopyTo(_outboundPlain); + _outboundPlain[payload.Length] = (byte)type; + + Write.Protect( + _outboundPlain.AsSpan(0, inner), + destination.Slice(HeaderLength, inner), + destination.Slice(HeaderLength + inner, TlsCipherSuite.TagLength), + destination[..HeaderLength]); + + return HeaderLength + inner + TlsCipherSuite.TagLength; + } + + /// Releases the pooled buffers and the AEAD instances. + /// + /// The guard is load-bearing now that the buffers are rented: this type is disposed twice on + /// the failure path — once by the handshake's catch, once by the stream that wraps it — and + /// returning the same array to the pool twice would hand one connection's buffer to another. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + Read?.Dispose(); + Write?.Dispose(); + + // Cleared on return: these held decrypted application data, and the pool hands the same + // array to whoever rents next. + ArrayPool.Shared.Return(_inbound, clearArray: true); + ArrayPool.Shared.Return(_outbound, clearArray: true); + ArrayPool.Shared.Return(_plaintext, clearArray: true); + ArrayPool.Shared.Return(_outboundPlain, clearArray: true); + } +} diff --git a/QuickProxyNet/Internal/Reality/TlsWriter.cs b/QuickProxyNet/Internal/Reality/TlsWriter.cs new file mode 100644 index 0000000..fd8c886 --- /dev/null +++ b/QuickProxyNet/Internal/Reality/TlsWriter.cs @@ -0,0 +1,120 @@ +namespace QuickProxyNet; + +/// +/// A minimal writer for TLS's length-prefixed wire format. +/// +/// +/// TLS nests variable-length vectors whose length is written before the contents are known, so +/// every serialiser needs the same backpatching dance. Doing it by hand at each call site is how +/// off-by-one length bugs get in — and a ClientHello with a wrong inner length is rejected with +/// no useful diagnostic, because the peer simply sees a malformed record. +/// +internal sealed class TlsWriter(int capacity = 512) +{ + private byte[] _buffer = new byte[capacity]; + private int _position; + + /// Number of bytes written so far. + public int Length => _position; + + /// The bytes written so far, as a span into the internal buffer. + public Span Written => _buffer.AsSpan(0, _position); + + public void WriteByte(byte value) + { + Ensure(1); + _buffer[_position++] = value; + } + + public void WriteUInt16(ushort value) + { + Ensure(2); + _buffer[_position++] = (byte)(value >> 8); + _buffer[_position++] = (byte)value; + } + + public void Write(ReadOnlySpan value) + { + Ensure(value.Length); + value.CopyTo(_buffer.AsSpan(_position)); + _position += value.Length; + } + + /// Writes zero bytes. + public void WriteZeros(int count) + { + Ensure(count); + _buffer.AsSpan(_position, count).Clear(); + _position += count; + } + + /// Reserves a one-byte length prefix; pass the result to . + public int BeginVector8() + { + WriteByte(0); + return _position; + } + + /// Reserves a two-byte length prefix; pass the result to . + public int BeginVector16() + { + WriteUInt16(0); + return _position; + } + + /// Reserves a three-byte length prefix; pass the result to . + public int BeginVector24() + { + WriteByte(0); + WriteUInt16(0); + return _position; + } + + /// Backpatches the length of the vector that started at . + /// The value returned by the matching BeginVector*. + /// 1, 2 or 3 — must match the BeginVector* that was used. + public void EndVector(int marker, int prefixSize) + { + int length = _position - marker; + int start = marker - prefixSize; + + switch (prefixSize) + { + case 1: + if (length > byte.MaxValue) + throw new InvalidOperationException($"A one-byte vector cannot hold {length} bytes."); + _buffer[start] = (byte)length; + break; + + case 2: + if (length > ushort.MaxValue) + throw new InvalidOperationException($"A two-byte vector cannot hold {length} bytes."); + _buffer[start] = (byte)(length >> 8); + _buffer[start + 1] = (byte)length; + break; + + case 3: + if (length > 0xFFFFFF) + throw new InvalidOperationException($"A three-byte vector cannot hold {length} bytes."); + _buffer[start] = (byte)(length >> 16); + _buffer[start + 1] = (byte)(length >> 8); + _buffer[start + 2] = (byte)length; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(prefixSize), prefixSize, "Prefixes are 1, 2 or 3 bytes."); + } + } + + /// Copies the written bytes into a new array. + public byte[] ToArray() => _buffer.AsSpan(0, _position).ToArray(); + + private void Ensure(int additional) + { + if (_position + additional <= _buffer.Length) + return; + + int capacity = Math.Max(_buffer.Length * 2, _position + additional); + Array.Resize(ref _buffer, capacity); + } +} diff --git a/QuickProxyNet/Internal/Reality/X25519.cs b/QuickProxyNet/Internal/Reality/X25519.cs new file mode 100644 index 0000000..b894d41 --- /dev/null +++ b/QuickProxyNet/Internal/Reality/X25519.cs @@ -0,0 +1,466 @@ +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// X25519 scalar multiplication (RFC 7748), for the key exchange REALITY hides inside the TLS +/// key_share. +/// +/// +/// +/// Why this exists at all. .NET 11 added X25519DiffieHellman; .NET 8, 9 and 10 have +/// no X25519 anywhere in the BCL. The library targets all four, and REALITY without X25519 is not +/// REALITY, so the older targets need an implementation. Correctness is checkable against the RFC +/// 7748 vectors and against a real Xray server, which is what the tests do. +/// +/// +/// Representation. Field elements are five 51-bit limbs in a Span<ulong>, the +/// standard radix-2^51 layout: products fit in without overflow, and the +/// reduction of 2^255 - 19 becomes a multiply by 19 on the wrapped limb. +/// +/// +/// What this is not. The Montgomery ladder below is written to run the same sequence of +/// operations regardless of the scalar — the conditional swap is arithmetic, not a branch — but +/// this is managed code on a JIT, and it makes no claim of being constant-time against a local +/// attacker measuring cache or timing. For REALITY's use, the secret is a per-connection +/// ephemeral key and the adversary is on the network, so that is the right trade. It would not be +/// for a long-lived signing key. +/// +/// +internal static class X25519 +{ + /// Length in bytes of a scalar, a public key and a shared secret. + public const int KeySize = 32; + + private const int Limbs = 5; + private const ulong Mask51 = (1UL << 51) - 1; + + /// The canonical base point, u = 9. + private static ReadOnlySpan BasePoint => + [ + 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ]; + + /// Generates an ephemeral key pair. + /// Receives the clamped private scalar; 32 bytes. + /// Receives the corresponding u-coordinate; 32 bytes. + public static void GenerateKeyPair(Span privateKey, Span publicKey) + { + if (privateKey.Length != KeySize || publicKey.Length != KeySize) + throw new ArgumentException($"X25519 keys are {KeySize} bytes."); + + RandomNumberGenerator.Fill(privateKey); + Clamp(privateKey); + ScalarMultiply(publicKey, privateKey, BasePoint); + } + + /// Computes the public key for an existing private scalar. + /// Receives the u-coordinate; 32 bytes. + /// The private scalar; 32 bytes. + public static void GetPublicKey(Span publicKey, ReadOnlySpan privateKey) => + ScalarMultiply(publicKey, privateKey, BasePoint); + + /// + /// Computes the shared secret for and . + /// + /// Receives the shared secret; 32 bytes. + /// Our private scalar; 32 bytes. + /// The peer's u-coordinate; 32 bytes. + /// + /// The result is all zeroes, which means the peer supplied a low-order point. RFC 7748 §6.1 + /// requires rejecting it: continuing would derive a key an attacker already knows. + /// + public static void Agree(Span sharedSecret, ReadOnlySpan privateKey, ReadOnlySpan peerPublicKey) + { + ScalarMultiply(sharedSecret, privateKey, peerPublicKey); + + byte accumulated = 0; + for (int i = 0; i < KeySize; i++) + accumulated |= sharedSecret[i]; + + if (accumulated == 0) + { + CryptographicOperations.ZeroMemory(sharedSecret); + throw new CryptographicException( + "X25519 produced an all-zero shared secret, which means the peer's public key was a " + + "low-order point. RFC 7748 requires rejecting it."); + } + } + + /// Applies the RFC 7748 clamping to a private scalar, in place. + public static void Clamp(Span scalar) + { + scalar[0] &= 248; + scalar[31] &= 127; + scalar[31] |= 64; + } + + /// The Montgomery ladder: = scalar · u. + private static void ScalarMultiply(Span result, ReadOnlySpan scalar, ReadOnlySpan u) + { + if (result.Length != KeySize || scalar.Length != KeySize || u.Length != KeySize) + throw new ArgumentException($"X25519 operands are {KeySize} bytes."); + + Span clamped = stackalloc byte[KeySize]; + scalar.CopyTo(clamped); + Clamp(clamped); + + Span x1 = stackalloc ulong[Limbs]; + Span x2 = stackalloc ulong[Limbs]; + Span z2 = stackalloc ulong[Limbs]; + Span x3 = stackalloc ulong[Limbs]; + Span z3 = stackalloc ulong[Limbs]; + Span a = stackalloc ulong[Limbs]; + Span b = stackalloc ulong[Limbs]; + Span c = stackalloc ulong[Limbs]; + Span d = stackalloc ulong[Limbs]; + Span e = stackalloc ulong[Limbs]; + + Decode(x1, u); + + Zero(x2); + x2[0] = 1; // x2 = 1 + Zero(z2); // z2 = 0 + x1.CopyTo(x3); // x3 = u + Zero(z3); + z3[0] = 1; // z3 = 1 + + ulong swap = 0; + + for (int position = 254; position >= 0; position--) + { + ulong bit = (ulong)((clamped[position >> 3] >> (position & 7)) & 1); + swap ^= bit; + ConditionalSwap(x2, x3, swap); + ConditionalSwap(z2, z3, swap); + swap = bit; + + // The RFC 7748 §5 ladder step, verbatim. + Sub(a, x2, z2); // a = x2 - z2 + Add(b, x2, z2); // b = x2 + z2 + Sub(c, x3, z3); // c = x3 - z3 + Add(d, x3, z3); // d = x3 + z3 + Mul(c, c, b); // c = (x3 - z3)(x2 + z2) + Mul(d, d, a); // d = (x3 + z3)(x2 - z2) + Add(e, c, d); + Sub(c, c, d); + Sqr(x3, e); // x3 = (c + d)^2 + Sqr(z3, c); // z3 = (c - d)^2 + Mul(z3, z3, x1); // z3 *= u + Sqr(e, a); // e = BB = (x2 - z2)^2 + Sqr(c, b); // c = AA = (x2 + z2)^2 + Mul(x2, e, c); // x2 = AA·BB + Sub(b, c, e); // b = E = AA - BB (A is no longer needed) + MulSmall(d, b, 121665); + Add(d, d, c); // d = AA + a24·E — AA, not BB: a24 = (486662 - 2)/4 only + // balances the doubling formula against AA. + Mul(z2, b, d); // z2 = E·(AA + a24·E) + } + + ConditionalSwap(x2, x3, swap); + ConditionalSwap(z2, z3, swap); + + Invert(z2, z2); + Mul(x2, x2, z2); + Encode(result, x2); + + CryptographicOperations.ZeroMemory(clamped); + } + + private static void Zero(Span fe) + { + for (int i = 0; i < Limbs; i++) + fe[i] = 0; + } + + /// Loads 32 little-endian bytes into five 51-bit limbs, masking bit 255. + private static void Decode(Span fe, ReadOnlySpan bytes) + { + ulong low = BinaryPrimitives.ReadUInt64LittleEndian(bytes); + ulong second = BinaryPrimitives.ReadUInt64LittleEndian(bytes[8..]); + ulong third = BinaryPrimitives.ReadUInt64LittleEndian(bytes[16..]); + ulong high = BinaryPrimitives.ReadUInt64LittleEndian(bytes[24..]); + + fe[0] = low & Mask51; + fe[1] = ((low >> 51) | (second << 13)) & Mask51; + fe[2] = ((second >> 38) | (third << 26)) & Mask51; + fe[3] = ((third >> 25) | (high << 39)) & Mask51; + // RFC 7748 §5: the most significant bit of the u-coordinate is ignored on decode. + fe[4] = (high >> 12) & Mask51; + } + + /// Fully reduces and stores a field element as 32 little-endian bytes. + private static void Encode(Span bytes, Span fe) + { + Carry(fe); + Carry(fe); + Carry(fe); + + // Conditionally subtract p = 2^255 - 19 so the output is the canonical representative. + ulong q = (fe[0] + 19) >> 51; + q = (fe[1] + q) >> 51; + q = (fe[2] + q) >> 51; + q = (fe[3] + q) >> 51; + q = (fe[4] + q) >> 51; + + fe[0] += 19 * q; + + fe[1] += fe[0] >> 51; fe[0] &= Mask51; + fe[2] += fe[1] >> 51; fe[1] &= Mask51; + fe[3] += fe[2] >> 51; fe[2] &= Mask51; + fe[4] += fe[3] >> 51; fe[3] &= Mask51; + fe[4] &= Mask51; + + ulong low = fe[0] | (fe[1] << 51); + ulong second = (fe[1] >> 13) | (fe[2] << 38); + ulong third = (fe[2] >> 26) | (fe[3] << 25); + ulong high = (fe[3] >> 39) | (fe[4] << 12); + + BinaryPrimitives.WriteUInt64LittleEndian(bytes, low); + BinaryPrimitives.WriteUInt64LittleEndian(bytes[8..], second); + BinaryPrimitives.WriteUInt64LittleEndian(bytes[16..], third); + BinaryPrimitives.WriteUInt64LittleEndian(bytes[24..], high); + } + + private static void Add(Span result, Span left, Span right) + { + for (int i = 0; i < Limbs; i++) + result[i] = left[i] + right[i]; + } + + /// + /// Subtraction with a 2p bias, so every limb stays non-negative without a borrow chain. + /// + private static void Sub(Span result, Span left, Span right) + { + // 2p in limb form: 2*(2^51 - 19) for limb 0 and 2*(2^51 - 1) elsewhere. Adding it before + // subtracting keeps the value congruent mod p and the limbs unsigned. + result[0] = left[0] + 0xFFFFFFFFFFFDAUL - right[0]; + result[1] = left[1] + 0xFFFFFFFFFFFFEUL - right[1]; + result[2] = left[2] + 0xFFFFFFFFFFFFEUL - right[2]; + result[3] = left[3] + 0xFFFFFFFFFFFFEUL - right[3]; + result[4] = left[4] + 0xFFFFFFFFFFFFEUL - right[4]; + Carry(result); + } + + /// The 128-bit product of two 64-bit limbs, as one machine multiply. + /// + /// The (UInt128)a * b this replaces reads as the same thing and is not: the JIT widens + /// both operands first and then runs the full 128x128 routine — three multiplies and the adds + /// that join them — because nothing in the expression tells it the high halves are zero. + /// is an intrinsic that compiles to the + /// single 64x64 multiply the hardware has. The field multiply below runs twenty-five of these + /// per call, and the ladder runs the field multiply about two thousand eight hundred times per + /// key exchange, so the difference is most of the cost of a REALITY handshake. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static UInt128 Wide(ulong left, ulong right) + { + ulong high = Math.BigMul(left, right, out ulong low); + + return new UInt128(high, low); + } + + private static void Mul(Span result, Span left, Span right) + { + ulong f0 = left[0], f1 = left[1], f2 = left[2], f3 = left[3], f4 = left[4]; + ulong g0 = right[0], g1 = right[1], g2 = right[2], g3 = right[3], g4 = right[4]; + + // Limbs above 4 wrap by 2^255 ≡ 19, so their contributions fold back multiplied by 19. + ulong g1_19 = 19 * g1; + ulong g2_19 = 19 * g2; + ulong g3_19 = 19 * g3; + ulong g4_19 = 19 * g4; + + UInt128 h0 = Wide(f0, g0) + Wide(f1, g4_19) + Wide(f2, g3_19) + Wide(f3, g2_19) + Wide(f4, g1_19); + UInt128 h1 = Wide(f0, g1) + Wide(f1, g0) + Wide(f2, g4_19) + Wide(f3, g3_19) + Wide(f4, g2_19); + UInt128 h2 = Wide(f0, g2) + Wide(f1, g1) + Wide(f2, g0) + Wide(f3, g4_19) + Wide(f4, g3_19); + UInt128 h3 = Wide(f0, g3) + Wide(f1, g2) + Wide(f2, g1) + Wide(f3, g0) + Wide(f4, g4_19); + UInt128 h4 = Wide(f0, g4) + Wide(f1, g3) + Wide(f2, g2) + Wide(f3, g1) + Wide(f4, g0); + + ulong carry = (ulong)(h0 >> 51); ulong r0 = (ulong)h0 & Mask51; + h1 += carry; carry = (ulong)(h1 >> 51); ulong r1 = (ulong)h1 & Mask51; + h2 += carry; carry = (ulong)(h2 >> 51); ulong r2 = (ulong)h2 & Mask51; + h3 += carry; carry = (ulong)(h3 >> 51); ulong r3 = (ulong)h3 & Mask51; + h4 += carry; carry = (ulong)(h4 >> 51); ulong r4 = (ulong)h4 & Mask51; + + r0 += 19 * carry; + r1 += r0 >> 51; r0 &= Mask51; + r2 += r1 >> 51; r1 &= Mask51; + + result[0] = r0; + result[1] = r1; + result[2] = r2; + result[3] = r3; + result[4] = r4; + } + + /// Squares a field element: = ^2. + /// + /// + /// A square is a multiply whose two operands are equal, so Mul(r, v, v) is correct and + /// this routine is not there for correctness. It is there because half of those twenty-five + /// products are then computed twice: f1·g2 and f2·g1 are the same number. Folding each pair + /// into one product doubled beforehand leaves ten multiplies instead of twenty-five. + /// + /// + /// It earns that on volume. Four of the roughly nine field multiplies in a ladder step are + /// squarings, and the inversion at the end is two hundred and fifty of them almost back to + /// back — together most of a key exchange. The formulation is the standard radix-2^51 one + /// (curve25519-donna-c64); X25519Test checks it against on + /// limb patterns chosen to sit right under the carry boundaries. + /// + /// + internal static void Sqr(Span result, Span value) + { + ulong f0 = value[0], f1 = value[1], f2 = value[2], f3 = value[3], f4 = value[4]; + + // The doubled and 19-folded operands each stand in for a pair of equal cross products. + ulong d0 = f0 * 2; + ulong d1 = f1 * 2; + ulong d2 = f2 * 2 * 19; + ulong d4_19 = f4 * 19; + ulong d4 = d4_19 * 2; + + UInt128 h0 = Wide(f0, f0) + Wide(d4, f1) + Wide(d2, f3); + UInt128 h1 = Wide(d0, f1) + Wide(d4, f2) + Wide(f3, f3 * 19); + UInt128 h2 = Wide(d0, f2) + Wide(f1, f1) + Wide(d4, f3); + UInt128 h3 = Wide(d0, f3) + Wide(d1, f2) + Wide(f4, d4_19); + UInt128 h4 = Wide(d0, f4) + Wide(d1, f3) + Wide(f2, f2); + + ulong carry = (ulong)(h0 >> 51); ulong r0 = (ulong)h0 & Mask51; + h1 += carry; carry = (ulong)(h1 >> 51); ulong r1 = (ulong)h1 & Mask51; + h2 += carry; carry = (ulong)(h2 >> 51); ulong r2 = (ulong)h2 & Mask51; + h3 += carry; carry = (ulong)(h3 >> 51); ulong r3 = (ulong)h3 & Mask51; + h4 += carry; carry = (ulong)(h4 >> 51); ulong r4 = (ulong)h4 & Mask51; + + r0 += 19 * carry; + r1 += r0 >> 51; r0 &= Mask51; + r2 += r1 >> 51; r1 &= Mask51; + + result[0] = r0; + result[1] = r1; + result[2] = r2; + result[3] = r3; + result[4] = r4; + } + + /// Multiplies a field element by a small scalar. + /// + /// Internal rather than private so tests can compare it against arbitrary-precision + /// arithmetic on adversarial limb patterns. A dropped carry mask here would be wrong by + /// exactly 2^51 for roughly one input in a billion — a defect no end-to-end test could + /// reach, and one that would surface as an unreproducible handshake failure. + /// + internal static void MulSmall(Span result, Span value, ulong scalar) + { + UInt128 h0 = Wide(value[0], scalar); + UInt128 h1 = Wide(value[1], scalar); + UInt128 h2 = Wide(value[2], scalar); + UInt128 h3 = Wide(value[3], scalar); + UInt128 h4 = Wide(value[4], scalar); + + ulong carry = (ulong)(h0 >> 51); ulong r0 = (ulong)h0 & Mask51; + h1 += carry; carry = (ulong)(h1 >> 51); ulong r1 = (ulong)h1 & Mask51; + h2 += carry; carry = (ulong)(h2 >> 51); ulong r2 = (ulong)h2 & Mask51; + h3 += carry; carry = (ulong)(h3 >> 51); ulong r3 = (ulong)h3 & Mask51; + h4 += carry; carry = (ulong)(h4 >> 51); ulong r4 = (ulong)h4 & Mask51; + + r0 += 19 * carry; + r1 += r0 >> 51; r0 &= Mask51; + + result[0] = r0; + result[1] = r1; + result[2] = r2; + result[3] = r3; + result[4] = r4; + } + + /// Multiplies two field elements. Internal for the same reason as . + internal static void MultiplyForTests(Span result, Span left, Span right) => + Mul(result, left, right); + + private static void Carry(Span fe) + { + ulong carry = fe[0] >> 51; fe[0] &= Mask51; + fe[1] += carry; carry = fe[1] >> 51; fe[1] &= Mask51; + fe[2] += carry; carry = fe[2] >> 51; fe[2] &= Mask51; + fe[3] += carry; carry = fe[3] >> 51; fe[3] &= Mask51; + fe[4] += carry; carry = fe[4] >> 51; fe[4] &= Mask51; + fe[0] += 19 * carry; + } + + /// + /// Swaps two field elements when is 1, arithmetically. + /// + /// + /// A branch here would leak the scalar's bits through timing, which is the classic way a + /// textbook ladder becomes a key-recovery oracle. The mask makes both cases do identical work. + /// + private static void ConditionalSwap(Span left, Span right, ulong swap) + { + ulong mask = 0UL - swap; + for (int i = 0; i < Limbs; i++) + { + ulong difference = mask & (left[i] ^ right[i]); + left[i] ^= difference; + right[i] ^= difference; + } + } + + /// Computes the multiplicative inverse via z^(p-2), the standard addition chain. + private static void Invert(Span result, Span z) + { + Span z2 = stackalloc ulong[Limbs]; + Span z9 = stackalloc ulong[Limbs]; + Span z11 = stackalloc ulong[Limbs]; + Span z2_5_0 = stackalloc ulong[Limbs]; + Span z2_10_0 = stackalloc ulong[Limbs]; + Span z2_20_0 = stackalloc ulong[Limbs]; + Span z2_50_0 = stackalloc ulong[Limbs]; + Span z2_100_0 = stackalloc ulong[Limbs]; + Span t = stackalloc ulong[Limbs]; + + Sqr(z2, z); // 2 + Sqr(t, z2); // 4 + Sqr(t, t); // 8 + Mul(z9, t, z); // 9 + Mul(z11, z9, z2); // 11 + Sqr(t, z11); // 22 + Mul(z2_5_0, t, z9); // 2^5 - 2^0 + + Square(t, z2_5_0, 5); + Mul(z2_10_0, t, z2_5_0); + + Square(t, z2_10_0, 10); + Mul(z2_20_0, t, z2_10_0); + + Square(t, z2_20_0, 20); + Mul(t, t, z2_20_0); + + Square(t, t, 10); + Mul(z2_50_0, t, z2_10_0); + + Square(t, z2_50_0, 50); + Mul(z2_100_0, t, z2_50_0); + + Square(t, z2_100_0, 100); + Mul(t, t, z2_100_0); + + Square(t, t, 50); + Mul(t, t, z2_50_0); + + Square(t, t, 5); + Mul(result, t, z11); + } + + private static void Square(Span result, Span value, int times) + { + Sqr(result, value); + for (int i = 1; i < times; i++) + Sqr(result, result); + } +} diff --git a/QuickProxyNet/Internal/ShareLinkQuery.cs b/QuickProxyNet/Internal/ShareLinkQuery.cs new file mode 100644 index 0000000..280efbe --- /dev/null +++ b/QuickProxyNet/Internal/ShareLinkQuery.cs @@ -0,0 +1,32 @@ +namespace QuickProxyNet; + +/// +/// Helpers shared by the vless://, trojan:// and vmess:// query +/// scanners. +/// +internal static class ShareLinkQuery +{ + /// + /// Removes the amp; prefix left on a query key when the whole link was + /// HTML-escaped before being published. + /// + /// + /// Producers that paste share links into HTML emit &amp; as the parameter + /// separator. Splitting on & then yields keys such as amp;security and + /// amp;flow. Ignoring those as "unknown keys" is not harmless: a REALITY node + /// would parse as security=none with an empty flow, pass the + /// supported-configuration check, and connect in cleartext — sending the user's UUID + /// unencrypted to a server expecting a REALITY handshake. That is exactly the silent + /// downgrade these parsers refuse to make elsewhere. + /// + /// 68 vless and 10 trojan links in a 17k real-world corpus arrive this + /// way, 51 of them REALITY. + /// + /// + /// This is safe to strip unconditionally: a literal & inside a value must be + /// percent-encoded as %26, so an unescaped & is always a separator. + /// + /// + public static ReadOnlySpan StripHtmlAmpPrefix(ReadOnlySpan key) + => key.StartsWith("amp;", StringComparison.OrdinalIgnoreCase) ? key[4..] : key; +} diff --git a/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs new file mode 100644 index 0000000..93ca4c9 --- /dev/null +++ b/QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs @@ -0,0 +1,251 @@ +using System.Buffers; +using System.Buffers.Text; +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet; + +/// +/// The HTTP/1.1 Upgrade exchange shared by the ws and httpupgrade +/// transports. +/// +/// +/// Both advertise Upgrade: websocket — that is what makes httpupgrade look +/// ordinary to a middlebox — but only the ws transport may send the +/// Sec-WebSocket-* headers. +/// +/// Sending them on httpupgrade looks harmless and is not: sing-box routes any request +/// carrying Sec-WebSocket-Key to its WebSocket handler, which an httpupgrade inbound +/// does not have, and answers 404. Xray accepts either form, so a single server would +/// have blessed the bug — it was caught by running both. Measured directly against +/// sing-box 1.13: the key alone triggers it, while Sec-WebSocket-Version on its own +/// still upgrades. +/// +/// +internal static class HttpUpgradeHandshake +{ + /// RFC 6455 section 1.3 — the constant the server mixes into the accept token. + private static ReadOnlySpan WebSocketGuid => "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"u8; + + /// Length of base64(16 bytes) — the Sec-WebSocket-Key. + private const int KeyBase64Length = 24; + + /// Length of base64(SHA-1) — the Sec-WebSocket-Accept. + private const int AcceptBase64Length = 28; + + /// + /// Performs the upgrade and returns the stream to continue on. + /// + /// The already-connected (and, for TLS modes, already-encrypted) stream. + /// Request target, sent verbatim — including any query such as ?ed=2048. + /// Value for the Host header. + /// + /// True for the ws transport: send the Sec-WebSocket-* headers and require the + /// server to echo a matching Sec-WebSocket-Accept. False for httpupgrade, + /// which must send neither (see the type remarks). + /// + /// Cancels the handshake. + public static async ValueTask PerformAsync( + Stream stream, + string path, + string hostHeader, + bool webSocket, + CancellationToken cancellationToken) + { + // The expected accept token is computed here, before the first await: a stackalloc'd + // Span cannot live across one, and hoisting the raw key into the async state machine + // to recompute it later would keep it alive for no reason. + var (request, length, expectedAccept) = BuildRequest(path, hostHeader, webSocket); + try + { + await stream.WriteAsync(request.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + } + finally + { + // The request carries no secret, but returning it cleared costs nothing here: + // this runs once per connection, not on the data path. + ArrayPool.Shared.Return(request, clearArray: true); + } + + var parser = new HttpResponseParser(); + try + { + bool found; + do + { + Memory memory = parser.GetMemory(); + int read = await stream.ReadAsync(memory, cancellationToken).ConfigureAwait(false); + if (read <= 0) + throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + "The proxy closed the connection during the HTTP upgrade handshake."); + found = parser.Parse(read); + } while (!found); + + int status = parser.GetStatusCode(); + if (status != 101) + { + throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + status < 0 + ? "The proxy returned a malformed HTTP response to the upgrade request." + : $"The proxy refused the HTTP upgrade with status {status} (expected 101). " + + "The configured path is the usual cause: a WebSocket server only upgrades " + + "on the exact path it was configured with."); + } + + if (expectedAccept is not null) + ValidateAccept(parser.Headers, expectedAccept); + + // The server may pipeline the first frames straight after the header block. + return PrefixedStream.WrapIfNeeded(parser.OverreadBytes, stream); + } + finally + { + parser.Dispose(); + } + } + + private static void ValidateAccept(ReadOnlySpan headers, ReadOnlySpan expected) + { + if (!TryGetHeaderValue(headers, "sec-websocket-accept"u8, out ReadOnlySpan actual)) + throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + "The proxy accepted the upgrade but sent no Sec-WebSocket-Accept header, so it is " + + "not a WebSocket endpoint."); + + if (!actual.SequenceEqual(expected)) + throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + "The proxy's Sec-WebSocket-Accept did not match the challenge; the peer is not " + + "speaking WebSocket (an intercepting middlebox is the usual cause)."); + } + + /// + /// Finds a header value by ASCII case-insensitive name. must be + /// lowercase. + /// + private static bool TryGetHeaderValue( + ReadOnlySpan headers, ReadOnlySpan name, out ReadOnlySpan value) + { + // Skip the status line; header fields start after the first CRLF. + int start = headers.IndexOf("\r\n"u8); + if (start < 0) + { + value = default; + return false; + } + headers = headers[(start + 2)..]; + + while (!headers.IsEmpty) + { + int eol = headers.IndexOf("\r\n"u8); + ReadOnlySpan line = eol < 0 ? headers : headers[..eol]; + headers = eol < 0 ? default : headers[(eol + 2)..]; + + if (line.IsEmpty) + break; + + int colon = line.IndexOf((byte)':'); + if (colon < 0 || colon != name.Length) + continue; + + if (!EqualsIgnoreAsciiCase(line[..colon], name)) + continue; + + value = Trim(line[(colon + 1)..]); + return true; + } + + value = default; + 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. + /// + private static (byte[] buffer, int length, byte[]? expectedAccept) BuildRequest( + string path, string hostHeader, bool webSocket) + { + // The challenge is 16 random bytes; the server must echo back base64(SHA1(key || GUID)). + Span keyBytes = stackalloc byte[16]; + Span key = stackalloc byte[KeyBase64Length]; + byte[]? expectedAccept = null; + + if (webSocket) + { + RandomNumberGenerator.Fill(keyBytes); + Base64.EncodeToUtf8(keyBytes, key, out _, out _); + + Span challenge = stackalloc byte[KeyBase64Length + 36]; + key.CopyTo(challenge); + WebSocketGuid.CopyTo(challenge[KeyBase64Length..]); + + Span digest = stackalloc byte[20]; + // SHA-1 is not a security choice here: RFC 6455 fixes it as the handshake token, and + // the token proves only that the peer parsed the request, never authenticity. TLS + // provides whatever authenticity this connection has. +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms + SHA1.HashData(challenge, digest); +#pragma warning restore CA5350 + + expectedAccept = new byte[AcceptBase64Length]; + Base64.EncodeToUtf8(digest, expectedAccept, out _, out _); + } + + int size = + 4 + Encoding.UTF8.GetMaxByteCount(path.Length) + 11 + // "GET " path " HTTP/1.1\r\n" + 6 + Encoding.UTF8.GetMaxByteCount(hostHeader.Length) + 2 + + 100 + // fixed headers below + KeyBase64Length + 2; + + byte[] buffer = ArrayPool.Shared.Rent(size); + int pos = 0; + + Write(buffer, ref pos, "GET "u8); + pos += Encoding.UTF8.GetBytes(path, buffer.AsSpan(pos)); + Write(buffer, ref pos, " HTTP/1.1\r\nHost: "u8); + pos += Encoding.UTF8.GetBytes(hostHeader, buffer.AsSpan(pos)); + Write(buffer, ref pos, "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"u8); + + if (webSocket) + { + Write(buffer, ref pos, "Sec-WebSocket-Key: "u8); + Write(buffer, ref pos, key); + Write(buffer, ref pos, "\r\nSec-WebSocket-Version: 13\r\n"u8); + } + + Write(buffer, ref pos, "\r\n"u8); + + return (buffer, pos, expectedAccept); + + static void Write(byte[] buffer, ref int pos, ReadOnlySpan value) + { + value.CopyTo(buffer.AsSpan(pos)); + pos += value.Length; + } + } +} diff --git a/QuickProxyNet/Internal/Transports/ProxyTransport.cs b/QuickProxyNet/Internal/Transports/ProxyTransport.cs new file mode 100644 index 0000000..8911b9c --- /dev/null +++ b/QuickProxyNet/Internal/Transports/ProxyTransport.cs @@ -0,0 +1,120 @@ +namespace QuickProxyNet; + +/// +/// The stream transport a VPN-style outbound is carried over, underneath the protocol header +/// and above TLS. +/// +internal enum TransportKind +{ + /// Raw TCP (tcp, raw, or unspecified) — no extra layer. + RawTcp, + + /// RFC 6455 WebSocket (ws, websocket). + WebSocket, + + /// Bare HTTP upgrade with no framing (httpupgrade). + HttpUpgrade, + + /// A transport this library does not speak (grpc, xhttp, h2, …). + Unsupported +} + +/// +/// Resolves and applies the transport layer shared by the VLESS, VMess and Trojan clients. +/// +/// +/// The layering is the same for all three: socket → optional SslStream → transport → +/// protocol request header. The transport knows nothing about which protocol rides on it, +/// which is why one implementation serves all three. +/// +internal static class ProxyTransport +{ + public static TransportKind Resolve(string? transport) + { + if (string.IsNullOrEmpty(transport)) + return TransportKind.RawTcp; + + // 'raw' is Xray's newer name for 'tcp'; both mean no transport layer at all. + if (transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || + transport.Equals("raw", StringComparison.OrdinalIgnoreCase)) + return TransportKind.RawTcp; + + if (transport.Equals("ws", StringComparison.OrdinalIgnoreCase) || + transport.Equals("websocket", StringComparison.OrdinalIgnoreCase)) + return TransportKind.WebSocket; + + if (transport.Equals("httpupgrade", StringComparison.OrdinalIgnoreCase)) + return TransportKind.HttpUpgrade; + + return TransportKind.Unsupported; + } + + /// + /// Wraps in the requested transport, returning the stream the + /// protocol header should be written to. + /// + /// + /// On failure the caller still owns and must dispose it; nothing + /// here takes ownership until the returned wrapper exists. + /// + public static async ValueTask ApplyAsync( + TransportKind kind, + Stream stream, + string? path, + string hostHeader, + CancellationToken cancellationToken) + { + switch (kind) + { + case TransportKind.RawTcp: + return stream; + + case TransportKind.WebSocket: + { + Stream upgraded = await HttpUpgradeHandshake + .PerformAsync(stream, NormalizePath(path), hostHeader, webSocket: true, cancellationToken) + .ConfigureAwait(false); + return new WebSocketStream(upgraded); + } + + case TransportKind.HttpUpgrade: + // Not a WebSocket handshake: no Sec-WebSocket-* headers and nothing to validate. + // sing-box answers 404 if the key is present — see HttpUpgradeHandshake. + return await HttpUpgradeHandshake + .PerformAsync(stream, NormalizePath(path), hostHeader, webSocket: false, cancellationToken) + .ConfigureAwait(false); + + default: + throw new NotSupportedException($"Transport kind '{kind}' has no implementation."); + } + } + + /// + /// 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. + /// + public static string NormalizePath(string? path) + { + if (string.IsNullOrEmpty(path)) + return "/"; + + return path[0] == '/' ? path : "/" + path; + } + + /// + /// Picks the Host header: the explicit transport host, else the SNI, else the + /// server address — the same precedence Xray and sing-box apply. + /// + public static string ResolveHostHeader(string? hostHeader, string? sni, string serverHost) + { + if (!string.IsNullOrEmpty(hostHeader)) + return hostHeader; + if (!string.IsNullOrEmpty(sni)) + return sni; + return serverHost; + } +} diff --git a/QuickProxyNet/Internal/Transports/WebSocketStream.cs b/QuickProxyNet/Internal/Transports/WebSocketStream.cs new file mode 100644 index 0000000..f8f3be8 --- /dev/null +++ b/QuickProxyNet/Internal/Transports/WebSocketStream.cs @@ -0,0 +1,159 @@ +using System.Net.WebSockets; + +namespace QuickProxyNet; + +/// +/// Presents an RFC 6455 client WebSocket as a byte , which is what every +/// proxy protocol in this library expects to write its header into. +/// +/// +/// The framing, masking and control-frame handling come from the BCL's +/// (via ) rather than +/// from hand-rolled code here. That implementation is hardened and allocation-tuned; a +/// from-scratch framer would be a large surface of subtle, security-relevant bugs (mask +/// reuse, fragment reassembly, control frames interleaved mid-message) for no gain. +/// +/// Message boundaries are deliberately not preserved. A proxy tunnel is a byte stream: the +/// VLESS/VMess/Trojan header may land in one frame and the payload in another, and the server +/// concatenates them. Each +/// becomes exactly one binary frame, which is what Xray and sing-box do. +/// +/// +internal sealed class WebSocketStream : Stream +{ + /// Consecutive empty frames one read tolerates before giving up on the peer. + private const int MaxEmptyFrames = 64; + + private readonly WebSocket _webSocket; + private readonly Stream _inner; + private bool _receivedClose; + + public WebSocketStream(Stream inner) + { + _inner = inner; + _webSocket = WebSocket.CreateFromStream(inner, new WebSocketCreationOptions + { + IsServer = false, + // No keep-alive pings. A proxy tunnel is kept alive by the traffic on it, and an + // unsolicited ping is one more thing distinguishing this client from a browser. + KeepAliveInterval = TimeSpan.Zero + }); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Flush() => _inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _inner.FlushAsync(cancellationToken); + + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + if (_receivedClose || buffer.IsEmpty) + return 0; + + // A zero-length binary frame is legal and carries no data. Returning its 0 verbatim + // would tell the caller the stream ended, silently truncating the tunnel — so keep + // receiving until there are actual bytes or the peer closes. Bounded, because a peer + // sending nothing but empty frames (or pings, which the BCL answers inside ReceiveAsync) + // would otherwise keep this read from returning. + int emptyFrames = 0; + while (true) + { + ValueWebSocketReceiveResult result; + try + { + result = await _webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException ex) + { + // The upgrade succeeded long ago; this is the tunnel itself breaking, which a + // caller should treat like any dropped connection rather than retry the upgrade. + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"The WebSocket transport failed while reading: {ex.Message}", ex); + } + + if (result.MessageType == WebSocketMessageType.Close) + { + _receivedClose = true; + return 0; + } + + if (result.Count > 0) + return result.Count; + + if (++emptyFrames > MaxEmptyFrames) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"The WebSocket peer sent {MaxEmptyFrames} consecutive frames carrying no data."); + } + } + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + // Never emit an empty frame: it carries nothing and some servers treat it as a probe. + if (buffer.IsEmpty) + return; + + try + { + await _webSocket + .SendAsync(buffer, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) + .ConfigureAwait(false); + } + catch (WebSocketException ex) + { + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"The WebSocket transport failed while writing: {ex.Message}", ex); + } + } + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override Task ReadAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override Task WriteAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + // No closing handshake: it would need a round trip on a connection the caller is + // already done with, and every real client just drops the socket. + _webSocket.Abort(); + _webSocket.Dispose(); + + // CreateFromStream hands stream ownership to the WebSocket, so this is normally + // redundant — but Dispose is idempotent and leaking a socket is not worth the bet. + _inner.Dispose(); + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + _webSocket.Abort(); + _webSocket.Dispose(); + await _inner.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } +} diff --git a/QuickProxyNet/Internal/TrojanHelper.cs b/QuickProxyNet/Internal/TrojanHelper.cs new file mode 100644 index 0000000..f496df2 --- /dev/null +++ b/QuickProxyNet/Internal/TrojanHelper.cs @@ -0,0 +1,100 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet; + +/// +/// Builds and writes the Trojan request over an already-authenticated +/// . +/// +/// +/// Wire layout: +/// +/// hex(SHA224(password))(56) | CRLF | cmd(0x01) | atyp(1) | addr(var) | port(2 BE) | CRLF +/// +/// Trojan uses SOCKS5-style address type codes (0x01 IPv4 / 0x03 domain / 0x04 IPv6) and, +/// unlike VLESS, writes the port after the address. Trojan has no success response: on +/// auth/connect failure the server silently closes or falls back to masquerade, so the +/// helper only writes the request and never reads a reply. +/// +internal static class TrojanHelper +{ + private const byte CommandTcp = 0x01; + private const byte AtypIPv4 = 0x01; + private const byte AtypDomain = 0x03; + private const byte AtypIPv6 = 0x04; + private const byte CR = 0x0D; + private const byte LF = 0x0A; + + // hex(56) + CRLF(2) + cmd(1) + address(var) + port(2) + CRLF(2). + private const int MaxRequestSize = Sha224.HexSize + 2 + 1 + ProxyAddress.MaxLength + 2 + 2; + + // Passwords longer than this (in UTF-8 bytes) fall back to a pooled buffer; typical + // passwords fit the stack buffer and stay allocation-free. + private const int StackPasswordBytes = 256; + + internal static async ValueTask EstablishTrojanTunnelAsync( + Stream stream, TrojanOptions options, string host, int port, CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(MaxRequestSize); + try + { + int length = BuildRequest(buffer, options.Password, host, port); + await stream.WriteAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + } + finally + { + // The buffer holds hex(SHA224(password)) — in Trojan that hash IS the replayable + // credential, so clear it before the array goes back to the shared pool. + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + + /// + /// Writes the full Trojan request for : + /// into and returns the number of bytes written. + /// + internal static int BuildRequest(Span buffer, string password, string host, int port) + { + WritePasswordHash(password, buffer); + buffer[Sha224.HexSize] = CR; + buffer[Sha224.HexSize + 1] = LF; + + int offset = Sha224.HexSize + 2; + buffer[offset] = CommandTcp; + offset++; + + int addressLength = + ProxyAddress.WriteTypeAndAddress(host, buffer.Slice(offset), AtypIPv4, AtypDomain, AtypIPv6); + offset += addressLength; + + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(offset), (ushort)port); + offset += 2; + + buffer[offset] = CR; + buffer[offset + 1] = LF; + return offset + 2; + } + + private static void WritePasswordHash(string password, Span destinationAscii) + { + int maxBytes = Encoding.UTF8.GetMaxByteCount(password.Length); + byte[]? rented = maxBytes > StackPasswordBytes ? ArrayPool.Shared.Rent(maxBytes) : null; + Span pwd = rented ?? stackalloc byte[StackPasswordBytes]; + int written = 0; + try + { + written = Encoding.UTF8.GetBytes(password, pwd); + Sha224.WriteHexLower(pwd.Slice(0, written), destinationAscii); + } + finally + { + // Zero the raw password bytes (stack or pooled) before releasing. + CryptographicOperations.ZeroMemory(pwd.Slice(0, written)); + if (rented is not null) + ArrayPool.Shared.Return(rented); + } + } +} diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs new file mode 100644 index 0000000..3791d65 --- /dev/null +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -0,0 +1,549 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The xtls-rprx-vision framing that sits between the VLESS response header and the +/// payload: a stream that strips the server's padding frames and pads its own first write. +/// +/// +/// +/// A server whose user is configured with flow=xtls-rprx-vision does not answer in plain +/// VLESS. After the two-byte response header it sends the user's UUID once, then a run of +/// frames — command(1) + contentLen(2) + paddingLen(2), content, padding — until a frame +/// arrives with the end command, after which the connection is raw. Reading such a stream +/// as if it were plain VLESS appears to work: the first response usually still contains a +/// recognisable HTTP/1.1 200 a few bytes in. It is the reads after it that are quietly +/// corrupted, which is exactly the failure that looks like a server problem. +/// +/// +/// What this implements and what it does not. The padding protocol, in both directions. +/// Not the other half of Vision — the TLS-in-TLS detection that lets Xray splice a connection +/// into a raw copy after a few packets. That is a throughput optimisation with no effect on the +/// wire format either peer must accept, and leaving it out costs nothing but the optimisation. +/// +/// +/// The uplink is padded once, with the end command, and then runs raw. The server's +/// unpadding is driven by whether the first sixteen bytes it receives are the user's UUID, so a +/// single closing frame is a complete, legal conversation. Xray keeps padding for a few packets +/// while it decides whether it is carrying TLS; since this stream never claims to do the +/// splicing that decision feeds, the extra frames would be padding for its own sake. +/// +/// +internal sealed class VisionStream : Stream +{ + /// The flow identifier this stream implements. Any other flow is not this class's. + public const string FlowName = "xtls-rprx-vision"; + + private const int UuidSize = 16; + private const int HeaderSize = 5; + + private const byte CommandPaddingContinue = 0x00; + private const byte CommandPaddingEnd = 0x01; + + /// + /// "Stop padding, the rest of this connection is a direct copy." Xray sends it instead of + /// once it has decided the connection carries TLS, and real + /// servers reach that decision far more often than they send the end command — a client that + /// only honours end stays in framed mode forever and dies on the next header. + /// + private const byte CommandPaddingDirect = 0x02; + + /// Xray's buf.Size, which bounds one padded frame. + private const int MaxFrame = 8192; + + /// + /// Consecutive frames carrying padding and no content before the stream is declared + /// broken. Xray sends one or two; a peer sending them without end would otherwise keep a + /// read from ever returning. + /// + private const int MaxPaddingOnlyFrames = 64; + + private enum Mode + { + /// Nothing read yet: the leading UUID decides whether this stream is framed. + Undecided, + + /// Inside the padded frames. + Framed, + + /// Past the closing frame — everything from here is payload. + Raw + } + + private readonly Stream _inner; + private readonly bool _leaveInnerOpen; + private readonly byte[] _uuid; + + private byte[] _buffer; + private int _start; + private int _end; + + private Mode _mode = Mode.Undecided; + private byte _command = CommandPaddingContinue; + private int _remainingContent; + private int _remainingPadding; + private int _paddingOnlyFrames; + + private bool _uplinkPadded; + private bool _disposed; + + /// + /// Wraps , which must be positioned where the payload would + /// begin in a plain VLESS session — that is, after the response header. + /// + /// The VLESS session. + /// The user id, big-endian, as it went out in the request header. + /// When true, disposing this stream leaves the session open. + public VisionStream(Stream innerStream, ReadOnlySpan uuid, bool leaveInnerOpen = false) + { + ArgumentNullException.ThrowIfNull(innerStream); + + if (uuid.Length != UuidSize) + throw new ArgumentException($"A VLESS user id is {UuidSize} bytes.", nameof(uuid)); + + _inner = innerStream; + _uuid = uuid.ToArray(); + _leaveInnerOpen = leaveInnerOpen; + _buffer = ArrayPool.Shared.Rent(MaxFrame); + } + + private int Buffered => _end - _start; + + public override bool CanRead => !_disposed && _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed && _inner.CanWrite; + 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 ================================ + + /// + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (buffer.IsEmpty) + return 0; + + while (true) + { + // Anything already unpadded and waiting is returned before touching the transport. + if (_mode == Mode.Raw) + return Buffered > 0 ? DrainInto(buffer.Span) : await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (_mode == Mode.Undecided) + { + // Decide from as few bytes as settle it. A full first frame header is needed to + // enter framed mode, but one byte that is not the UUID is enough to know the + // server is not framing — and waiting for 21 bytes from a server that sent a + // 5-byte greeting and is now waiting for us would be a deadlock. + while (!TryDecideMode()) + { + if (await FillSomeAsync(cancellationToken).ConfigureAwait(false) == 0) + { + _mode = Mode.Raw; // ended before a frame could exist: whatever came is payload + break; + } + } + + continue; + } + + if (_remainingContent == 0 && _remainingPadding == 0) + { + if (EndsFraming(_command)) + { + _mode = Mode.Raw; + continue; + } + + await FillAsync(HeaderSize, throwOnEof: false, cancellationToken).ConfigureAwait(false); + if (Buffered == 0) + return 0; // a clean close on a frame boundary is the end of the stream + + if (Buffered < HeaderSize) + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame header, mid-response."); + + ReadFrameHeader(); + continue; + } + + 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."); + + if (_remainingContent > 0) + { + int taken = Math.Min(Math.Min(_remainingContent, Buffered), buffer.Length); + _buffer.AsSpan(_start, taken).CopyTo(buffer.Span); + _start += taken; + _remainingContent -= taken; + return taken; + } + + int skipped = Math.Min(_remainingPadding, Buffered); + _start += skipped; + _remainingPadding -= skipped; + } + } + + /// + 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) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (buffer.IsEmpty) + return 0; + + while (true) + { + if (_mode == Mode.Raw) + return Buffered > 0 ? DrainInto(buffer) : _inner.Read(buffer); + + if (_mode == Mode.Undecided) + { + while (!TryDecideMode()) + { + if (FillSome() == 0) + { + _mode = Mode.Raw; + break; + } + } + + continue; + } + + if (_remainingContent == 0 && _remainingPadding == 0) + { + if (EndsFraming(_command)) + { + _mode = Mode.Raw; + continue; + } + + Fill(HeaderSize, throwOnEof: false); + if (Buffered == 0) + return 0; + + if (Buffered < HeaderSize) + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame header, mid-response."); + + ReadFrameHeader(); + continue; + } + + if (Buffered == 0 && FillSome() == 0) + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame, mid-response."); + + if (_remainingContent > 0) + { + int taken = Math.Min(Math.Min(_remainingContent, Buffered), buffer.Length); + _buffer.AsSpan(_start, taken).CopyTo(buffer); + _start += taken; + _remainingContent -= taken; + return taken; + } + + int skipped = Math.Min(_remainingPadding, Buffered); + _start += skipped; + _remainingPadding -= skipped; + } + } + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + /// + /// Tries to decide, from the bytes buffered so far, whether the peer is speaking Vision + /// framing. Returns false when more bytes are needed to tell. + /// + private bool TryDecideMode() + { + int compared = Math.Min(Buffered, UuidSize); + if (compared > 0 && !_buffer.AsSpan(_start, compared).SequenceEqual(_uuid.AsSpan(0, compared))) + { + // Not our UUID — the server answered in plain VLESS despite the flow, which is what + // a non-Vision server does. Everything buffered is payload, and nothing more needs + // to arrive to know that. + _mode = Mode.Raw; + return true; + } + + if (Buffered < UuidSize + HeaderSize) + return false; // the UUID matches so far; framed mode needs the whole first header + + _start += UuidSize; + _mode = Mode.Framed; + return true; + } + + /// Whether was the last framed packet. + private static bool EndsFraming(byte command) => + command is CommandPaddingEnd or CommandPaddingDirect; + + private void ReadFrameHeader() + { + ReadOnlySpan header = _buffer.AsSpan(_start, HeaderSize); + _command = header[0]; + _remainingContent = BinaryPrimitives.ReadUInt16BigEndian(header[1..]); + _remainingPadding = BinaryPrimitives.ReadUInt16BigEndian(header[3..]); + _start += HeaderSize; + + if (_remainingContent > 0) + _paddingOnlyFrames = 0; + else if (++_paddingOnlyFrames > MaxPaddingOnlyFrames) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"The VLESS server sent {MaxPaddingOnlyFrames} consecutive xtls-rprx-vision frames with no content."); + } + + private int DrainInto(Span destination) + { + int taken = Math.Min(Buffered, destination.Length); + _buffer.AsSpan(_start, taken).CopyTo(destination); + _start += taken; + return taken; + } + + /// Buffers at least bytes, compacting first if needed. + private async ValueTask FillAsync(int count, bool throwOnEof, CancellationToken cancellationToken) + { + Compact(count); + + 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; + } + } + + private void Fill(int count, bool throwOnEof) + { + 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; + } + } + + private async ValueTask FillSomeAsync(CancellationToken cancellationToken) + { + Compact(1); + int read = await _inner.ReadAsync(_buffer.AsMemory(_end, _buffer.Length - _end), cancellationToken) + .ConfigureAwait(false); + _end += read; + return read; + } + + private int FillSome() + { + Compact(1); + int read = _inner.Read(_buffer.AsSpan(_end)); + _end += read; + return read; + } + + /// Moves what is buffered to the front when would not fit. + private void Compact(int count) + { + ObjectDisposedException.ThrowIf(_buffer.Length == 0, this); + + if (_start == _end) + { + _start = _end = 0; + return; + } + + if (_end + count <= _buffer.Length) + return; + + _buffer.AsSpan(_start, Buffered).CopyTo(_buffer); + _end -= _start; + _start = 0; + } + + // ================================ writing ================================ + + /// + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_uplinkPadded) + { + _uplinkPadded = true; + if (TryRentPaddedFrame(buffer.Span, out byte[]? frame, out int length)) + { + try + { + await _inner.WriteAsync(frame.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(frame, clearArray: true); // the caller's first packet + } + + return; + } + } + + await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + /// + 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); + + if (!_uplinkPadded) + { + _uplinkPadded = true; + if (TryRentPaddedFrame(buffer, out byte[]? frame, out int length)) + { + try + { + _inner.Write(frame.AsSpan(0, length)); + } + finally + { + ArrayPool.Shared.Return(frame, clearArray: true); // the caller's first packet + } + + return; + } + } + + _inner.Write(buffer); + } + + /// + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + /// + /// Builds the single closing frame the uplink sends: uuid + end-command header + content + /// + padding. Returns false for content too large to frame, which then goes out raw — + /// legal, because the server only enters framed mode if the first bytes are the UUID. + /// + private bool TryRentPaddedFrame(ReadOnlySpan content, out byte[] frame, out int length) + { + frame = null!; + length = 0; + + int overhead = UuidSize + HeaderSize; + if (content.Length > MaxFrame - overhead) + return false; + + int padding = PaddingLength(content.Length); + length = overhead + content.Length + padding; + + frame = ArrayPool.Shared.Rent(length); + Span span = frame.AsSpan(0, length); + + _uuid.CopyTo(span); + span[UuidSize] = CommandPaddingEnd; + BinaryPrimitives.WriteUInt16BigEndian(span[(UuidSize + 1)..], (ushort)content.Length); + BinaryPrimitives.WriteUInt16BigEndian(span[(UuidSize + 3)..], (ushort)padding); + content.CopyTo(span[overhead..]); + span[(overhead + content.Length)..].Clear(); + + return true; + } + + /// + /// Xray's rule: pad a short packet out past 900 bytes, and give a long one a small random + /// tail. The point is to blur the length of the first records, so the number has to be + /// unpredictable — hence the cryptographic RNG rather than . + /// + private static int PaddingLength(int contentLength) + { + int padding = contentLength < 900 + ? RandomNumberGenerator.GetInt32(500) + 900 - contentLength + : RandomNumberGenerator.GetInt32(256); + + return Math.Min(padding, MaxFrame - UuidSize - HeaderSize - contentLength); + } + + // ================================ disposal ================================ + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + + // Transport first, buffer second: a ReadAsync still in flight on another thread is + // reading into _buffer, and closing the transport is what ends it. Returned first, the + // array could be re-rented and written into by that late completion. + if (!_leaveInnerOpen) + await _inner.DisposeAsync().ConfigureAwait(false); + ReturnBuffer(); + + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + if (!_leaveInnerOpen) + _inner.Dispose(); + ReturnBuffer(); + } + + _disposed = true; + base.Dispose(disposing); + } + + private void ReturnBuffer() + { + byte[] buffer = _buffer; + _buffer = []; + + // Cleared: this held decrypted tunnel payload, and the pool hands the array to whoever + // rents next. + if (buffer.Length > 0) + ArrayPool.Shared.Return(buffer, clearArray: true); + } +} diff --git a/QuickProxyNet/Internal/VlessHelper.cs b/QuickProxyNet/Internal/VlessHelper.cs new file mode 100644 index 0000000..ba0ffde --- /dev/null +++ b/QuickProxyNet/Internal/VlessHelper.cs @@ -0,0 +1,126 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Text; + +namespace QuickProxyNet; + +/// +/// Builds the VLESS request header and reads the VLESS response header over an already +/// established transport (plain TCP or an authenticated ). +/// +/// +/// Request layout (addons omitted for plain TCP): +/// +/// ver(0x00) | uuid(16 BE) | addonsLen(0x00) | cmd(0x01 TCP) | port(2 BE) | atyp(1) | addr(var) +/// +/// VLESS writes the port before the address (unlike SOCKS5) and uses 0x02 for a domain +/// address type. The response is ver(1) + addonsLen(1) + addons(var); it is consumed +/// by on the first read — not here — because neither +/// Xray nor sing-box flushes it before the target replies. See that type for the measurement. +/// +internal static class VlessHelper +{ + private const byte Version = 0x00; + private const byte CommandTcp = 0x01; + private const byte AtypIPv4 = 0x01; + private const byte AtypDomain = 0x02; + private const byte AtypIPv6 = 0x03; + + /// Protobuf tag for Addons.Flow: field 1, length-delimited. + private const byte AddonsFlowTag = 0x0A; + + /// The tag and length that precede the flow identifier inside the addons block. + private const int AddonsOverhead = 2; + + // ver(1) + uuid(16) + addonsLen(1) + addons + cmd(1) + port(2) + max address. + private static readonly int MaxRequestSize = + 1 + UuidCodec.Size + 1 + AddonsOverhead + VisionStream.FlowName.Length + 1 + 2 + ProxyAddress.MaxLength; + + /// + /// Writes the VLESS request header over and returns the stream + /// the caller should use, which validates the server response header on its first read. + /// + /// + /// The response header is deliberately not read here. See + /// for why reading it eagerly deadlocks against any + /// client-speaks-first target. + /// + internal static async ValueTask EstablishVlessTunnelAsync( + Stream stream, VlessOptions options, string host, int port, CancellationToken cancellationToken) + { + bool vision = IsVision(options.Flow); + byte[] buffer = ArrayPool.Shared.Rent(MaxRequestSize); + try + { + int length = BuildRequest(buffer, options.Id, host, port, vision ? VisionStream.FlowName : default); + await stream.WriteAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + // The buffer holds the user UUID (the VLESS credential); clear it before + // returning the array to the shared pool. + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + Stream session = new VlessResponseStream(stream, host, port); + if (!vision) + return session; + + // Vision framing starts where the payload would otherwise start, so it wraps the + // response stream rather than the transport. + return CreateVisionStream(session, options.Id); + } + + /// + /// Whether is the one XTLS flow this library speaks. + /// + /// + /// The comparison is exact. A server configured with a flow we do not implement must fail + /// loudly rather than be handed a plain VLESS request that it will answer in a framing we + /// then misread — which looks like a working connection for about one packet. + /// + internal static bool IsVision(string? flow) => + string.Equals(flow, VisionStream.FlowName, StringComparison.Ordinal); + + private static VisionStream CreateVisionStream(Stream session, string id) + { + Span uuid = stackalloc byte[UuidCodec.Size]; + UuidCodec.WriteBigEndian(id, uuid); + return new VisionStream(session, uuid); + } + + internal static int BuildRequest(Span buffer, ReadOnlySpan id, string host, int port) + => BuildRequest(buffer, id, host, port, default); + + /// + /// Writes the request header, carrying in the addons block when it + /// is non-empty. The addons are a protobuf message with a single field, so the encoding is + /// written by hand rather than pulling in a protobuf runtime for five bytes. + /// + internal static int BuildRequest( + Span buffer, ReadOnlySpan id, string host, int port, ReadOnlySpan flow) + { + buffer[0] = Version; + UuidCodec.WriteBigEndian(id, buffer.Slice(1, UuidCodec.Size)); + + int offset = 1 + UuidCodec.Size; + if (flow.IsEmpty) + { + buffer[offset++] = 0x00; // addons length + } + else + { + buffer[offset++] = (byte)(AddonsOverhead + flow.Length); + buffer[offset++] = AddonsFlowTag; + buffer[offset++] = (byte)flow.Length; + offset += Encoding.ASCII.GetBytes(flow, buffer[offset..]); + } + + buffer[offset++] = CommandTcp; + BinaryPrimitives.WriteUInt16BigEndian(buffer[offset..], (ushort)port); + offset += 2; + offset += ProxyAddress.WriteTypeAndAddress(host, buffer[offset..], AtypIPv4, AtypDomain, AtypIPv6); + return offset; + } +} diff --git a/QuickProxyNet/Internal/VlessResponseStream.cs b/QuickProxyNet/Internal/VlessResponseStream.cs new file mode 100644 index 0000000..79c72f4 --- /dev/null +++ b/QuickProxyNet/Internal/VlessResponseStream.cs @@ -0,0 +1,209 @@ +namespace QuickProxyNet; + +/// +/// A pass-through stream that consumes and validates the VLESS server response header +/// (ver(1) + addonsLen(1) + addons(var)) lazily, on the first read, and then +/// forwards every operation to the transport unchanged. +/// +/// +/// +/// This is the VLESS counterpart of , and it exists for the +/// same reason: neither Xray-core nor sing-box flushes the VLESS response header until the +/// target has produced its first bytes. Both write it into a buffered writer that is only +/// unbuffered once data comes back from the target. Reading it eagerly inside +/// ConnectAsync therefore deadlocks against every client-speaks-first protocol — HTTP, +/// TLS, the Minecraft handshake — because the client waits for a header the server will not +/// send until the client's request has reached the target, and the client cannot send that +/// request until ConnectAsync returns. +/// +/// +/// This was measured, not assumed: a raw probe that writes the VLESS request and then reads +/// two bytes hangs against both servers, while one that writes the request and an HTTP GET +/// together gets 00 00 followed immediately by the HTTP response. +/// +/// +/// The tradeoff is that a rejected handshake (wrong user id — both servers simply drop the +/// connection) surfaces on the first Read rather than from ConnectAsync. That is +/// inherent to VLESS, not a consequence of this design: the server sends nothing at connect +/// time either way, so there is no failure to observe earlier. +/// +/// +internal sealed class VlessResponseStream : Stream +{ + private const byte Version = 0x00; + + private readonly Stream _inner; + private readonly bool _leaveInnerOpen; + private readonly string _host; + private readonly int _port; + + private bool _headerRead; + private bool _disposed; + + /// + /// Wraps , which must be positioned at the start of the + /// server response header. + /// + /// The transport (the raw stream or the TLS session). + /// Target host, used only to build a useful error message. + /// Target port, used only to build a useful error message. + /// When true, disposing this stream leaves the transport open. + /// is null. + public VlessResponseStream(Stream innerStream, string host, int port, bool leaveInnerOpen = false) + { + ArgumentNullException.ThrowIfNull(innerStream); + + _inner = innerStream; + _host = host; + _port = port; + _leaveInnerOpen = leaveInnerOpen; + } + + /// Whether the response header has already been read and validated. + public bool IsHeaderRead => _headerRead; + + /// + /// Reads and validates the response header if that has not happened yet. Callers that want + /// handshake failures reported before the first payload read can await this explicitly — + /// at the cost of the deadlock described on the class. + /// + /// + /// The version byte is not 0x00, or the server closed the connection before + /// completing the handshake. + /// + public async ValueTask ReadHeaderAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_headerRead) + return; + + // ver(1) + addonsLen(1). Small and short-lived, and — unlike the request header — + // it carries no credential material, so there is nothing to pool or to clear. + byte[] header = new byte[2]; + try + { + await _inner.ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false); + + if (header[0] != Version) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"Unexpected VLESS response version. Expected 0x00, got 0x{header[0]:X2}."); + + // addonsLen is a single byte. The content is unused for plain TCP; draining it + // positions the stream at the target's first response byte. + int addonsLength = header[1]; + if (addonsLength > 0) + { + byte[] addons = new byte[addonsLength]; + await _inner.ReadExactlyAsync(addons, cancellationToken).ConfigureAwait(false); + } + } + catch (EndOfStreamException ex) + { + // A short/closed response is the primary VLESS failure signal (e.g. wrong UUID: + // both Xray and sing-box just drop the connection). Surface it like the HTTP path. + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, + $"VLESS server closed the connection before completing the handshake for {_host}:{_port} (wrong UUID or rejected request?).", + ex); + } + + _headerRead = true; + } + + public override bool CanRead => !_disposed && _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed && _inner.CanWrite; + 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 ================================ + + /// + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_headerRead) + await ReadHeaderAsync(cancellationToken).ConfigureAwait(false); + + return await _inner.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + /// + 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) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_headerRead) + ReadHeaderAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + return _inner.Read(buffer); + } + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + // ================================ writing ================================ + + /// + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _inner.WriteAsync(buffer, 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); + _inner.Write(buffer); + } + + /// + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + // ================================ disposal ================================ + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + if (!_leaveInnerOpen) + await _inner.DisposeAsync().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing && !_leaveInnerOpen) + _inner.Dispose(); + + _disposed = true; + base.Dispose(disposing); + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessAuthId.cs b/QuickProxyNet/Internal/Vmess/VmessAuthId.cs new file mode 100644 index 0000000..3cf93e3 --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessAuthId.cs @@ -0,0 +1,117 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// Builds the VMessAEAD AuthID (the 16-byte "EAuID" that opens every request), +/// per proxy/vmess/aead/authid.go. +/// +/// +/// Plaintext layout (16 bytes): +/// +/// [0..8) Unix timestamp, seconds, int64 big-endian +/// [8..12) 4 random bytes +/// [12..16) CRC-32/IEEE of bytes [0..12), uint32 big-endian +/// +/// The plaintext is then encrypted as a single raw AES-128 block (ECB, no +/// padding, no IV) under KDF16(cmdKey, "AES Auth ID Encryption"). This is a +/// block-cipher permutation, not an AEAD — must not be used here. +/// +internal static class VmessAuthId +{ + /// Length of an AuthID (and of its plaintext) in bytes. + public const int Size = 16; + + /// Number of random bytes embedded in the AuthID plaintext. + public const int RandomSize = 4; + + // Offsets inside the 16-byte plaintext. + private const int TimestampOffset = 0; + private const int RandomOffset = 8; + private const int ChecksumOffset = 12; + + private static ReadOnlySpan EncryptionKeyLabel => "AES Auth ID Encryption"u8; + + /// + /// Writes the 16-byte AuthID plaintext (pre-encryption) into + /// . + /// + /// Timestamp in whole Unix seconds (int64, big-endian). + /// Exactly random bytes. + /// Receives the 16-byte plaintext. + /// + /// is not 4 bytes, or is + /// shorter than 16 bytes. + /// + public static void WritePlaintext(long unixSeconds, ReadOnlySpan random4, Span destination) + { + if (random4.Length != RandomSize) + throw new ArgumentException($"Random must be exactly {RandomSize} bytes.", nameof(random4)); + if (destination.Length < Size) + throw new ArgumentException($"Destination must be at least {Size} bytes.", nameof(destination)); + + Span plaintext = destination[..Size]; + BinaryPrimitives.WriteInt64BigEndian(plaintext[TimestampOffset..], unixSeconds); + random4.CopyTo(plaintext[RandomOffset..]); + + // CRC covers timestamp ‖ random (the first 12 bytes) and is stored big-endian. + Crc32.WriteBigEndian(plaintext[..ChecksumOffset], plaintext[ChecksumOffset..]); + } + + /// + /// Creates an AuthID from explicit time and randomness — the deterministic form used + /// by tests and by . + /// + /// The 16-byte cmdKey (). + /// Timestamp in whole Unix seconds. + /// Exactly random bytes. + /// Receives the encrypted 16-byte AuthID. + /// An input or the destination has the wrong size. + public static void Create( + ReadOnlySpan cmdKey, long unixSeconds, ReadOnlySpan random4, Span destination) + { + if (cmdKey.Length != VmessCmdKey.Size) + throw new ArgumentException($"cmdKey must be exactly {VmessCmdKey.Size} bytes.", nameof(cmdKey)); + if (destination.Length < Size) + throw new ArgumentException($"Destination must be at least {Size} bytes.", nameof(destination)); + + Span plaintext = stackalloc byte[Size]; + + // Aes.Key only accepts an array on net8.0 (SetKey(ReadOnlySpan) is newer), + // so the derived key is materialised once per handshake and zeroed afterwards. + byte[] key = new byte[16]; + try + { + WritePlaintext(unixSeconds, random4, plaintext); + VmessKdf.Kdf16(cmdKey, EncryptionKeyLabel, key); + + using var aes = Aes.Create(); + aes.Key = key; + aes.EncryptEcb(plaintext, destination[..Size], PaddingMode.None); + } + finally + { + CryptographicOperations.ZeroMemory(key); + CryptographicOperations.ZeroMemory(plaintext); + } + } + + /// + /// Creates an AuthID for "now", drawing the timestamp from + /// and the 4 random bytes from + /// . + /// + /// + /// VMessAEAD uses the exact current second with no jitter (the ±30 s window + /// belongs to legacy, non-AEAD VMess). Servers accept a ±120 s clock skew. + /// + /// The 16-byte cmdKey. + /// Receives the encrypted 16-byte AuthID. + public static void Create(ReadOnlySpan cmdKey, Span destination) + { + Span random = stackalloc byte[RandomSize]; + RandomNumberGenerator.Fill(random); + Create(cmdKey, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), random, destination); + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessBodyKeys.cs b/QuickProxyNet/Internal/Vmess/VmessBodyKeys.cs new file mode 100644 index 0000000..6fcb4e0 --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessBodyKeys.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// Pure body key-derivation helpers for VMessAEAD (alterId = 0): +/// the ChaCha20-Poly1305 MD5 key expansion and the SHA-256-based response +/// key/IV derivation (proxy/vmess/encoding). +/// +internal static class VmessBodyKeys +{ + /// Length of the expanded ChaCha20 key in bytes. + public const int ChaCha20KeySize = 32; + + /// Length of a derived response key or IV in bytes. + public const int ResponseKeySize = 16; + + /// + /// Expands a 16-byte body key into the 32-byte ChaCha20-Poly1305 key used by VMess + /// (GenerateChacha20Poly1305Key): key[0:16] = MD5(bodyKey), + /// key[16:32] = MD5(key[0:16]). + /// + /// + /// is shorter than 32 bytes. + /// + public static void ExpandChaCha20Key(ReadOnlySpan bodyKey, Span destination) + { + if (destination.Length < ChaCha20KeySize) + throw new ArgumentException( + $"Destination must be at least {ChaCha20KeySize} bytes.", nameof(destination)); + + MD5.HashData(bodyKey, destination[..16]); + MD5.HashData(destination[..16], destination.Slice(16, 16)); + } + + /// + /// Derives a 16-byte VMessAEAD response body key or IV from the corresponding + /// request value: SHA256(source)[0:16]. + /// + /// + /// is shorter than 16 bytes. + /// + public static void DeriveResponseKeyOrIv(ReadOnlySpan source, Span destination) + { + if (destination.Length < ResponseKeySize) + throw new ArgumentException( + $"Destination must be at least {ResponseKeySize} bytes.", nameof(destination)); + + Span full = stackalloc byte[32]; + SHA256.HashData(source, full); + full[..ResponseKeySize].CopyTo(destination); + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessCmdKey.cs b/QuickProxyNet/Internal/Vmess/VmessCmdKey.cs new file mode 100644 index 0000000..7bec45c --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessCmdKey.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// Derives the VMess cmdKey — the 16-byte master key that every VMessAEAD key +/// derivation is rooted in (common/protocol/id.go): +/// +/// cmdKey = MD5( uuid16 ‖ "c48619fe-8f02-49e0-b9e9-edf763e17e21" ) +/// +/// +/// +/// The user id contributes its 16 RFC 4122 big-endian bytes (via +/// , not ), and the magic +/// suffix is appended as 36 literal ASCII bytes — it is a constant string, never parsed +/// as a UUID. The MD5 input is therefore always exactly 52 bytes. +/// +internal static class VmessCmdKey +{ + /// Length of a cmdKey in bytes. + public const int Size = 16; + + /// Total MD5 input length: 16 uuid bytes + 36 ASCII magic bytes. + private const int HashInputSize = UuidCodec.Size + 36; + + // Verbatim from common/protocol/id.go — hashed as ASCII, not as a UUID. + private static ReadOnlySpan Magic => "c48619fe-8f02-49e0-b9e9-edf763e17e21"u8; + + /// + /// Derives the cmdKey for into . + /// + /// The canonical VMess user id. + /// Receives the 16-byte cmdKey. + /// + /// is shorter than 16 bytes. + /// + /// is not a canonical UUID. + public static void Derive(ReadOnlySpan uuid, Span destination) + { + if (destination.Length < Size) + throw new ArgumentException($"Destination must be at least {Size} bytes.", nameof(destination)); + + Span input = stackalloc byte[HashInputSize]; + try + { + UuidCodec.WriteBigEndian(uuid, input[..UuidCodec.Size]); + Magic.CopyTo(input[UuidCodec.Size..]); + MD5.HashData(input, destination[..Size]); + } + finally + { + // The buffer holds the raw user id (the VMess credential). + CryptographicOperations.ZeroMemory(input); + } + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessKdf.cs b/QuickProxyNet/Internal/Vmess/VmessKdf.cs new file mode 100644 index 0000000..8626a74 --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessKdf.cs @@ -0,0 +1,220 @@ +using System.Buffers; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The VMessAEAD key-derivation function: a nested / recursive HMAC-SHA256 +/// construction (proxy/vmess/aead/kdf.go). The innermost HMAC is keyed by the +/// ASCII seed "VMess AEAD KDF" over plain SHA-256; each subsequent path element +/// becomes the key of an HMAC whose underlying hash function is the previous HMAC. +/// The final HMAC hashes the supplied key (the 16-byte cmdKey) and produces +/// 32 bytes; the Kdf16 overloads keep the first 16, the Kdf12 overloads +/// the first 12. +/// +/// +/// +/// A plain cannot express "an HMAC whose hash function is +/// another HMAC", so the generic RFC 2104 construction (block size 64, ipad 0x36, +/// opad 0x5C, keys longer than the block pre-hashed) is implemented by hand for +/// every level above the innermost one. The innermost level — HMAC-SHA256 keyed +/// by the constant seed — is a standard HMAC, so it is delegated to the one-shot +/// , +/// which is allocation-free and lets the platform run its own optimized HMAC instead of +/// two separate SHA-256 passes. +/// +/// +/// Evaluating an HMAC at level n requires two evaluations of level n−1 +/// (inner and outer pass), so a chain of n levels above the base costs +/// 2^n base HMAC computations — 8 for the four-element request-header +/// derivations. This fan-out is inherent to the construction. The implementation +/// therefore focuses on what can be fixed: it performs no heap allocations at +/// all (all pads and scratch live on the stack; a pooled buffer is used only in the +/// never-hit oversized-key fallback) and halves the number of platform-crypto calls via +/// the one-shot base HMAC. +/// +/// +/// Do not replace the base HMAC with a hand-rolled midstate one. This was built and +/// measured, and it lost. The seed is a constant, so its ipad/opad blocks can be +/// pre-hashed into SHA-256 midstates — which the platform cannot do, since neither +/// nor exports a chaining state. +/// Folding both the seed pads and the (per-derivation constant) innermost path +/// element into resumable midstates cuts a four-element derivation from 46 block +/// compressions to 24. It still does not pay, because the premise that the platform call +/// is dominated by CNG round-trip overhead is wrong: costs +/// ~200 ns per 64-byte block here, i.e. it is compression-bound, while the managed +/// costs ~312 ns per block — 1.58x more. Halving the block count +/// only just cancels the per-block penalty. Measured interleaved on an Intel Xeon E5-2697 +/// v4 (Broadwell, no SHA-NI): the four request-header derivations went 37.2 µs -> 34.7 µs +/// (0.94x), but a single one-element derivation went 1.93 µs -> 2.31 µs (1.20x), because +/// its fixed setup is amortized over far fewer blocks. A real connection does five +/// one-element derivations (VmessAuthId plus four in VmessResponse) and four +/// three-element ones, so the two effects cancel to about 1%. On a CPU with SHA-NI the +/// platform side gets faster still and the trade gets worse. VmessKdfBenchmark keeps +/// the midstate variant so the comparison can be re-run on other hardware. +/// +/// +internal static class VmessKdf +{ + private const int BlockSize = 64; + private const int DigestSize = 32; + + // A level's precomputed pads: [K' XOR ipad (64)][K' XOR opad (64)]. + private const int PadPairSize = 2 * BlockSize; + + // The deepest chain VMess uses: label, arg1 (authid), arg2 (connection nonce). + private const int MaxLevels = 3; + + // Largest ipad-concat input kept on the stack. The deepest VMess evaluation needs + // 64 + 64 + 64 + 16 = 208 bytes; anything larger falls back to the pool. + private const int MaxStackInput = 256; + + // ASCII seed that keys the innermost HMAC (verbatim from aead/consts.go). + private static ReadOnlySpan Seed => "VMess AEAD KDF"u8; + + // Persistent seed-keyed HMAC-SHA256, one per thread. The seed is a public protocol + // constant, so keeping the keyed state alive holds no secret material; reusing it + // skips the per-call key import that HMACSHA256.HashData would repeat. + [ThreadStatic] + private static IncrementalHash? t_baseHmac; + + // ---- single path element (response-header + auth-id labels) ---- + + /// + /// Writes the first 16 bytes of KDF(key, label) into . + /// + public static void Kdf16(ReadOnlySpan key, ReadOnlySpan label, Span destination) + => Derive(key, label, default, default, levels: 1, destination, 16); + + /// + /// Writes the first 12 bytes of KDF(key, label) into . + /// + public static void Kdf12(ReadOnlySpan key, ReadOnlySpan label, Span destination) + => Derive(key, label, default, default, levels: 1, destination, 12); + + // ---- three path elements (request-header labels: label ‖ authid ‖ nonce) ---- + + /// + /// Writes the first 16 bytes of KDF(key, label, arg1, arg2) into + /// . Used for the request-header key derivations + /// where is the auth-id and the + /// connection nonce. + /// + public static void Kdf16( + ReadOnlySpan key, ReadOnlySpan label, + ReadOnlySpan arg1, ReadOnlySpan arg2, Span destination) + => Derive(key, label, arg1, arg2, levels: 3, destination, 16); + + /// + /// Writes the first 12 bytes of KDF(key, label, arg1, arg2) into + /// (request-header nonce derivations). + /// + public static void Kdf12( + ReadOnlySpan key, ReadOnlySpan label, + ReadOnlySpan arg1, ReadOnlySpan arg2, Span destination) + => Derive(key, label, arg1, arg2, levels: 3, destination, 12); + + private static void Derive( + ReadOnlySpan key, + ReadOnlySpan label, ReadOnlySpan arg1, ReadOnlySpan arg2, + int levels, Span destination, int length) + { + if (destination.Length < length) + throw new ArgumentException($"Destination must be at least {length} bytes.", nameof(destination)); + + // 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]; + InitLevel(pads, 0, label); + if (levels == 3) + { + InitLevel(pads, 1, arg1); + InitLevel(pads, 2, arg2); + } + + Span full = stackalloc byte[DigestSize]; + Compute(pads, levels, key, full); + full[..length].CopyTo(destination[..length]); + + CryptographicOperations.ZeroMemory(full); + CryptographicOperations.ZeroMemory(pads); + } + + // Precomputes the ipad/opad pair for one manual HMAC level, keyed by that level's + // path element. + private static void InitLevel(Span pads, int level, ReadOnlySpan key) + { + Span normalizedKey = stackalloc byte[BlockSize]; + normalizedKey.Clear(); + + // RFC 2104: keys longer than the block are pre-hashed with the same hash + // function this HMAC uses — the chain formed by the levels *below* this one. + // None of the VMess path elements hit this branch, but it is kept for + // correctness of the generic construction. + if (key.Length > BlockSize) + Compute(pads, level, key, normalizedKey[..DigestSize]); + else + key.CopyTo(normalizedKey); + + Span inner = pads.Slice(level * PadPairSize, BlockSize); + Span outer = pads.Slice(level * PadPairSize + BlockSize, BlockSize); + for (int i = 0; i < BlockSize; i++) + { + inner[i] = (byte)(normalizedKey[i] ^ 0x36); + outer[i] = (byte)(normalizedKey[i] ^ 0x5C); + } + + CryptographicOperations.ZeroMemory(normalizedKey); + } + + /// + /// Computes H(opad ‖ H(ipad ‖ message)) for the HMAC formed by the first + /// pad pairs, where level 0's underlying hash function is + /// the seed-keyed HMAC-SHA256 and each higher level's is the level below it. + /// + private static void Compute( + ReadOnlySpan pads, int levels, ReadOnlySpan message, Span destination) + { + if (levels == 0) + { + // The innermost level is a *standard* HMAC-SHA256 keyed by the seed. + IncrementalHash hmac = t_baseHmac ??= + IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, Seed); + hmac.AppendData(message); + hmac.GetHashAndReset(destination); + return; + } + + int top = levels - 1; + ReadOnlySpan innerPad = pads.Slice(top * PadPairSize, BlockSize); + ReadOnlySpan outerPad = pads.Slice(top * PadPairSize + BlockSize, BlockSize); + + // --- inner pass: H_below(ipad ‖ message) --- + Span innerDigest = stackalloc byte[DigestSize]; + int innerLength = BlockSize + message.Length; + byte[]? rented = innerLength > MaxStackInput ? ArrayPool.Shared.Rent(innerLength) : null; + // The recursion is bounded by MaxLevels, so stack use stays small. + Span innerInput = rented ?? stackalloc byte[MaxStackInput]; + try + { + innerPad.CopyTo(innerInput); + message.CopyTo(innerInput[BlockSize..]); + Compute(pads, top, innerInput[..innerLength], innerDigest); + } + finally + { + CryptographicOperations.ZeroMemory(innerInput[..innerLength]); + if (rented is not null) + ArrayPool.Shared.Return(rented); + } + + // --- outer pass: H_below(opad ‖ innerDigest) --- + Span outerInput = stackalloc byte[BlockSize + DigestSize]; + outerPad.CopyTo(outerInput); + innerDigest.CopyTo(outerInput[BlockSize..]); + Compute(pads, top, outerInput, destination); + + CryptographicOperations.ZeroMemory(innerDigest); + CryptographicOperations.ZeroMemory(outerInput); + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessRequest.cs b/QuickProxyNet/Internal/Vmess/VmessRequest.cs new file mode 100644 index 0000000..70b04fe --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessRequest.cs @@ -0,0 +1,373 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The per-connection random and time inputs of a VMessAEAD request header, supplied by +/// the caller so the whole header is a pure function of its inputs. +/// +/// +/// VMess mixes randomness into six independent places (AuthID timestamp and nonce, the +/// connection nonce, the body key and IV, the response verifier, and the padding). Taking +/// them as an explicit input makes deterministic and therefore +/// testable against fixed wire vectors; production code fills the struct through +/// . It is a ref struct over +/// caller-owned buffers, so building a header allocates nothing. +/// +internal readonly ref struct VmessRequestMaterial +{ + /// Timestamp embedded in the AuthID, in whole Unix seconds. + public long AuthIdTimestamp { get; init; } + + /// The 4 random bytes of the AuthID plaintext. + public ReadOnlySpan AuthIdRandom { get; init; } + + /// The 8-byte connection nonce that salts both header AEAD derivations. + public ReadOnlySpan ConnectionNonce { get; init; } + + /// The 16-byte request body key (used later by the body cipher). + public ReadOnlySpan BodyKey { get; init; } + + /// The 16-byte request body IV (used later by the body cipher). + public ReadOnlySpan BodyIv { get; init; } + + /// The response verifier byte the server echoes back in its response header. + public byte ResponseVerifier { get; init; } + + /// + /// The random padding appended after the address. Its length is the padding + /// length written into the high nibble of the security byte, so it must be 0–15 bytes. + /// + public ReadOnlySpan Padding { get; init; } +} + +/// +/// Builds the VMessAEAD (alterId = 0) client request header: the plaintext +/// instruction/command section (proxy/vmess/encoding/client.go) and the AEAD +/// envelope that seals it (proxy/vmess/aead/encrypt.go). +/// +/// +/// Wire layout of the sealed header: +/// +/// authid(16) ‖ encryptedLength(2+16) ‖ connectionNonce(8) ‖ encryptedHeader(L+16) +/// +/// where L is the length of the command section — total 58 + L bytes. Both +/// AEADs are AES-128-GCM with a 16-byte tag, keyed by +/// KDF(cmdKey, label, authid, connectionNonce), and both use the AuthID as +/// associated data. +/// +/// Note that the command section writes the port before the address (VMess is +/// configured PortThenAddress), unlike SOCKS5/Trojan. The address type codes match +/// VLESS: 0x01 IPv4 / 0x02 domain / 0x03 IPv6. +/// +/// +internal static class VmessRequest +{ + /// Request header version byte. + public const byte Version = 0x01; + + /// Command: TCP. + public const byte CommandTcp = 0x01; + + /// Command: UDP. + public const byte CommandUdp = 0x02; + + /// Command: Mux. + public const byte CommandMux = 0x03; + + /// Option flag S: the body is sent as a chunked stream. + public const byte OptionChunkStream = 0x01; + + /// Option flag M: chunk length obfuscation. + public const byte OptionChunkMasking = 0x04; + + /// Option flag P: global padding. + public const byte OptionGlobalPadding = 0x08; + + /// Option flag A: authenticated chunk length. + public const byte OptionAuthenticatedLength = 0x10; + + /// Option set a modern AEAD client sends (S | M | P | A). + public const byte DefaultOption = + OptionChunkStream | OptionChunkMasking | OptionGlobalPadding | OptionAuthenticatedLength; + + /// Security type 3: AES-128-GCM body cipher. + public const byte SecurityAes128Gcm = 0x03; + + /// Security type 4: ChaCha20-Poly1305 body cipher. + public const byte SecurityChaCha20Poly1305 = 0x04; + + /// Security type 5: no body encryption. + public const byte SecurityNone = 0x05; + + /// Security type 6: "zero" — no encryption and no authentication. + public const byte SecurityZero = 0x06; + + /// Length of the request body key and IV, in bytes. + public const int BodyKeySize = 16; + + /// Length of the connection nonce, in bytes. + public const int ConnectionNonceSize = 8; + + /// Maximum random padding the 4-bit padding-length nibble can express. + public const int MaxPaddingLength = 15; + + /// + /// Bytes the envelope adds on top of the command section: + /// authid(16) + encryptedLength(18) + connectionNonce(8) + GCM tag(16). + /// + public const int SealOverhead = VmessAuthId.Size + 2 + TagSize + ConnectionNonceSize + TagSize; + + /// + /// Scratch size required by : + /// random(4) + nonce(8) + bodyKey(16) + bodyIv(16) + respV(1) + padding(15). + /// + public const int MaterialScratchSize = + VmessAuthId.RandomSize + ConnectionNonceSize + BodyKeySize + BodyKeySize + 1 + MaxPaddingLength; + + private const int TagSize = 16; + private const int GcmKeySize = 16; + private const int GcmNonceSize = 12; + + // Field offsets inside the CreateMaterial scratch buffer. + private const int ScratchRandomOffset = 0; + private const int ScratchNonceOffset = ScratchRandomOffset + VmessAuthId.RandomSize; // 4 + private const int ScratchBodyKeyOffset = ScratchNonceOffset + ConnectionNonceSize; // 12 + private const int ScratchBodyIvOffset = ScratchBodyKeyOffset + BodyKeySize; // 28 + private const int ScratchResponseVerifierOffset = ScratchBodyIvOffset + BodyKeySize; // 44 + private const int ScratchPaddingOffset = ScratchResponseVerifierOffset + 1; // 45 + + // version(1) + iv(16) + key(16) + respV(1) + option(1) + padSec(1) + reserved(1) + + // command(1) + port(2) — everything before the address type byte. + private const int AddressOffset = 40; + + /// Largest command section this builder can emit. + public const int MaxCommandSectionSize = + AddressOffset + ProxyAddress.MaxLength + MaxPaddingLength + 4; + + /// Largest sealed request header this builder can emit. + public const int MaxRequestSize = MaxCommandSectionSize + SealOverhead; + + private static ReadOnlySpan LengthKeyLabel => "VMess Header AEAD Key_Length"u8; + private static ReadOnlySpan LengthNonceLabel => "VMess Header AEAD Nonce_Length"u8; + private static ReadOnlySpan PayloadKeyLabel => "VMess Header AEAD Key"u8; + private static ReadOnlySpan PayloadNonceLabel => "VMess Header AEAD Nonce"u8; + + /// + /// Fills a from the system CSPRNG and the current + /// UTC second. The returned struct points into , which the + /// caller must keep alive (and should clear) for as long as the material is used. + /// + /// + /// A buffer of at least bytes. + /// + /// is too small. + public static VmessRequestMaterial CreateMaterial(Span scratch) + { + if (scratch.Length < MaterialScratchSize) + throw new ArgumentException( + $"Scratch must be at least {MaterialScratchSize} bytes.", nameof(scratch)); + + Span buffer = scratch[..MaterialScratchSize]; + RandomNumberGenerator.Fill(buffer); + + // dice.RollWith(16) — a uniform padding length in [0, 16). + int paddingLength = RandomNumberGenerator.GetInt32(0, MaxPaddingLength + 1); + + return new VmessRequestMaterial + { + AuthIdTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + AuthIdRandom = buffer.Slice(ScratchRandomOffset, VmessAuthId.RandomSize), + ConnectionNonce = buffer.Slice(ScratchNonceOffset, ConnectionNonceSize), + BodyKey = buffer.Slice(ScratchBodyKeyOffset, BodyKeySize), + BodyIv = buffer.Slice(ScratchBodyIvOffset, BodyKeySize), + ResponseVerifier = buffer[ScratchResponseVerifierOffset], + Padding = buffer.Slice(ScratchPaddingOffset, paddingLength), + }; + } + + /// + /// Writes the plaintext command section for : + /// into and returns the number of bytes written. + /// + /// Receives the command section; see . + /// The random inputs (body key/IV, response verifier, padding). + /// The option bitflags (see ). + /// The security type nibble (0–15, e.g. ). + /// The command byte (e.g. ). + /// The target host: an IPv4/IPv6 literal or a domain name. + /// The target port. + /// A material field or the destination has the wrong size. + /// exceeds 15. + public static int WriteCommandSection( + Span destination, + in VmessRequestMaterial material, + byte option, + byte security, + byte command, + string host, + int port) + { + if (material.BodyIv.Length != BodyKeySize) + throw new ArgumentException($"BodyIv must be exactly {BodyKeySize} bytes.", nameof(material)); + if (material.BodyKey.Length != BodyKeySize) + throw new ArgumentException($"BodyKey must be exactly {BodyKeySize} bytes.", nameof(material)); + if (material.Padding.Length > MaxPaddingLength) + throw new ArgumentException( + $"Padding must be at most {MaxPaddingLength} bytes (it is a 4-bit field).", nameof(material)); + if (security > 0x0F) + throw new ArgumentOutOfRangeException(nameof(security), security, + "Security type must fit the low nibble (0-15)."); + // The address length is only known after it has been written, so the buffer must + // be able to hold the worst case (a 255-byte domain plus padding and checksum). + if (destination.Length < MaxCommandSectionSize) + throw new ArgumentException( + $"Destination must be at least {MaxCommandSectionSize} bytes.", nameof(destination)); + + destination[0] = Version; + material.BodyIv.CopyTo(destination.Slice(1, BodyKeySize)); + material.BodyKey.CopyTo(destination.Slice(1 + BodyKeySize, BodyKeySize)); + destination[33] = material.ResponseVerifier; + destination[34] = option; + destination[35] = (byte)((material.Padding.Length << 4) | security); + destination[36] = 0x00; // reserved + destination[37] = command; + BinaryPrimitives.WriteUInt16BigEndian(destination.Slice(38, 2), (ushort)port); + + // VMess writes port first, then atyp + address (PortThenAddress). + int offset = AddressOffset + ProxyAddress.WriteTypeAndAddress( + host, destination[AddressOffset..], AtypIPv4, AtypDomain, AtypIPv6); + + material.Padding.CopyTo(destination[offset..]); + offset += material.Padding.Length; + + // FNV-1a-32 (big-endian) over everything written so far, padding included. + Fnv1a32.WriteBigEndian(destination[..offset], destination[offset..]); + return offset + 4; + } + + private const byte AtypIPv4 = 0x01; + private const byte AtypDomain = 0x02; + private const byte AtypIPv6 = 0x03; + + /// + /// Seals a command section into the AEAD envelope and returns the total number of + /// bytes written (58 + data.Length). + /// + /// Receives the sealed header. + /// The 16-byte cmdKey. + /// The 16-byte AuthID; also the associated data of both AEADs. + /// The 8-byte connection nonce. + /// The plaintext command section. + /// An input or the destination has the wrong size. + public static int Seal( + Span destination, + ReadOnlySpan cmdKey, + ReadOnlySpan authId, + ReadOnlySpan connectionNonce, + ReadOnlySpan data) + { + if (cmdKey.Length != VmessCmdKey.Size) + throw new ArgumentException($"cmdKey must be exactly {VmessCmdKey.Size} bytes.", nameof(cmdKey)); + if (authId.Length != VmessAuthId.Size) + throw new ArgumentException($"AuthID must be exactly {VmessAuthId.Size} bytes.", nameof(authId)); + if (connectionNonce.Length != ConnectionNonceSize) + throw new ArgumentException( + $"Connection nonce must be exactly {ConnectionNonceSize} bytes.", nameof(connectionNonce)); + if (data.Length > ushort.MaxValue) + throw new ArgumentException("Command section exceeds 65535 bytes.", nameof(data)); + + int total = SealOverhead + data.Length; + if (destination.Length < total) + throw new ArgumentException($"Destination must be at least {total} bytes.", nameof(destination)); + + Span key = stackalloc byte[GcmKeySize]; + Span nonce = stackalloc byte[GcmNonceSize]; + try + { + authId.CopyTo(destination); + + // --- length AEAD: uint16 BE of len(data), AAD = authid --- + Span lengthPlaintext = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(lengthPlaintext, (ushort)data.Length); + + VmessKdf.Kdf16(cmdKey, LengthKeyLabel, authId, connectionNonce, key); + VmessKdf.Kdf12(cmdKey, LengthNonceLabel, authId, connectionNonce, nonce); + using (var lengthGcm = new AesGcm(key, TagSize)) + { + lengthGcm.Encrypt( + nonce, + lengthPlaintext, + destination.Slice(VmessAuthId.Size, 2), + destination.Slice(VmessAuthId.Size + 2, TagSize), + authId); + } + + int nonceOffset = VmessAuthId.Size + 2 + TagSize; // 34 + connectionNonce.CopyTo(destination[nonceOffset..]); + + // --- payload AEAD: the command section, AAD = authid --- + int payloadOffset = nonceOffset + ConnectionNonceSize; // 42 + VmessKdf.Kdf16(cmdKey, PayloadKeyLabel, authId, connectionNonce, key); + VmessKdf.Kdf12(cmdKey, PayloadNonceLabel, authId, connectionNonce, nonce); + using (var payloadGcm = new AesGcm(key, TagSize)) + { + payloadGcm.Encrypt( + nonce, + data, + destination.Slice(payloadOffset, data.Length), + destination.Slice(payloadOffset + data.Length, TagSize), + authId); + } + + return total; + } + finally + { + CryptographicOperations.ZeroMemory(key); + CryptographicOperations.ZeroMemory(nonce); + } + } + + /// + /// Builds the complete sealed VMessAEAD request header into + /// and returns the number of bytes written. + /// + /// Receives the header; see . + /// The 16-byte cmdKey (). + /// The random and time inputs (). + /// The option bitflags (see ). + /// The security type nibble (e.g. ). + /// The command byte (e.g. ). + /// The target host. + /// The target port. + /// An input or the destination has the wrong size. + public static int Build( + Span destination, + ReadOnlySpan cmdKey, + in VmessRequestMaterial material, + byte option, + byte security, + byte command, + string host, + int port) + { + byte[] rented = ArrayPool.Shared.Rent(MaxCommandSectionSize); + try + { + int length = WriteCommandSection(rented, material, option, security, command, host, port); + + Span authId = stackalloc byte[VmessAuthId.Size]; + VmessAuthId.Create(cmdKey, material.AuthIdTimestamp, material.AuthIdRandom, authId); + + return Seal(destination, cmdKey, authId, material.ConnectionNonce, rented.AsSpan(0, length)); + } + finally + { + // The command section carries the body key and IV in the clear. + ArrayPool.Shared.Return(rented, clearArray: true); + } + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessResponse.cs b/QuickProxyNet/Internal/Vmess/VmessResponse.cs new file mode 100644 index 0000000..b36da78 --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessResponse.cs @@ -0,0 +1,300 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The parsed plaintext of a VMessAEAD server response header. +/// +internal readonly struct VmessResponseHeader +{ + /// The response verifier the server echoed back (byte 0). + public byte ResponseVerifier { get; init; } + + /// The response option bitmask (byte 1). + public byte Option { get; init; } + + /// The command id (byte 2); 0 means "no command". + public byte Command { get; init; } + + /// + /// The length of the command data (byte 3). Reported as 0 when + /// is 0, because the field is then meaningless. + /// + public byte CommandLength { get; init; } +} + +/// +/// Reads the VMessAEAD (alterId = 0) server response header +/// (proxy/vmess/encoding/client.go, DecodeResponseHeader). +/// +/// +/// The response header is sealed in two AES-128-GCM blocks — always AES-128-GCM, +/// independent of the negotiated body cipher — with empty associated data: +/// +/// encryptedLength(2 + 16 tag) ‖ encryptedHeader(L + 16 tag) +/// +/// Each block uses a single fixed nonce taken straight from the KDF; there is no chunk +/// counter here. Both keys derive from responseBodyKey and both nonces from +/// responseBodyIV, which are themselves SHA256(requestBodyKey/IV)[0:16]. +/// +internal static class VmessResponse +{ + /// Size of the sealed length block: uint16 + 16-byte tag. + public const int LengthBlockSize = 2 + TagSize; + + /// Length of a response body key or IV, in bytes. + public const int KeySize = VmessBodyKeys.ResponseKeySize; + + /// Smallest legal header plaintext: respV, option, command, commandLength. + public const int MinHeaderSize = 4; + + private const int TagSize = 16; + private const int NonceSize = 12; + + // Layout of the scratch buffer holding all four derived values. + private const int LengthKeyOffset = 0; // 16 bytes + private const int LengthIvOffset = LengthKeyOffset + 16; // 12 bytes + private const int HeaderKeyOffset = LengthIvOffset + NonceSize; // 16 bytes + private const int HeaderIvOffset = HeaderKeyOffset + 16; // 12 bytes + private const int MaterialSize = HeaderIvOffset + NonceSize; // 56 + + private static ReadOnlySpan LengthKeyLabel => "AEAD Resp Header Len Key"u8; + private static ReadOnlySpan LengthIvLabel => "AEAD Resp Header Len IV"u8; + private static ReadOnlySpan HeaderKeyLabel => "AEAD Resp Header Key"u8; + private static ReadOnlySpan HeaderIvLabel => "AEAD Resp Header IV"u8; + + /// + /// Derives the response body key and IV from the request ones: + /// SHA256(requestBodyKey)[0:16] and SHA256(requestBodyIV)[0:16]. + /// + /// The 16-byte request body key. + /// The 16-byte request body IV. + /// Receives the 16-byte response body key. + /// Receives the 16-byte response body IV. + /// An input or destination has the wrong size. + public static void DeriveBodyKeys( + ReadOnlySpan requestBodyKey, + ReadOnlySpan requestBodyIv, + Span responseBodyKey, + Span responseBodyIv) + { + if (requestBodyKey.Length != KeySize) + throw new ArgumentException($"Request body key must be exactly {KeySize} bytes.", nameof(requestBodyKey)); + if (requestBodyIv.Length != KeySize) + throw new ArgumentException($"Request body IV must be exactly {KeySize} bytes.", nameof(requestBodyIv)); + + VmessBodyKeys.DeriveResponseKeyOrIv(requestBodyKey, responseBodyKey); + VmessBodyKeys.DeriveResponseKeyOrIv(requestBodyIv, responseBodyIv); + } + + /// + /// Derives the four response-header AEAD parameters. Note that both keys come + /// from and both IVs from + /// . + /// + /// The 16-byte response body key. + /// The 16-byte response body IV. + /// Receives the 16-byte length-block key. + /// Receives the 12-byte length-block nonce. + /// Receives the 16-byte header key. + /// Receives the 12-byte header nonce. + /// An input or destination has the wrong size. + public static void DeriveHeaderKeys( + ReadOnlySpan responseBodyKey, + ReadOnlySpan responseBodyIv, + Span lengthKey, + Span lengthIv, + Span headerKey, + Span headerIv) + { + if (responseBodyKey.Length != KeySize) + throw new ArgumentException($"Response body key must be exactly {KeySize} bytes.", nameof(responseBodyKey)); + if (responseBodyIv.Length != KeySize) + throw new ArgumentException($"Response body IV must be exactly {KeySize} bytes.", nameof(responseBodyIv)); + + VmessKdf.Kdf16(responseBodyKey, LengthKeyLabel, lengthKey); + VmessKdf.Kdf12(responseBodyIv, LengthIvLabel, lengthIv); + VmessKdf.Kdf16(responseBodyKey, HeaderKeyLabel, headerKey); + VmessKdf.Kdf12(responseBodyIv, HeaderIvLabel, headerIv); + } + + /// + /// Reads, decrypts and validates the server response header from + /// , leaving the stream positioned on the first response + /// body chunk. Command data, if any, is parsed and skipped. + /// + /// The transport, positioned at the start of the response header. + /// The 16-byte response body key (). + /// The 16-byte response body IV (). + /// + /// The response verifier byte the client put in its request header; the server must echo it. + /// + /// Cancels the read. + /// A key or IV has the wrong size. + /// The response header is truncated. + /// + /// The header failed authentication, is malformed, or the response verifier does not match. + /// + public static async ValueTask ReadAsync( + Stream stream, + ReadOnlyMemory responseBodyKey, + ReadOnlyMemory responseBodyIv, + byte expectedResponseVerifier, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + if (responseBodyKey.Length != KeySize) + throw new ArgumentException($"Response body key must be exactly {KeySize} bytes.", nameof(responseBodyKey)); + if (responseBodyIv.Length != KeySize) + throw new ArgumentException($"Response body IV must be exactly {KeySize} bytes.", nameof(responseBodyIv)); + + byte[] material = ArrayPool.Shared.Rent(MaterialSize); + try + { + DeriveInto(material, responseBodyKey, responseBodyIv); + + int headerLength; + byte[] lengthBlock = ArrayPool.Shared.Rent(LengthBlockSize); + try + { + // A short read here is truncation, never a clean end of stream. + await stream.ReadExactlyAsync(lengthBlock.AsMemory(0, LengthBlockSize), cancellationToken); + headerLength = OpenLength(material, lengthBlock); + } + catch (EndOfStreamException ex) + { + throw ClosedBeforeResponse(ex); + } + finally + { + ArrayPool.Shared.Return(lengthBlock, clearArray: true); + } + + if (headerLength < MinHeaderSize) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"VMess response header is too short: {headerLength} bytes (minimum {MinHeaderSize})."); + + int sealedLength = headerLength + TagSize; + byte[] buffer = ArrayPool.Shared.Rent(sealedLength + headerLength); + try + { + await stream.ReadExactlyAsync(buffer.AsMemory(0, sealedLength), cancellationToken); + return OpenHeader(material, buffer, headerLength, expectedResponseVerifier); + } + catch (EndOfStreamException ex) + { + throw ClosedBeforeResponse(ex); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + finally + { + ArrayPool.Shared.Return(material, clearArray: true); + } + } + + // ---- synchronous cores (span locals are illegal inside an async method) ---- + + private static void DeriveInto( + byte[] material, ReadOnlyMemory responseBodyKey, ReadOnlyMemory responseBodyIv) + => DeriveHeaderKeys( + responseBodyKey.Span, + responseBodyIv.Span, + material.AsSpan(LengthKeyOffset, 16), + material.AsSpan(LengthIvOffset, NonceSize), + material.AsSpan(HeaderKeyOffset, 16), + material.AsSpan(HeaderIvOffset, NonceSize)); + + private static int OpenLength(byte[] material, byte[] lengthBlock) + { + Span plaintext = stackalloc byte[2]; + try + { + using var gcm = new AesGcm(material.AsSpan(LengthKeyOffset, 16), TagSize); + gcm.Decrypt( + material.AsSpan(LengthIvOffset, NonceSize), + lengthBlock.AsSpan(0, 2), + lengthBlock.AsSpan(2, TagSize), + plaintext); + } + catch (CryptographicException ex) + { + // Same class of failure as a verifier mismatch below: the bytes were sealed under + // keys that are not ours, so whatever answered is not the session we requested. + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, + "VMess response header length block failed authentication: the response was " + + "produced with different keys, so the server rejected the request or something " + + "else answered in its place.", ex); + } + + return BinaryPrimitives.ReadUInt16BigEndian(plaintext); + } + + /// + /// The error for a connection that ends before a response header arrives. + /// + /// + /// This is the normal way a VMess server says no: an unknown AuthID — wrong id, a non-zero + /// alterId on the server, or clocks more than about two minutes apart — is simply dropped, + /// never answered. So the bare this replaces was the + /// library's most common rejection surfacing with no protocol name and no hint. + /// + private static ProxyProtocolException ClosedBeforeResponse(EndOfStreamException inner) => + new(ProxyErrorCode.ConnectionFailed, + "VMess server closed the connection before sending a response header. This is how " + + "a VMess server rejects a request it cannot authenticate: check the user id, that " + + "the server's alterId is 0 (VMessAEAD), and that this machine's clock is within " + + "about two minutes of the server's.", + inner); + + private static VmessResponseHeader OpenHeader( + byte[] material, byte[] buffer, int headerLength, byte expectedResponseVerifier) + { + // The plaintext is written just past the sealed bytes inside the same rental. + Span plaintext = buffer.AsSpan(headerLength + TagSize, headerLength); + try + { + using var gcm = new AesGcm(material.AsSpan(HeaderKeyOffset, 16), TagSize); + gcm.Decrypt( + material.AsSpan(HeaderIvOffset, NonceSize), + buffer.AsSpan(0, headerLength), + buffer.AsSpan(headerLength, TagSize), + plaintext); + } + catch (CryptographicException ex) + { + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, + "VMess response header failed authentication: the response was produced with " + + "different keys, so the server rejected the request or something else answered " + + "in its place.", ex); + } + + if (plaintext[0] != expectedResponseVerifier) + throw new ProxyProtocolException(ProxyErrorCode.AuthFailed, + $"VMess response verifier mismatch: expected 0x{expectedResponseVerifier:X2}, " + + $"got 0x{plaintext[0]:X2}."); + + byte command = plaintext[2]; + byte commandLength = plaintext[3]; + + // Commands are dynamic-port / switch-account directives; a minimal client parses + // and ignores them, but the bytes must still fit inside the decrypted header. + if (command != 0 && MinHeaderSize + commandLength > headerLength) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"VMess response command data ({commandLength} bytes) does not fit the " + + $"{headerLength}-byte header."); + + return new VmessResponseHeader + { + ResponseVerifier = plaintext[0], + Option = plaintext[1], + Command = command, + CommandLength = command == 0 ? (byte)0 : commandLength, + }; + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs b/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs new file mode 100644 index 0000000..14d8b94 --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs @@ -0,0 +1,235 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// A pass-through stream that consumes and verifies the VMessAEAD server response header +/// () lazily, on the first read, and then forwards every +/// operation to the transport unchanged. +/// +/// +/// +/// This shim exists because of when a real VMess server flushes its response header. +/// v2ray-core and Xray-core write it into a buf.NewBufferedWriter and only flush +/// after the target has produced its first bytes (the "optimize for small +/// response packet" read in proxy/vmess/inbound blocks before +/// writer.SetBuffered(false)). Reading the header eagerly inside +/// ConnectAsync would therefore deadlock against any client-speaks-first protocol +/// — HTTP, TLS, the Minecraft handshake — because the client would be blocked waiting for +/// a header the server will not send until the client's request reaches the target. +/// +/// +/// Placing the header read here instead of inside keeps the body +/// framing and the response header as separate, separately testable layers: the transport +/// is wrapped as transport → VmessResponseStream → VmessStream, so the first chunk +/// read that performs transparently pulls the header first. +/// +/// +/// The tradeoff is that a rejected handshake (wrong user id, a tampered response, a +/// response verifier mismatch) surfaces on the first Read rather than from +/// ConnectAsync. That is inherent to VMess rather than a consequence of this +/// design: a server that rejects the AuthID simply stops responding, so there is no +/// failure to observe at connect time either way. +/// +/// +internal sealed class VmessResponseStream : Stream +{ + private readonly Stream _inner; + private readonly bool _leaveInnerOpen; + private readonly byte _expectedResponseVerifier; + + private byte[]? _responseBodyKey; + private byte[]? _responseBodyIv; + private VmessResponseHeader _header; + private bool _headerRead; + private bool _disposed; + + /// + /// Wraps , which must be positioned at the start of the + /// server response header. + /// + /// The transport (the raw stream or the TLS session). + /// The 16-byte response body key (). + /// The 16-byte response body IV. + /// The verifier byte the server must echo back. + /// When true, disposing this stream leaves the transport open. + /// is null. + /// A key or IV is not exactly 16 bytes. + public VmessResponseStream( + Stream innerStream, + ReadOnlySpan responseBodyKey, + ReadOnlySpan responseBodyIv, + byte expectedResponseVerifier, + bool leaveInnerOpen = false) + { + ArgumentNullException.ThrowIfNull(innerStream); + if (responseBodyKey.Length != VmessResponse.KeySize) + throw new ArgumentException( + $"Response body key must be exactly {VmessResponse.KeySize} bytes.", nameof(responseBodyKey)); + if (responseBodyIv.Length != VmessResponse.KeySize) + throw new ArgumentException( + $"Response body IV must be exactly {VmessResponse.KeySize} bytes.", nameof(responseBodyIv)); + + _inner = innerStream; + _leaveInnerOpen = leaveInnerOpen; + _expectedResponseVerifier = expectedResponseVerifier; + _responseBodyKey = responseBodyKey.ToArray(); + _responseBodyIv = responseBodyIv.ToArray(); + } + + /// Whether the response header has already been read and verified. + public bool IsHeaderRead => _headerRead; + + /// + /// The parsed response header. Only meaningful once is true. + /// + public VmessResponseHeader Header => _header; + + /// + /// Reads and verifies the response header if that has not happened yet. Callers that + /// want handshake failures reported before the first payload read can await this + /// explicitly — at the cost of the deadlock described on the class. + /// + public async ValueTask ReadHeaderAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_headerRead) + return _header; + + byte[]? key = _responseBodyKey; + byte[]? iv = _responseBodyIv; + if (key is null || iv is null) + throw new InvalidOperationException("The VMess response header keys are no longer available."); + + _header = await VmessResponse.ReadAsync( + _inner, key, iv, _expectedResponseVerifier, cancellationToken); + _headerRead = true; + + // The header keys are single-use; the body keys live in VmessStream. + ClearKeys(); + return _header; + } + + public override bool CanRead => !_disposed && _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed && _inner.CanWrite; + 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 ================================ + + /// + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_headerRead) + await ReadHeaderAsync(cancellationToken); + + return await _inner.ReadAsync(buffer, cancellationToken); + } + + /// + 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) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_headerRead) + ReadHeaderAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + return _inner.Read(buffer); + } + + /// + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + // ================================ writing ================================ + + /// + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _inner.WriteAsync(buffer, 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); + _inner.Write(buffer); + } + + /// + public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); + + // ================================ disposal ================================ + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + ClearKeys(); + if (!_leaveInnerOpen) + await _inner.DisposeAsync(); + + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _disposed = true; + ClearKeys(); + if (!_leaveInnerOpen) + _inner.Dispose(); + } + else + { + _disposed = true; + } + + base.Dispose(disposing); + } + + private void ClearKeys() + { + if (_responseBodyKey is not null) + { + CryptographicOperations.ZeroMemory(_responseBodyKey); + _responseBodyKey = null; + } + + if (_responseBodyIv is not null) + { + CryptographicOperations.ZeroMemory(_responseBodyIv); + _responseBodyIv = null; + } + } +} diff --git a/QuickProxyNet/Internal/Vmess/VmessStream.cs b/QuickProxyNet/Internal/Vmess/VmessStream.cs new file mode 100644 index 0000000..d5332e4 --- /dev/null +++ b/QuickProxyNet/Internal/Vmess/VmessStream.cs @@ -0,0 +1,590 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet; + +/// +/// The body cipher negotiated in the VMess request header's security nibble. +/// +internal enum VmessSecurity : byte +{ + /// AES-128-GCM (security type 3): the 16-byte body key is used directly. + Aes128Gcm = VmessRequest.SecurityAes128Gcm, + + /// + /// ChaCha20-Poly1305 (security type 4): the body key is MD5-expanded to 32 bytes + /// (). + /// + ChaCha20Poly1305 = VmessRequest.SecurityChaCha20Poly1305, +} + +/// +/// The VMessAEAD (alterId = 0) encrypted body stream: a that +/// seals everything written and opens everything read, using the baseline chunk framing +/// (request option S set; M, P and A cleared). +/// +/// +/// +/// Wire format of one chunk (common/crypto/auth.go with PlainChunkSizeParser): +/// +/// +/// length(2, big-endian) ‖ sealed(plaintextLen + 16) +/// +/// The length field carries the sealed size — the plaintext length plus the +/// 16-byte AEAD tag — not the plaintext length. Associated data is empty. +/// +/// The 12-byte nonce of a chunk is uint16BE(counter) ‖ bodyIV[2:12] +/// (GenerateChunkNonce): only the first two bytes ever change, and the counter is a +/// ushort that wraps at 0xFFFF. The read and write directions are fully +/// independent — separate keys, IVs and counters — so the two halves can be closed at +/// different times. +/// +/// +/// End of stream is in band: an authenticated empty chunk (00 10 followed by +/// the 16-byte tag of an empty plaintext). +/// returns 0 only after opening such a chunk. A short read of the length prefix or +/// of a chunk body is truncation and raises ; a failed +/// tag check raises with +/// (the AEAD exception is its inner). Neither is +/// ever reported as a clean end of stream. +/// +/// +internal sealed class VmessStream : Stream +{ + /// Size of every AEAD tag, in bytes. + public const int TagSize = 16; + + /// Size of the plain uint16 chunk length prefix, in bytes. + public const int LengthPrefixSize = 2; + + /// Largest sealed chunk the uint16 length field can express. + public const int MaxSealedChunkSize = ushort.MaxValue; + + /// + /// Largest plaintext a peer may put in one chunk (65535 − 16). This is the + /// wire-format hard cap the reader must tolerate; it is deliberately not the + /// smaller value this implementation emits. + /// + public const int MaxReceivePlaintextSize = MaxSealedChunkSize - TagSize; + + /// Size of the send buffer — Xray's buf.Size. + public const int SendBufferSize = 8192; + + /// + /// Largest plaintext this implementation puts in one chunk: + /// buf.Size − Overhead(16) − SizeBytes(2) = 8174, matching Xray-core. + /// + public const int MaxSendPlaintextSize = SendBufferSize - TagSize - LengthPrefixSize; + + private const int InitialReceiveBufferSize = 8192; + + private readonly Stream _inner; + private readonly bool _leaveInnerOpen; + private readonly ChunkCipher _writer; + private readonly ChunkCipher _reader; + + private byte[]? _sendBuffer; + private byte[]? _receiveSealed; + private byte[]? _receivePlain; + private int _plainOffset; + private int _plainCount; + + private bool _readEof; + private bool _writeCompleted; + private bool _disposed; + + /// + /// Wraps in the VMess body framing. + /// + /// The transport, positioned after the request header / response header. + /// The 16-byte body key for the client→server direction (the request body key). + /// The 16-byte body IV for the client→server direction (the request body IV). + /// The 16-byte body key for the server→client direction (the response body key). + /// The 16-byte body IV for the server→client direction (the response body IV). + /// The negotiated body cipher. + /// When true, disposing this stream does not dispose the transport. + /// is null. + /// A key or IV is not exactly 16 bytes. + /// + /// is not a supported body cipher, or ChaCha20-Poly1305 was + /// requested but the platform does not provide it. + /// + public VmessStream( + Stream innerStream, + ReadOnlySpan writeKey, + ReadOnlySpan writeIv, + ReadOnlySpan readKey, + ReadOnlySpan readIv, + VmessSecurity security, + bool leaveInnerOpen = false) + { + ArgumentNullException.ThrowIfNull(innerStream); + + _inner = innerStream; + _leaveInnerOpen = leaveInnerOpen; + _writer = new ChunkCipher(writeKey, writeIv, security, nameof(writeKey), nameof(writeIv)); + try + { + _reader = new ChunkCipher(readKey, readIv, security, nameof(readKey), nameof(readIv)); + } + catch + { + _writer.Dispose(); + throw; + } + } + + /// The number of chunks sealed so far (the next write uses this as its nonce counter). + public ushort WriteChunkCounter => _writer.Counter; + + /// The number of chunks opened so far (the next read uses this as its nonce counter). + public ushort ReadChunkCounter => _reader.Counter; + + /// Whether the terminating empty chunk has already been written. + public bool IsWriteCompleted => _writeCompleted; + + /// Whether the peer's terminating empty chunk has been received and verified. + public bool IsReadCompleted => _readEof; + + public override bool CanRead => !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed && !_writeCompleted; + 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 ================================ + + /// + public override async ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_plainCount == 0) + { + if (_readEof || buffer.IsEmpty) + return 0; + + // Skip zero-length plaintext chunks other than the terminator? There are none: + // a length of 16 *is* the terminator, so one chunk always yields data or EOF. + int sealedLength = await ReceiveSealedChunkAsync(cancellationToken); + int plaintextLength = sealedLength - TagSize; + + if (plaintextLength == 0) + { + // The terminator must still be opened: that authenticates its tag and + // advances the nonce counter exactly like any other chunk. + OpenChunk(sealedLength, Memory.Empty); + _readEof = true; + return 0; + } + + // Fast path: the caller's buffer can hold the whole chunk, so the AEAD + // writes the plaintext straight into it — no intermediate buffer, no copy. + if (buffer.Length >= plaintextLength) + { + OpenChunk(sealedLength, buffer[..plaintextLength]); + return plaintextLength; + } + + EnsurePlainCapacity(plaintextLength); + OpenChunk(sealedLength, _receivePlain.AsMemory(0, plaintextLength)); + _plainOffset = 0; + _plainCount = plaintextLength; + } + + int count = Math.Min(buffer.Length, _plainCount); + _receivePlain!.AsMemory(_plainOffset, count).CopyTo(buffer); + _plainOffset += count; + _plainCount -= count; + return count; + } + + /// + 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(); + + // Reads one sealed chunk (length prefix + ciphertext + tag) into _receiveSealed and + // returns the sealed length. The chunk is not opened yet. + private async ValueTask ReceiveSealedChunkAsync(CancellationToken cancellationToken) + { + _receiveSealed ??= ArrayPool.Shared.Rent(InitialReceiveBufferSize); + + // A short read of the prefix is truncation, never a clean end of stream: end of stream + // is in band (the authenticated empty chunk), and a FIN in its place is exactly what a + // truncation attack looks like. Xray's own reader is more lenient here; this one keeps + // the documented contract. + await _inner.ReadExactlyAsync(_receiveSealed.AsMemory(0, LengthPrefixSize), cancellationToken); + int sealedLength = BinaryPrimitives.ReadUInt16BigEndian(_receiveSealed.AsSpan(0, LengthPrefixSize)); + + if (sealedLength < TagSize) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"VMess chunk length {sealedLength} is smaller than the {TagSize}-byte AEAD tag."); + + // The 2-byte prefix has already been decoded, so the buffer can be swapped freely. + if (_receiveSealed.Length < sealedLength) + { + ArrayPool.Shared.Return(_receiveSealed, clearArray: true); + _receiveSealed = ArrayPool.Shared.Rent(sealedLength); + } + + await _inner.ReadExactlyAsync(_receiveSealed.AsMemory(0, sealedLength), cancellationToken); + return sealedLength; + } + + // 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, InitialReceiveBufferSize)); + } + else if (_receivePlain.Length < plaintextLength) + { + ArrayPool.Shared.Return(_receivePlain, clearArray: true); + _receivePlain = ArrayPool.Shared.Rent(plaintextLength); + } + } + + // Opens the sealed chunk currently in _receiveSealed into `plaintext`, which must be + // exactly sealedLength - TagSize bytes (possibly empty for the terminator). + private void OpenChunk(int sealedLength, Memory plaintext) + { + int plaintextLength = sealedLength - TagSize; + try + { + _reader.Open( + _receiveSealed!.AsSpan(0, plaintextLength), + _receiveSealed.AsSpan(plaintextLength, TagSize), + plaintext.Span); + } + catch (CryptographicException ex) + { + // The nonce has already advanced, so nothing after this chunk can be opened either: + // the stream is over. A caller reading a Stream expects a proxy error here, not an + // AEAD primitive's exception. + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + "A VMess data chunk failed authentication: it was not sealed with this session's keys " + + "or was altered in transit. The stream cannot continue.", ex); + } + } + + // ================================ writing ================================ + + /// + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_writeCompleted) + throw new InvalidOperationException( + "The VMess write direction is closed: the terminating chunk has already been sent."); + + while (!buffer.IsEmpty) + { + int count = Math.Min(buffer.Length, MaxSendPlaintextSize); + int length = SealChunk(buffer.Span.Slice(0, count)); + await _inner.WriteAsync(_sendBuffer!.AsMemory(0, length), cancellationToken); + buffer = buffer.Slice(count); + } + } + + /// + 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) + { + byte[] rented = ArrayPool.Shared.Rent(Math.Max(buffer.Length, 1)); + try + { + buffer.CopyTo(rented); + WriteAsync(rented.AsMemory(0, buffer.Length), CancellationToken.None) + .AsTask().GetAwaiter().GetResult(); + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + /// + public override void Write(byte[] buffer, int offset, int count) + => WriteAsync(buffer.AsMemory(offset, count), CancellationToken.None) + .AsTask().GetAwaiter().GetResult(); + + /// + /// Half-closes the write direction by sending the authenticated empty chunk + /// (00 10 ‖ tag). Idempotent; the read direction keeps working afterwards. + /// + /// Cancels the write. + public async ValueTask CompleteWriteAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_writeCompleted) + return; + + _writeCompleted = true; + int length = SealChunk(ReadOnlySpan.Empty); + await _inner.WriteAsync(_sendBuffer!.AsMemory(0, length), cancellationToken); + await _inner.FlushAsync(cancellationToken); + } + + // Frames and seals one chunk into _sendBuffer; returns the number of wire bytes. + private int SealChunk(ReadOnlySpan plaintext) + { + byte[] buffer = _sendBuffer ??= ArrayPool.Shared.Rent(SendBufferSize); + + int sealedLength = plaintext.Length + TagSize; + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(0, LengthPrefixSize), (ushort)sealedLength); + _writer.Seal( + plaintext, + buffer.AsSpan(LengthPrefixSize, plaintext.Length), + buffer.AsSpan(LengthPrefixSize + plaintext.Length, TagSize)); + + return LengthPrefixSize + sealedLength; + } + + // ================================ disposal ================================ + + /// + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + try + { + if (!_writeCompleted) + { + try + { + await CompleteWriteAsync(CancellationToken.None); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException + or OperationCanceledException or ProxyProtocolException) + { + // A broken transport must not turn disposal into a failure. The WebSocket + // transport reports a dead socket as ProxyProtocolException, so that is + // as much "broken transport" here as an IOException is. + } + } + } + finally + { + _disposed = true; + // Transport first: a read still in flight on another thread targets these buffers, + // and closing the transport is what faults it. Returning the arrays to the pool + // before that lets a late completion write into someone else's rental. + if (!_leaveInnerOpen) + await _inner.DisposeAsync(); + ReleaseResources(); + } + + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (_disposed) + { + base.Dispose(disposing); + return; + } + + if (disposing) + { + try + { + if (!_writeCompleted) + { + _writeCompleted = true; + try + { + int length = SealChunk(ReadOnlySpan.Empty); + _inner.Write(_sendBuffer!.AsSpan(0, length)); + _inner.Flush(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException or ProxyProtocolException) + { + // See DisposeAsync. + } + } + } + finally + { + _disposed = true; + if (!_leaveInnerOpen) + _inner.Dispose(); + ReleaseResources(); + } + } + else + { + _disposed = true; + } + + base.Dispose(disposing); + } + + private void ReleaseResources() + { + _writer.Dispose(); + _reader.Dispose(); + + if (_sendBuffer is not null) + { + ArrayPool.Shared.Return(_sendBuffer, clearArray: true); + _sendBuffer = null; + } + + if (_receiveSealed is not null) + { + ArrayPool.Shared.Return(_receiveSealed, clearArray: true); + _receiveSealed = null; + } + + if (_receivePlain is not null) + { + ArrayPool.Shared.Return(_receivePlain, clearArray: true); + _receivePlain = null; + } + + _plainOffset = 0; + _plainCount = 0; + } + + // ================================ chunk cipher ================================ + + /// + /// One direction of the body stream: the AEAD instance plus the rolling + /// uint16BE(counter) ‖ bodyIV[2:12] nonce. + /// + private sealed class ChunkCipher : IDisposable + { + private const int NonceSize = 12; + private const int BodyKeySize = 16; + + private readonly byte[] _nonce = new byte[NonceSize]; + private readonly AesGcm? _aes; + private readonly ChaCha20Poly1305? _chacha; + private ushort _counter; + + public ChunkCipher( + ReadOnlySpan bodyKey, ReadOnlySpan bodyIv, + VmessSecurity security, string keyName, string ivName) + { + if (bodyKey.Length != BodyKeySize) + throw new ArgumentException($"Body key must be exactly {BodyKeySize} bytes.", keyName); + if (bodyIv.Length != BodyKeySize) + throw new ArgumentException($"Body IV must be exactly {BodyKeySize} bytes.", ivName); + + // Bytes [2..12) of the IV are the constant tail of every chunk nonce. + bodyIv.Slice(2, NonceSize - 2).CopyTo(_nonce.AsSpan(2)); + + switch (security) + { + case VmessSecurity.Aes128Gcm: + _aes = new AesGcm(bodyKey, TagSize); + break; + + case VmessSecurity.ChaCha20Poly1305: + if (!ChaCha20Poly1305.IsSupported) + throw new NotSupportedException( + "VMess security 'chacha20-poly1305' requires ChaCha20-Poly1305, which this " + + "platform does not provide. Use 'aes-128-gcm' instead."); + + Span expanded = stackalloc byte[VmessBodyKeys.ChaCha20KeySize]; + try + { + VmessBodyKeys.ExpandChaCha20Key(bodyKey, expanded); + _chacha = new ChaCha20Poly1305(expanded); + } + finally + { + CryptographicOperations.ZeroMemory(expanded); + } + break; + + default: + throw new NotSupportedException( + $"VMess security type {(byte)security} is not a supported body cipher; " + + "only aes-128-gcm and chacha20-poly1305 are implemented."); + } + } + + public ushort Counter => _counter; + + public void Seal(ReadOnlySpan plaintext, Span ciphertext, Span tag) + { + NextNonce(); + if (_aes is not null) + _aes.Encrypt(_nonce, plaintext, ciphertext, tag); + else + _chacha!.Encrypt(_nonce, plaintext, ciphertext, tag); + } + + public void Open(ReadOnlySpan ciphertext, ReadOnlySpan tag, Span plaintext) + { + NextNonce(); + if (_aes is not null) + _aes.Decrypt(_nonce, ciphertext, tag, plaintext); + else + _chacha!.Decrypt(_nonce, ciphertext, tag, plaintext); + } + + // Writes the current counter into nonce[0..2) and advances it; a ushort wraps + // 0xFFFF -> 0x0000 exactly like Go's uint16, and nonce[2..12) never changes. + private void NextNonce() + { + BinaryPrimitives.WriteUInt16BigEndian(_nonce.AsSpan(0, 2), _counter); + _counter++; + } + + public void Dispose() + { + _aes?.Dispose(); + _chacha?.Dispose(); + CryptographicOperations.ZeroMemory(_nonce); + } + } +} diff --git a/QuickProxyNet/Proxy.cs b/QuickProxyNet/Proxy.cs index c7d7571..64e2b19 100644 --- a/QuickProxyNet/Proxy.cs +++ b/QuickProxyNet/Proxy.cs @@ -18,6 +18,49 @@ namespace QuickProxyNet; /// 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. + /// + /// + /// 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. + /// + public static async ValueTask ConnectAsync(string proxyLink, string host, int port, + CancellationToken cancellationToken = default) + { + IProxyClient client = ProxyClientFactory.Instance.Create(proxyLink); + return await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + } + + /// + /// Connects to a target host through a proxy described by a URL or share link, giving up + /// after . + /// + /// The proxy URL or share link. + /// 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 async ValueTask ConnectAsync(string proxyLink, string host, int port, + TimeSpan timeout, CancellationToken cancellationToken = default) + { + IProxyClient client = ProxyClientFactory.Instance.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. diff --git a/QuickProxyNet/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs index f423b94..cdb5eba 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -13,6 +13,69 @@ public sealed class 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. @@ -20,8 +83,30 @@ public sealed class ProxyClientFactory /// 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 { @@ -30,7 +115,9 @@ public IProxyClient Create(Uri proxyUri) "socks4" => ProxyType.Socks4, "socks4a" => ProxyType.Socks4a, "socks5" => ProxyType.Socks5, - _ => throw new NotSupportedException($"Scheme: {proxyUri.Scheme}") + _ => 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)) diff --git a/QuickProxyNet/ProxyProtocolException.cs b/QuickProxyNet/ProxyProtocolException.cs index 51200e1..7752f68 100644 --- a/QuickProxyNet/ProxyProtocolException.cs +++ b/QuickProxyNet/ProxyProtocolException.cs @@ -52,5 +52,12 @@ public enum ProxyErrorCode /// Failed to resolve host to an IPv4 address (required for SOCKS4). SocksNoIPv4Address, /// The proxy connection timed out. - Timeout + Timeout, + /// A protocol string field (e.g. a target host name) exceeded the 255-byte limit. + StringTooLong, + /// + /// The HTTP upgrade to an alternate transport (ws, httpupgrade) failed — the + /// server refused it, or answered something that is not a WebSocket handshake. + /// + TransportUpgradeFailed } diff --git a/QuickProxyNet/ProxyType.cs b/QuickProxyNet/ProxyType.cs index 35646e3..41a9e37 100644 --- a/QuickProxyNet/ProxyType.cs +++ b/QuickProxyNet/ProxyType.cs @@ -1,4 +1,4 @@ -namespace QuickProxyNet; +namespace QuickProxyNet; public enum ProxyType { @@ -6,5 +6,8 @@ public enum ProxyType Https, Socks4, Socks4a, - Socks5 -} \ No newline at end of file + Socks5, + Vless, + Vmess, + Trojan +} diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index c7f9a58..40f24d1 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -1,21 +1,22 @@ - + - net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0;net11.0 enable enable latest v - 3.0 + 4.0 QuickProxyNet Titlehhhh Titlehhhh - QuickProxyNet is a high-performance .NET library for connecting to servers via HTTP, HTTPS, SOCKS4, SOCKS4a, and SOCKS5 proxies, providing direct Stream access for low-level network operations. - proxy;networking;http;socks;high-performance - Copyright © Titlehhhh 2024 + 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 + 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 @@ -26,12 +27,12 @@ icon.png LICENSE.txt - readme.md + README.md - + @@ -41,6 +42,7 @@ + diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index 0fcf12c..51cc745 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -1,15 +1,21 @@ # QuickProxyNet -High-performance, zero-dependency C# library for connecting through HTTP, HTTPS, SOCKS4, SOCKS4a, and SOCKS5 proxies. 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 and Trojan. Returns a raw `Stream` for direct data access. -**Targets:** .NET 8 / .NET 9 / .NET 10 +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. + +No companion package and no external binary are involved. The `grpc` and `xhttp` transports, Vision's TLS-in-TLS splice, and a genuine uTLS browser fingerprint are not implemented. + +**Targets:** .NET 8 / .NET 9 / .NET 10 / .NET 11 ## Quick Start +`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 -// One-liner — ideal for mass proxy checking +// Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess links 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, TimeSpan.FromSeconds(5)); ``` @@ -25,16 +31,17 @@ await using var stream = await new Uri("http://proxy:8080") - Zero runtime dependencies (BCL only) - Zero-alloc protocol logic (`ArrayPool`, `stackalloc`, `Utf8Formatter`, `ValueTask`) +- 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`) +- Static API (`Proxy.ConnectAsync`) and factory API (`ProxyClientFactory.Instance.Create(link)`) ## Error Handling ```csharp try { - await using var stream = await Proxy.ConnectAsync(proxyUri, host, port, + await using var stream = await Proxy.ConnectAsync(proxyLink, host, port, TimeSpan.FromSeconds(5)); } catch (ProxyProtocolException ex) when (ex.ErrorCode == ProxyErrorCode.Timeout) @@ -47,4 +54,4 @@ catch (ProxyProtocolException ex) when (ex.ErrorCode == ProxyErrorCode.AuthFaile } ``` -See [full documentation](https://github.com/Titlehhhh/QuickProxyNet) for all error codes and configuration options. +See [full documentation](https://github.com/Titlehhhh/QuickProxyNet) for all error codes, the support matrix, and configuration options. diff --git a/README.md b/README.md index d105702..a4614b9 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,18 @@ **QuickProxyNet** is a high-performance, zero-dependency C# library for connecting to servers through proxy protocols. It provides direct `Stream` access with minimal allocations and latency — ideal for mass proxy checking, crawlers, and any scenario where thousands of proxy connections are made in parallel. -**Targets:** .NET 8 / .NET 9 / .NET 10 +**Targets:** .NET 8 / .NET 9 / .NET 10 / .NET 11 ## Features - **Zero runtime dependencies** — BCL only, no third-party packages - **Zero-alloc protocol logic** — `ArrayPool`, `stackalloc`, `Utf8Formatter`, `ValueTask` throughout -- **5 proxy protocols** — HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 -- **Static one-liner API** — `Proxy.ConnectAsync(uri, host, port)` for mass checkers +- **5 classic proxy protocols** — HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 +- **3 VPN-style protocols** — VLESS, VMess (VMessAEAD), Trojan, over `tcp`, `ws` or `httpupgrade` +- **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 +- **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` - **Raw Stream access** — full control over the tunneled connection @@ -27,16 +31,26 @@ dotnet add package QuickProxyNet ## Quick Start -### One-liner (recommended for mass checking) +### 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. ```csharp -// Single call — no intermediate objects allocated +// Works for http/https/socks4/socks4a/socks5/vless/trojan/vmess links 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, TimeSpan.FromSeconds(5)); ``` +```csharp +// A VLESS REALITY share link — handled in-process, no Xray required +await using var stream = await Proxy.ConnectAsync( + "vless://uuid@1.2.3.4:443?security=reality&pbk=...&sni=www.example.com&flow=xtls-rprx-vision", + "example.com", 443, + TimeSpan.FromSeconds(10)); +``` + ### Extension method on Uri ```csharp @@ -47,7 +61,7 @@ await using var stream = await proxy.ConnectThroughProxyAsync("example.com", 443 ### Factory API (when you need to configure the client) ```csharp -var client = ProxyClientFactory.Instance.Create(new Uri("socks5://proxy:1080")); +var client = ProxyClientFactory.Instance.Create("socks5://proxy:1080"); client.NoDelay = true; client.ReadTimeout = 5000; @@ -65,6 +79,71 @@ await using var stream = await client.ConnectAsync("example.com", 80, TimeSpan.FromSeconds(10)); ``` +## VLESS REALITY + +REALITY support is implemented in managed code inside the core package (`QuickProxyNet/Internal/Reality/`): a hand-written TLS 1.3 client — ClientHello construction, X25519 key exchange, the HKDF key schedule, and the record layer. There is no external process and no extra package; the zero-dependency promise holds. The `xtls-rprx-vision` flow is supported, including its padding protocol in both directions (`VisionStream`). + +**Honest limitation:** the ClientHello is not yet a real browser fingerprint. There is no GREASE, no padding extension, the extension order does not match Chrome, and key_share offers bare X25519 where Chrome sends X25519MLKEM768. This does not prevent connecting or carrying traffic — verified against live servers — but DPI that fingerprints ClientHellos can tell it apart from a browser. See [docs/reality-fingerprint-plan.md](docs/reality-fingerprint-plan.md) and the `TlsClientHello` class comment for the byte-level details and the plan. + +Also not implemented: Vision's TLS-in-TLS splice. It is a throughput optimization and does not affect the wire format. + +## What's supported, what's not + +### Protocols + +| Protocol | Status | Notes | +|---|---|---| +| HTTP / HTTPS `CONNECT` | Supported | optional basic auth | +| SOCKS4 / SOCKS4a / SOCKS5 | Supported | SOCKS5 with optional username/password auth | +| VLESS | Supported | `security=none`, `tls`, `reality`; flow `xtls-rprx-vision` | +| Trojan | Supported | over TLS | +| VMess | Supported | VMessAEAD, `alterId=0`, optional TLS | +| Hysteria2 / TUIC | **Not supported** | QUIC-based; the library has no datagram model | +| Shadowsocks | **Not supported** | — | + +### Transports + +| Transport | Status | +|---|---| +| `tcp` / `raw` | Supported | +| `ws` / `websocket` | Supported | +| `httpupgrade` | Supported | +| `grpc` | **Not supported** — needs an HTTP/2 layer | +| `xhttp` | **Not supported** — needs HTTP/2/3 | + +### VLESS security and flow + +| | Status | +|---|---| +| `security=none` | Supported | +| `security=tls` | Supported — `SslStream` | +| `security=reality` | Supported — managed TLS 1.3, no external binary | +| `flow` empty | Supported | +| `flow=xtls-rprx-vision` | Supported — padding protocol both ways; TLS-in-TLS splice not implemented | +| `flow=xtls-rprx-vision-udp443` | **Not supported** | +| Browser-grade ClientHello fingerprint | **Not implemented** — see the REALITY section above | + +### How much of the real world that covers + +Measured by this library's own parsers over a corpus of 20 228 share links (snapshot of +2026-08-21; the list changes daily, so these are proportions, not constants). + +| | Share of corpus | Status | +|---|---|---| +| Plain VLESS / VMess / Trojan (`tcp`, `ws`, `httpupgrade`) | 41% | Works | +| VLESS REALITY, incl. `xtls-rprx-vision` | 46% | Works | +| `grpc` transport | 6.0% | Not supported | +| Hysteria2 | 2.9% | Not supported | +| `xhttp` transport | 2.9% | Not supported | +| `xtls-rprx-vision-udp443` flow | 0.1% | Not supported | + +UDP is not supported as a class: the whole library is built around `ConnectAsync(...) -> Stream`. + +There is no companion package and no optional binary. An earlier `QuickProxyNet.Reality` package +drove a child Xray process to reach REALITY; it was removed once the managed implementation was +verified against live servers. What it also covered — `grpc`, `xhttp`, a real uTLS fingerprint — +is listed above as unsupported rather than quietly delegated. + ## Error Handling All proxy protocol errors throw `ProxyProtocolException` with a specific `ProxyErrorCode`: @@ -72,7 +151,7 @@ All proxy protocol errors throw `ProxyProtocolException` with a specific `ProxyE ```csharp try { - await using var stream = await Proxy.ConnectAsync(proxyUri, host, port, + await using var stream = await Proxy.ConnectAsync(proxyLink, host, port, TimeSpan.FromSeconds(5)); } catch (ProxyProtocolException ex) @@ -89,7 +168,9 @@ catch (ProxyProtocolException ex) // Proxy requires credentials (HTTP 407) break; case ProxyErrorCode.AuthFailed: - // Wrong username/password + // Wrong username/password; for REALITY, the server did not recognise our + // pbk/sid and relayed us to the decoy site; for VMess, the response was + // sealed under keys that are not ours break; case ProxyErrorCode.InvalidResponse: // Proxy returned garbage @@ -100,6 +181,13 @@ catch (ProxyProtocolException ex) } ``` +The VPN-style protocols use the same type and codes. `RealityHandshakeException` is a +`ProxyProtocolException`, so the `catch` above sees it. Two rejections that protocols express +by simply closing the connection — a VLESS or VMess server that does not know the id — come +back as `ConnectionFailed` with a message naming the protocol and what to check (for VMess: +the id, a non-zero `alterId` on the server, and a clock more than ~2 minutes off). Error +messages never contain the credential: a malformed user id is reported by length, not value. + ### Error Codes | Code | Description | @@ -125,6 +213,9 @@ catch (ProxyProtocolException ex) | `Socks4` | SOCKS4 | UserId | No (resolved locally) | | `Socks4a` | SOCKS4a | UserId | Yes | | `Socks5` | SOCKS5 (RFC 1928) | Username/Password (RFC 1929) | Yes | +| `Vless` | VLESS (incl. REALITY, `xtls-rprx-vision`) | UUID | Yes | +| `Vmess` | VMess (VMessAEAD) | UUID | Yes | +| `Trojan` | Trojan | Password | Yes | ## Configuration Options diff --git a/Sample/Program.cs b/Sample/Program.cs index 4273a29..e47627f 100644 --- a/Sample/Program.cs +++ b/Sample/Program.cs @@ -1,81 +1,37 @@ -using System.Net; -using System.Net.Sockets; using System.Text; using QuickProxyNet; -namespace Sample; +// Paste any supported link — the library reads the scheme itself: +// socks5://user:pass@host:1080 +// http://host:8080 +// vless://uuid@host:443?security=reality&pbk=...&sid=...&sni=...&flow=xtls-rprx-vision +// trojan://password@host:443?sni=... +// vmess:// +Console.Write("Proxy link: "); +string? link = Console.ReadLine(); +if (string.IsNullOrWhiteSpace(link)) + return; -class Program -{ - static async Task Main(string[] args) - { - Console.WriteLine("Enter proxy uri (protocol://:@host:port"); - string? proxyUri = Console.ReadLine(); - - if (string.IsNullOrEmpty(proxyUri)) - return; - - Uri uri = new Uri(proxyUri); - - ProxyClientFactory factory = new ProxyClientFactory(); - - IProxyClient proxyClient = factory.Create(uri); - - Stream stream = await proxyClient.ConnectAsync("example.com", 80); // 80 for HTTP - - - HttpRequestMessage requestMessage = new HttpRequestMessage(); - - requestMessage.Method = HttpMethod.Get; - requestMessage.RequestUri = new Uri("https://www.example.com/"); - - string rawString = await ToRawString(requestMessage); +const string Host = "example.com"; - byte[] bytes = Encoding.UTF8.GetBytes(rawString); - - await stream.WriteAsync(bytes); - - using StreamReader sr = new StreamReader(stream); - while (!sr.EndOfStream) - { - var line = await sr.ReadLineAsync(); - if (!string.IsNullOrEmpty(line)) - { - Console.WriteLine(line); - } - } - } - - public static async Task ToRawString(HttpRequestMessage request) - { - var sb = new StringBuilder(); - - var line1 = $"{request.Method} {request.RequestUri} HTTP/{request.Version}"; - sb.AppendLine(line1); - - foreach (var (key, value) in request.Headers) - foreach (var val in value) - { - var header = $"{key}: {val}"; - sb.AppendLine(header); - } - - if (request.Content?.Headers != null) - { - foreach (var (key, value) in request.Content.Headers) - foreach (var val in value) - { - var header = $"{key}: {val}"; - sb.AppendLine(header); - } - } - - sb.AppendLine(); +try +{ + await using Stream stream = await Proxy.ConnectAsync(link, Host, 80, TimeSpan.FromSeconds(10)); - var body = await (request.Content?.ReadAsStringAsync() ?? Task.FromResult(null)); - if (!string.IsNullOrWhiteSpace(body)) - sb.AppendLine(body); + await stream.WriteAsync(Encoding.ASCII.GetBytes( + $"GET / HTTP/1.1\r\nHost: {Host}\r\nConnection: close\r\n\r\n")); + await stream.FlushAsync(); - return sb.ToString(); - } -} \ No newline at end of file + using var reader = new StreamReader(stream, Encoding.ASCII); + Console.WriteLine(await reader.ReadToEndAsync()); +} +catch (ProxyProtocolException ex) +{ + // Every protocol failure — classic or VPN-style, REALITY included — arrives here with a code. + Console.Error.WriteLine($"{ex.ErrorCode}: {ex.Message}"); +} +catch (NotSupportedException ex) +{ + // The link parsed, but names a transport or flow this library does not implement. + Console.Error.WriteLine(ex.Message); +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..eaadea7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# VPN-подобные proxy-протоколы + +Эта папка фиксирует исследование протоколов, которые потенциально можно +добавить в QuickProxyNet с сохранением главной модели библиотеки: +`ConnectAsync(...) -> Stream`. + +Файлы: + +Wire-level заметки по протоколам: + +- [VLESS](vless.md) — реализован +- [VMess](vmess.md), [VMessAEAD request](vmess-aead-request.md), [VMessAEAD body](vmess-aead-body.md) — реализован +- [Trojan](trojan.md) — реализован +- [Hysteria2](hysteria2.md), [hy2](hy2.md), [TUIC](tuic.md) — **не реализованы** +- [Анализ QUIC-протоколов](quic-protocols-analysis.md) — почему Hysteria2 и TUIC + не ложатся на модель «один `ConnectAsync` — один сокет» + +Планы и результаты: + +- [План реализации](implementation-plan.md) — что сделано, что дальше, и замеры + покрытия по реальному корпусу ссылок +- [Достоверность отпечатка REALITY](reality-fingerprint-plan.md) — побайтовый + разбор ClientHello Chrome 133 и план работ; относится к `QuickProxyNet/Internal/Reality/` + +Наличие документа не означает, что протокол поддержан: там, где поддержки нет, +это сказано явно. diff --git a/docs/hy2.md b/docs/hy2.md new file mode 100644 index 0000000..e62e6fd --- /dev/null +++ b/docs/hy2.md @@ -0,0 +1,46 @@ +# hy2 + +`hy2://` - распространенный короткий scheme для Hysteria2 share links. Это не +отдельный протокол от Hysteria2; в документации QuickProxyNet его стоит считать +алиасом `hysteria2://`. + +Подробное описание wire-level поведения, QUIC/TLS стека, auth, obfuscation и +Stream-модели см. в [hysteria2.md](hysteria2.md). + +## URI + +Пример: + +```text +hy2://@server.example.com:443?sni=example.com&obfs=salamander&obfs-password=secret#name +``` + +Нормализация для будущей реализации: + +```text +scheme: hy2 -> protocol: Hysteria2 +password: userinfo +server: host +server_port: port +sni/peer: TLS server name +obfs + obfs-password: optional obfuscation +``` + +## Практический вывод + +Если QuickProxyNet когда-либо добавит Hysteria2, поддержка `hy2://` должна быть +частью того же клиента: + +```text +ProxyType.Hysteria2 +schemes: hysteria2, hy2 +``` + +Для `ConnectAsync(...)->Stream` это все равно QUIC stream wrapper, а не +`NetworkStream`. + +## Источники + +- Hysteria2 docs: https://v2.hysteria.network/docs/ +- Hysteria2 protocol specification: https://v2.hysteria.network/docs/developers/Protocol/ +- sing-box Hysteria2 notes: https://sing-box.sagernet.org/manual/proxy-protocol/hysteria2/ diff --git a/docs/hysteria2.md b/docs/hysteria2.md new file mode 100644 index 0000000..a7a149d --- /dev/null +++ b/docs/hysteria2.md @@ -0,0 +1,172 @@ +# Hysteria2 + +Hysteria2 - TCP/UDP proxy поверх QUIC. Это принципиально другой класс, чем +SOCKS/HTTP/VLESS-over-TCP: транспортом является UDP, а пользовательские TCP +соединения отображаются на QUIC streams. + +`hy2://` обычно является share-link алиасом для Hysteria2. + +Версионный контекст: upstream Hysteria2 на 2026-06-25 имеет актуальные +публичные документы по v2 protocol и релизы ветки `app/v2.x`. + +## Стек + +```text +UDP socket -> QUIC + TLS 1.3 -> Hysteria2 protocol -> QUIC stream -> target TCP stream +``` + +Для QuickProxyNet это значит: вернуть `Stream` можно только как wrapper поверх +одного QUIC bidirectional stream. Это не `NetworkStream`; нужна QUIC-библиотека +и управление QUIC connection lifecycle. + +## URI + +Распространенная форма: + +```text +hysteria2://@:?sni=example.com#name +hy2://@:?sni=example.com#name +``` + +Частые параметры: + +| Параметр | Значение | +| --- | --- | +| password/userinfo | пароль auth | +| `sni` | TLS SNI | +| `insecure` / `allowInsecure` | политика сертификата | +| `obfs` | obfuscation type | +| `obfs-password` | пароль obfuscation | +| `pinSHA256` | pin сертификата | +| `alpn` | ALPN, обычно HTTP/3-like | + +## Protocol overview + +Официальная спецификация Hysteria2 описывает протокол как TCP & UDP proxy на +базе QUIC. По умолчанию он мимикрирует под HTTP/3 traffic. Для соединения: + +1. Клиент открывает QUIC connection к серверу. +2. Выполняется TLS 1.3 handshake внутри QUIC. +3. Клиент проходит Hysteria authentication. +4. Для TCP target открывается QUIC stream. +5. В stream передается request к целевому host/port и затем payload. + +Точная структура frames зависит от Hysteria2 specification; это не простой +однократный header поверх TCP socket. Есть отдельная логика для TCP streams, +UDP relay, keepalive и masquerade/obfuscation. + +Публично описанные wire-level свойства: + +- числа больше 1 байта идут big-endian; +- QUIC varint совместим с RFC 9000; +- TCP-туннель строится как отдельный QUIC bidirectional stream; +- UDP идет через QUIC datagrams с собственной сессией/пакетом/фрагментами; +- Salamander obfuscation описана отдельно на уровне байтов; +- Gecko в upstream-документах помечается как experimental obfuscation mode. + +## Шифрование + +QUIC всегда включает TLS 1.3 security. Поэтому режима "без шифрования" в +практическом смысле нет: + +```text +UDP packets are QUIC-protected +application data goes through QUIC crypto +``` + +Дополнительная obfuscation может накладываться поверх UDP/QUIC для обхода +блокировок, но это не замена TLS. + +На сервере Hysteria обычно требует `tls` или `acme`; одновременно использовать +оба режима нельзя. На клиенте важны `sni`, `insecure`, certificate pinning и +client certificate options. Серверная настройка `sniGuard` может отклонять +handshake, если SNI не соответствует сертификату. + +## Auth и masquerade + +Официальный сервер поддерживает несколько auth-моделей: + +| Режим | Смысл | +| --- | --- | +| `password` | общий секрет | +| `userpass` | alias, фактический secret `username:password` | +| `http` | backend auth через HTTP POST | +| `command` | backend auth через внешний процесс | + +sing-box не имеет отдельного alias `userpass`; для совместимости там надо +передавать строку `username:password` как обычный пароль. + +Masquerade нужен, чтобы endpoint выглядел как HTTP/3 сайт. Если masquerade не +настроен, сервер обычно отвечает `404 Not Found` на HTTP-запросы. Режимы: +`file`, `proxy`, `string`. Если включить obfs, сервер уже не выглядит как +валидный HTTP/3 endpoint. + +## Obfs, bandwidth и realms + +Obfs требует одинаковый пароль на клиенте и сервере. Salamander и Gecko - это +отдельные режимы обфускации поверх базового QUIC/TLS поведения. + +Hysteria2 умеет использовать bandwidth hints и congestion control. Если +bandwidth задан, может использоваться Brutal congestion control; без него +документация описывает non-Brutal controller, обычно BBR. Эти детали upstream +считает implementation details, поэтому их нельзя фиксировать как вечный +wire-contract. + +Hysteria Realms - режим NAT traversal: rendezvous service помогает сторонам +найти друг друга, затем используется UDP hole punching и QUIC соединение +идет напрямую. Realm token не заменяет пароль Hysteria-сервера. + +## TCP как Stream + +Для одного target TCP соединения можно представить: + +```csharp +Stream stream = hysteriaConnection.OpenTcpStream("mc.example.com", 25565); +``` + +Но под капотом это: + +- shared QUIC connection; +- bidirectional QUIC stream; +- Hysteria2 request framing; +- flow control; +- close/reset mapping; +- congestion control. + +В .NET есть `System.Net.Quic`, но он требует платформенной поддержки MsQuic и +не является такой же простой зависимостью, как `Socket`/`SslStream`. +`QuicStream` в .NET наследует `Stream`, поэтому TCP path можно естественно +адаптировать под текущую модель QuickProxyNet. + +## Производительность + +Hysteria2 рассчитан на плохие/lossy сети и активно использует QUIC congestion +control. Для Minecraft это может быть полезно на нестабильных маршрутах, но: + +- добавляется UDP/QUIC stack; +- TCP-over-QUIC может вести себя иначе, чем прямой TCP; +- один QUIC connection может мультиплексировать много target streams. + +## Оценка для QuickProxyNet + +| Задача | Сложность | +| --- | --- | +| Парсить `hy2://`/`hysteria2://` URI | Низкая | +| Открыть QUIC connection | Средняя/высокая | +| Реализовать Hysteria2 auth/framing | Высокая | +| Вернуть один TCP `Stream` | Возможно через wrapper | +| UDP relay | Нужен отдельный datagram API | + +Это не стоит смешивать с базовым `ProxyClient` без проектирования lifecycle: +одно QUIC соединение может обслуживать много потоков, поэтому модель +"один ConnectAsync - один socket" не оптимальна. + +## Источники + +- Hysteria2 protocol specification: https://v2.hysteria.network/docs/developers/Protocol/ +- Hysteria2 full client config: https://v2.hysteria.network/docs/advanced/Full-Client-Config/ +- Hysteria2 about HTTP/3: https://v2.hysteria.network/docs/misc/About-HTTP3/ +- Hysteria repository: https://github.com/apernet/hysteria +- QUIC RFC 9000: https://datatracker.ietf.org/doc/rfc9000/ +- .NET QUIC overview: https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/quic/quic-overview +- `QuicStream`: https://learn.microsoft.com/en-us/dotnet/api/system.net.quic.quicstream diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..60608af --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,318 @@ +# План реализации VPN-протоколов в QuickProxyNet + +Документ разбивает задачу на подзадачи, фиксирует дизайн и перф-приоритеты. +Исследовательские wire-заметки — в соседних файлах (`vless.md`, `trojan.md`, …). +Здесь — как это лечь в код, в каком порядке, и что и как бенчить. + +## 0. Цель и принципы + +Сохранить главную модель библиотеки: `ConnectAsync(...) -> Stream`. Новый +протокол — это ещё один способ довести байты до target host:port, поверх +уже существующего socket/SslStream. Никакого нового публичного контракта, +кроме дополнительных `ProxyType` и клиентов. + +Инварианты из `AGENTS.md`, которые держим на горячем пути: +- `Span`/`Memory`/`stackalloc`/`ArrayPool`, аренда возвращается в `finally`; +- `BinaryPrimitives` для network byte order; +- без LINQ в hot path; +- helpers `internal`, публичное API — с XML-докой; +- multi-target `net8.0`/`net9.0`/`net10.0`/`net11.0`. + +Корпус для проверки корректности — реальные share-ссылки из PypsCFG +(`vless://`, `vmess://`, `trojan://`, `hy2://`, `tuic://`, ~17.7k конфигов). +Используем как fixtures парсинга (не для сетевых тестов в CI). + +## 1. Классификация по сложности + +| Протокол | Транспорт | Крипто сверх BCL | QUIC | Класс | Порядок | +| --- | --- | --- | --- | --- | --- | +| VLESS `none`/`tls` | TCP / TLS | нет | нет | Низкий | **1** | +| Trojan | TLS (обяз.) | SHA-224 (нет в BCL) | нет | Средний | **2** | +| VMess (AEAD) | TCP / TLS | AEAD KDF + body framing | нет | Высокий | 3 | +| Hysteria2 / hy2 | QUIC/UDP | — (TLS 1.3 в QUIC) | да | Высокий | 4 | +| TUIC | QUIC/UDP | TLS exporter token | да | Высокий | 4 | +| VLESS REALITY / XTLS-vision | TCP | собственный TLS 1.3 (X25519, HKDF, record layer) | нет | Очень высокий | **сделано** (§8) | + +Обоснование порядка: VLESS `none`/`tls` не тянет новых зависимостей и +прогоняет всю новую архитектуру (config-парсер, UUID big-endian, address +writer, header build, response read). Trojan переиспользует address writer и +TLS-слой, добавляя только SHA-224. VMess — отдельная state-machine со +stream-wrapper. QUIC-протоколы ломают модель «один ConnectAsync — один socket» +(одно QUIC-соединение мультиплексирует стримы) и выносятся в опциональный +пакет `QuickProxyNet.Quic`. + +## 2. Архитектура + +### 2.1 Где расходятся текущая модель и VPN-протоколы + +Сейчас `ProxyConnector`/`Proxy` диспатчат по `Uri.Scheme` и берут из URI +только `UserInfo` (creds). Для HTTP/SOCKS этого хватает. VPN-протоколам нужен +богатый конфиг: uuid/password, `security`, `sni`, `alpn`, `flow`, `pbk`, +`sid`, `fp`, `type`, `path`, `host`. VMess вообще кодируется как base64(JSON). + +Вывод: нужен **слой типизированного конфига** — парсер share-ссылки → +options-объект. Клиент держит options (как `HttpsProxyClient` держит TLS-опции). + +### 2.2 Новые типы + +``` +QuickProxyNet/ + ProxyType.cs + Vless, Vmess, Trojan, Hysteria2, Tuic + Clients/ + VlessClient.cs : ProxyClient (none/tls) + TrojanClient.cs : ProxyClient (фаза 2) + Configs/ + VlessOptions.cs typed config (uuid, security, sni, alpn, flow, transport…) + VlessShareLink.cs static parser: string/Uri -> VlessOptions + TrojanOptions.cs (фаза 2) + Internal/ + ProxyAddress.cs address writer: atyp+addr / port, IPv4/IPv6/domain + UuidCodec.cs canonical UUID string -> 16 bytes big-endian, zero-alloc + VlessHelper.cs request header build + response header read + Sha224.cs (фаза 2) внутренний SHA-224 для Trojan +``` + +Публичная поверхность фазы 1: `ProxyType.Vless`, `VlessClient`, +`VlessOptions`, `VlessShareLink.Parse(...)`, регистрация схемы `vless` в +`ProxyClientFactory`. + +### 2.3 Поток VLESS + +``` +none: socket -> NetworkStream -> [write VLESS req] -> [read VLESS resp] -> return stream +tls: socket -> SslStream.AuthAsClient(sni,alpn) -> [write req] -> [read resp] -> return SslStream +``` + +Response header (`ver(1) + addonsLen(1) + addons`) читаем ДО возврата stream, +иначе пользователь увидит `00 00` перед байтами target. Если сервер прислал +больше (overread — часть ответа target), оборачиваем в `PrefixedStream` +(уже есть в `HttpHelper`; выносим в `Internal/PrefixedStream.cs` для повторного +использования). + +### 2.4 Wire-формат request (базовый TCP, addons=0) + +``` +ver(0x00) | uuid(16 BE) | addonsLen(0x00) | cmd(0x01 TCP) | port(2 BE) | atyp(1) | addr(var) +``` + +Внимание к порядку: у VLESS **port идёт перед address** (в отличие от SOCKS5). +Address type: `01`+IPv4(4), `02`+len(1)+domain, `03`+IPv6(16). + +## 3. Перф-план (что и как бенчим) + +«Перф самое главное» → каждый перф-чувствительный юнит получает бенч в +`QuickProxyNet.Benchmarks` (BenchmarkDotNet, `[MemoryDiagnoser]`), сравнение +наивной и оптимизированной реализации, как уже сделано в +`FindEndOfHeadersBenchmark`. + +| Юнит | Наивно | Оптимизировано | Метрика | +| --- | --- | --- | --- | +| UUID `string`→16B | `Guid.Parse().ToByteArray()` (mixed-endian **баг** + alloc) | `Guid.TryWriteBytes(span, bigEndian:true)` / ручной hex | ns + B/op | +| Request header build | `List`/`MemoryStream` | `stackalloc`/pooled + `BinaryPrimitives` | ns + B/op (цель 0 alloc) | +| Share-link parse | `Uri` + `HttpUtility.ParseQueryString` | span-парсер query, без `Dictionary` для hot-полей | ns + B/op на 60k конфигов | +| Address write | — | общий `ProxyAddress` writer | 0 alloc | + +Каждый бенч запускаем (`dotnet run -c Release --project QuickProxyNet.Benchmarks`) +и фиксируем числа в PR. Корректность UUID big-endian и address writer — +обязательно юнит-тестами (это самые частые места ошибок). + +### 3.1 Замеры фазы 1 (`VlessBenchmark`) + +BenchmarkDotNet 0.15.8, .NET 10, Xeon E5-2697 v4, ShortRun/InProcessNoEmit +(конвенция репо). Надёжна колонка аллокаций; ns при ShortRun шумят (маржа до +84%), поэтому по времени делаем только грубые выводы. + +| Юнит | Baseline | Оптимизировано | Alloc | Вывод | +| --- | --- | --- | --- | --- | +| Header build | `MemoryStream` 296 B | span/pooled **0 B** | −100% | zero-alloc, время в пределах шума | +| Share-link parse | `Uri`+`Dictionary` 1848 B | span-scan **1088 B** | −41% | пол — сам `new Uri`; словарь/строки значений убраны | +| UUID encode | `Guid.Parse().ToByteArray()` (mixed-endian **баг**) | `Guid.TryParse`+`TryWriteBytes(bigEndian)` | ≈0 | ручной hex не быстрее → оставляем codec на `Guid` | + +Итог: header — 0 alloc (цель достигнута). Parse можно ещё ускорить полностью +ручным парсером без `Uri`, но `Uri` даёт робастность на «грязном» корпусе — +пока оставляем, отмечено как возможная оптимизация фазы 2. + +## 4. API-эксперименты + +Открытые вопросы дизайна публичного API (проверяем на первой фазе): + +1. **Конструирование клиента.** `new VlessClient(host, port, VlessOptions)` vs + `VlessClient.FromShareLink("vless://…")`. Гипотеза: оба — фабрику через + share-link, ctor через options. +2. **Статический путь.** Расширить ли `Proxy.ConnectAsync(Uri,…)` на `vless`? + Проблема: текущий путь тянет из URI только creds. Вариант — перегрузка + `Proxy.ConnectAsync(VlessOptions, host, port)` без диспатча по строке. +3. **TLS-опции.** Переиспользовать паттерн `HttpsProxyClient` + (`ServerCertificateValidationCallback`, `SslProtocols`, ALPN) — вынести в + общий `TlsProxyOptions`, чтобы Trojan/VLESS-tls его делили. +4. **Куда парсить `flow`/REALITY.** В options положить поля, но в фазе 1 при + `security=reality` или непустом `flow` кидать `NotSupportedException` с + явным сообщением — чтобы контракт был честным. + +Решения фиксируем здесь по мере проверки бенчами/тестами. + +## 5. Подзадачи (роадмап) + +**Фаза 1 — VLESS none/tls (текущая):** +- [ ] `ProxyType` + `Vless` и заглушки остальных +- [ ] `Internal/UuidCodec` + тесты + бенч +- [ ] `Internal/ProxyAddress` writer + тесты +- [ ] `Configs/VlessOptions` + `VlessShareLink.Parse` + тесты на корпусе +- [ ] `Internal/VlessHelper` (build request / read response) + тесты через `FakeProxyStream` +- [ ] `Clients/VlessClient` (none + tls) +- [ ] wiring: `ProxyConnector` (для none), `ProxyClientFactory` (схема `vless`) +- [ ] бенчи: UUID, header build, share-link parse — прогнать, записать числа + +**Фаза 2 — Trojan:** `Internal/Sha224` + тесты (вектора NIST) + бенч, +`TrojanOptions`/parser, `TrojanClient` (TLS обяз.), общий `ProxyAddress`. + +**Фаза 3 — VMess AEAD:** KDF/auth, body framing, `VmessStream` wrapper, +time-sync, `security` (`aes-128-gcm`/`chacha20-poly1305`), `alterId=0`. + +Зафиксированные API-решения фазы 3 (утверждены 2026-07-23): + +1. **Share-link / JSON.** `VmessShareLink.Parse` разбирает классический + `vmess://base64(JSON)`. Декодируем base64 → парсим `System.Text.Json` + `Utf8JsonReader` (в составе фреймворка на net8/9/10, без внешних + зависимостей; zero-alloc-ридер по UTF-8). URI-style vmess-ссылки и не-base64 + вход → `FormatException` с явным сообщением (расширим позже). +2. **Body security (`scy`).** Поддерживаем `aes-128-gcm`, `chacha20-poly1305` + и `auto` (→ `aes-128-gcm` при аппаратном AES, иначе `chacha20-poly1305`, + как v2ray). `none`/`zero`/`aes-128-cfb`/legacy → `NotSupportedException`. +3. **Детерминизм тестов.** Заголовок VMess вшивает UTC-время + random, поэтому + вводим **internal seam** для времени (`TimeProvider`, как в базовом + `ProxyClient`) и для random/nonce. Helper даёт детерминированные wire-байты + в юнит-тестах и сверяется с независимым эталоном. Публичное API seam не + расширяет — только internal. +4. **Транспорт/TLS/legacy.** Поддерживаем `net=tcp` c `security` none/tls + (`SslStream` снаружи + `VmessStream` внутри — паттерн `VlessClient`), + `alterId=0` (VMessAEAD). `ws`/`grpc`/`h2`/`httpupgrade`/`reality`, + `alterId>0` (legacy MD5 auth), UDP/Mux → `NotSupportedException` с явным + сообщением (честный gating до записи байтов). + +**Фаза 4 — транспорты ws/httpupgrade (сделано 2026-08-14):** общий транспортный +слой `Internal/Transports/` для всех трёх протоколов. Порядок слоёв: +`socket → optional SslStream → transport → protocol header`. Фрейминг RFC 6455 +берём из BCL (`WebSocket.CreateFromStream`), а не пишем руками. Подробности и +грабли — в `AGENTS.md`, пункты 13–14. + +**Фаза 5 — QUIC (Hysteria2/TUIC):** отдельный пакет `QuickProxyNet.Quic` на +`System.Net.Quic`, lifecycle одного QUIC-соединения на несколько стримов. +**Депризорити­зировано** — см. §7. + +**REALITY / XTLS-vision:** сделано, в ядре, управляемым TLS 1.3 — см. §8. Не сделан +браузерный отпечаток ClientHello; это следующий шаг по REALITY +(`reality-fingerprint-plan.md`). + +## 7. Что делать дальше: решение по цифрам (2026-08-14) + +Порядок фаз в этом документе был выбран по «сложности реализации», а не по тому, +сколько реальных конфигов он открывает. Прогон `tools/CorpusCheck` по 21 403 +реальным ссылкам это исправил. Ключевая метрика — **сколько ссылок реально может +подключиться**, а не сколько распарсилось: парсинг успешен и для REALITY, и для +grpc, которые падают уже на `ConnectAsync`. + +Было (только `tcp`/`raw`): + +| | коннектится | доля | +| --- | ---: | ---: | +| vless | 663 / 17 367 | 3.8% | +| trojan | 871 / 1 285 | 67.8% | +| vmess | 1 320 / 2 279 | 57.9% | +| **всего** | **2 854 / 21 403** | **13.3%** | + +Стало (ws + httpupgrade + разбор `security=` у vmess): + +| | коннектится | доля | +| --- | ---: | ---: | +| vless | 6 162 / 17 367 | 35.5% | +| trojan | 1 279 / 1 285 | 99.5% | +| vmess | 2 277 / 2 279 | 99.9% | +| **всего** | **9 718 / 21 403** | **45.4%** | + +Что осталось блокировать и чего это стоит: + +| Блокер | Ссылок | % корпуса | Цена | +| --- | ---: | ---: | --- | +| REALITY | 10 653 | 49.8% | uTLS ClientHello — `SslStream` не умеет | +| gRPC | 991 | 4.6% | HTTP/2-фрейминг | +| xhttp | 789 | 3.7% | нестандартный, только Xray | +| Hysteria2/TUIC | 472 | 2.2% | QUIC, ломает «один ConnectAsync — один сокет» | + +**Вывод: QUIC — худшая из оставшихся инвестиций.** Самая тяжёлая архитектурная +работа в роадмапе ради 2.2% охвата. Он стоял «фазой 4» только потому, что шёл +следующим по документу — это не обоснование. Следующий по отношению +охват/стоимость — gRPC. REALITY — половина корпуса, и на момент этого замера +вывод был: отдельный проект, а не фича. Он и оказался отдельным проектом — см. §8. + +Метод, а не только результат: считать надо то, что может провалиться. «Процент +распарсенного» рос до 99.9% ровно тогда, когда 96% ссылок не могли подключиться — +метрика, которая не умеет падать, ничего не измеряет. + +## 6. Границы фазы 1 (honest scope) + +Историческая справка: границы, с которых начиналась фаза 1. + +Поддерживалось: `vless://` с `security=none` и `security=tls`, транспорт +`tcp`/`raw`, команда TCP, адреса IPv4/IPv6/domain, `sni`/`alpn`. +Явно НЕ поддерживалось (кидали `NotSupportedException`): `reality`, непустой +`flow`, транспорты `ws`/`grpc`/`xhttp`/`httpupgrade`, команды UDP/Mux. + +С тех пор закрыты `ws` и `httpupgrade` (фаза 4), а `reality` и +`flow=xtls-rprx-vision` — в самом ядре, через `VlessClient` (§8). Остаются `grpc`, +`xhttp` и UDP/Mux. + + +## 8. REALITY: что вышло (2026-08-20, уточнено 2026-08-22) + +Прогноз из §7 — «отдельный проект, а не фича» — подтвердился по объёму работы, но +не по форме результата. А вывод «пока нет способа подделать uTLS-отпечаток, честный +`NotSupportedException` — единственное правильное поведение» оказался верным лишь +наполовину, и разбираться стоило именно с этой половиной. + +Верно то, что `SslStream` не может говорить на REALITY: он отдаёт рукопожатие +Schannel или OpenSSL и не даёт написать ClientHello. Неверно то, что из этого +следует отказ как единственный выход: рукопожатие можно написать самим. Так и +сделано — X25519, аутентификация REALITY, клиент TLS 1.3 и record layer на C#, в +`QuickProxyNet/Internal/Reality/`, плюс `VisionStream` для `xtls-rprx-vision`. +Проходит настоящее рукопожатие с Xray-core и проносит VLESS без внешнего +процесса; проверено и на loopback-Xray, и на живых узлах из корпуса. + +По дороге существовал второй вариант — пакет `QuickProxyNet.Reality`, который +поднимал дочерний Xray за фасадом `RealityProxy`. Он был удалён, не выходя в NuGet: +когда управляемый путь доказал себя на реальных серверах, вторая реализация ради +`grpc`/`xhttp` и чужого отпечатка перестала стоить своей поддержки. + +### Что оказалось дешевле, чем выглядело + +Криптография самого REALITY — крошечная: X25519, HKDF-SHA256, AES-256-GCM, +HMAC-SHA512. Всё, кроме X25519, есть в платформе. Сложность целиком в TLS 1.3 с +побайтовым контролем ClientHello, а он — не общий стек: одна форма рукопожатия, +без PSK, без возобновления, без HRR, без клиентских сертификатов. Всё за +пределами этой формы отвергается по имени. + +Ключевая деталь, снявшая неопределённость: приватный ключ, которым REALITY +аутентифицируется, — **тот же самый**, что уходит в `key_share`. Сервер достаёт +публичную половину прямо из `clientHello.keyShares`. + +### Что оказалось дороже + +Отпечаток. Наш ClientHello сейчас около 200 байт, у Chrome 133 — примерно 1 700. +И это не косметика: клиент, который *работает*, но не совпадает ни с одним живым +браузером, попадает в более узкую и заметную корзину, чем если бы REALITY не +поддерживался вовсе. Пока этот разрыв не закрыт, управляемая реализация — это +реализация протокола, а не средство обхода блокировок, и в документации типа так +и написано. + +Байтовый разбор и поэтапный план — в [reality-fingerprint-plan.md](reality-fingerprint-plan.md). +Три вещи оттуда меняют подход: JA3 непригоден как критерий (Chrome перемешивает +расширения на каждое соединение), `MLKem` из .NET 10 привязан к ОС и потому +непригоден как основа, а ML-KEM-768 придётся реализовать управляемо на все TFM. + +### Публичная форма — решено + +Внутрь `VlessClient`: `security=reality` в `VlessOptions`, или просто ссылка в +`ProxyClientFactory.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 new file mode 100644 index 0000000..f1f85df --- /dev/null +++ b/docs/quic-protocols-analysis.md @@ -0,0 +1,795 @@ +# QUIC protocols (Hysteria2, TUIC): implementation analysis + +**Статус: ни один из двух протоколов не реализован.** `ProxyType.Hysteria2` и `ProxyType.Tuic` +существуют в `ProxyType.cs`, но `ProxyClientFactory` бросает `ArgumentOutOfRangeException` +для обоих. Этот документ не пересказывает спецификации — `docs/hysteria2.md`, `docs/hy2.md` +и `docs/tuic.md` уже это делают. Здесь отвечаем на два вопроса: *чего реально будет +стоить встроить это в библиотеку* и *что при попытке ломается*. + +Каждое утверждение про `System.Net.Quic` ниже проверялось по reference-сборкам, +установленным на этой машине, и по исходникам `dotnet/runtime`, а не по памяти. +Лог проверки — в [§6](#6-что-было-проверено-а-что-нет). + +--- + +## 0. Сначала выводы + +1. **TUIC v5 нельзя реализовать на `System.Net.Quic` в принципе.** Его токен + `Authenticate` выводится через TLS Keying Material Exporter. В BCL нет API экспортера + нигде (`ExportKeyingMaterial` не встречается в reference pack .NET 10), а MsQuic + получил `ConnectionExportKeyingMaterial` только в **v2.6 и под preview-флагом + компиляции** — тогда как `msquic.dll`, поставляемый в .NET 10.0.10, имеет версию + **2.4.18**. Это жесткий блокер, а не оценка трудозатрат. +2. **Hysteria2 реализуем, но только в своей TCP-половине**, и без ключевой + особенности. UDP relay ездит поверх QUIC datagrams (RFC 9221), которые + `System.Net.Quic` не выставляет наружу ни в *одной* выпущенной версии, включая + preview .NET 11. Brutal congestion control — подключаемый контроллер на стороне + отправителя в quic-go; MsQuic не выставляет через `System.Net.Quic` ни одной ручки + congestion control, так что "Hysteria2" здесь означало бы "Hysteria2 framing поверх + CUBIC из MsQuic". +3. **Конфликт жизненных циклов реален и неустраним.** `ProxyClient.ConnectAsync(string, + int, CancellationToken)` **не `virtual`**, а `ConnectAsync(Stream, …)` — + `abstract` и бессмыслен для QUIC. QUIC-клиент не может быть наследником + `ProxyClient` без изменения API. +4. **Рекомендация: явно владеемый объект сессии как настоящий API плюс тонкий адаптер + `IProxyClient` + `IAsyncDisposable` для совместимости с share-link/фабрикой.** + Никакого статического пула на весь процесс, никакого соединения-на-`ConnectAsync` и + никакого QUIC в статическом быстром пути `Proxy`. Подробности в [§4](#4-варианты-дизайна-жизненного-цикла). +5. **`net8.0` — определяющее ограничение.** `System.Net.Quic` несет на `net8.0` + атрибут уровня сборки `[RequiresPreviewFeatures]` (проверено по метаданным). + Использование его там требует ``, что по умолчанию помечает + тем же атрибутом саму `QuickProxyNet.dll` — вынуждая **каждого потребителя** NuGet-пакета + явно включать preview-функции. Это надо нейтрализовать осознанно. + +--- + +## 1. Как эти два протокола реально устроены поверх QUIC + +Оба — "TCP-and-UDP proxy over QUIC+TLS 1.3". Оба отображают одно проксируемое TCP +соединение на один **bidirectional** QUIC stream. Все остальное различается. + +### 1.1 Hysteria2 + +```text +UDP → QUIC (ALPN "h3") → HTTP/3 auth exchange → per-target bidi stream → target TCP + → QUIC datagrams → target UDP +``` + +**Аутентификация — это настоящий HTTP/3-запрос**, а не байтовый заголовок. +`core/client/client.go` строит `http3.Transport` поверх QUIC-соединения и выполняет: + +```text +POST https://hysteria/auth +Hysteria-Auth: (or "user:pass" for the userpass alias) +Hysteria-CC-RX: (client max receive rate, B/s; 0 = unknown) +Hysteria-Padding: + +HTTP/3 233 HyOK +Hysteria-UDP: true|false +Hysteria-CC-RX: | "auto" +Hysteria-Padding: +``` + +ALPN равен `h3`, потому что его выставляет `http3.Transport` — endpoint должен быть +неотличим от HTTP/3-сайта, из-за чего же неаутентифицированный запрос получает обычный +`404` от masquerade-обработчика. + +**Это самая недооцененная статья затрат во всем проекте.** Переиспользовать `HttpClient` +здесь никак нельзя: `SocketsHttpHandler` предоставляет `ConnectCallback` +(только HTTP/1.x и HTTP/2) и `EnableMultipleHttp3Connections`, но ничего, что вернуло бы +лежащий под ним `QuicConnection`, — а это соединение нужно нам дальше, чтобы открывать +сырые proxy-streams. Значит, реализация Hysteria2 означает написание **минимального +HTTP/3-клиента** поверх собственного `QuicConnection`: unidirectional control stream с +`SETTINGS`-фреймом, заглушки для encoder/decoder-стримов QPACK, `HEADERS`-фрейм со +статической таблицей QPACK + literal field lines и разбор ответа в объеме, достаточном +для чтения статуса 233. Работа ограниченная — динамическая таблица не нужна, — но это +самостоятельная реализация протокола, а не запись заголовка. + +**TCP** — свежий bidi stream на каждую цель, затем: + +```text +TCPRequest : [varint 0x401] [varint addrLen] [addr "host:port"] [varint padLen] [pad] +TCPResponse: [uint8 status] [varint msgLen] [msg] [varint padLen] [pad] + status 0x00 = OK, 0x01 = error +``` + +В отличие от VLESS/VMess, клиент **обязан дождаться `TCPResponse`** перед пересылкой +полезной нагрузки. Это противоположность ловушке с ленивым чтением +`VmessResponseStream`, зафиксированной в AGENTS.md §2: здесь сервер отвечает на сам +request-фрейм, поэтому синхронное чтение в `ConnectAsync` корректно и не приводит к +взаимной блокировке. + +**UDP** — только QUIC datagrams: + +```text +[uint32 sessionId][uint16 packetId][uint8 fragId][uint8 fragCount][varint addrLen][addr][payload] +``` + +Клиент выставляет `EnableDatagrams: true` вместе с `OmitMaxDatagramFrameSize`. +**В `System.Net.Quic` невыразимо.** + +**Congestion control.** После аутентификации клиент вызывает либо +`congestion.UseBrutal(conn, tx, …)`, либо `congestion.UseConfigured(conn, …)` — он +подменяет congestion-контроллер quic-go на живом соединении. Brutal — это вся история +производительности Hysteria: отправитель с фиксированной скоростью, намеренно +игнорирующий сигналы потерь. Внутри MsQuic есть CUBIC/BBR, `System.Net.Quic` не +выставляет ни того, ни другого, и точки подключения своего контроллера нет. Клиент +Hysteria2 на .NET будет совместим по протоколу и несовместим по производительности. + +### 1.2 TUIC (v5) + +```text +UDP → QUIC + TLS 1.3 → uni-stream: Authenticate + → bidi stream: Connect + payload + → uni-stream / datagram: Packet + → datagram: Heartbeat +``` + +Заголовок команды — `[VER=0x05][TYPE][OPT…]`, big-endian, с типами +`00 Authenticate / 01 Connect / 02 Packet / 03 Dissociate / 04 Heartbeat`. + +`Authenticate` — это `[UUID:16][TOKEN:32]`, и спецификация прямо говорит про TOKEN: + +> Сырой пароль клиента хешируется в 256-битный токен с помощью TLS Keying Material +> Exporter текущей TLS-сессии. При экспорте `label` должен быть UUID клиента, а +> `context` — сырым паролем. + +Именно это одно предложение и убивает TUIC на данном стеке — см. [§2.6](#26-чего-нет-вообще). + +`Connect` на этом фоне приятен: открыть bidi stream, записать +`[0x05][0x01][ADDR]` и сразу начать писать payload. **Отдельного успешного ответного +фрейма нет**; ошибка проявляется как reset стрима или закрытие соединения. Это чисто +ложится на `QuicStream` + `QuicException`. + +`Packet` может ехать по uni-stream (`udp_relay_mode=quic`, без потерь, выше накладные +расходы) *либо* в datagram (`udp_relay_mode=native`, значение по умолчанию в sing-box). +Режим uni-stream — тот, который был бы реализуем, если бы проблема с аутентификацией +была решена. `Heartbeat` существует только в виде datagram. + +### 1.3 Различия, которые важны для нас + +| | Hysteria2 | TUIC v5 | +| --- | --- | --- | +| Материал аутентификации | строка пароля в заголовке | 32-байтовый токен из TLS exporter | +| Транспорт аутентификации | HTTP/3 `POST /auth`, нужен настоящий H3-клиент | сырые байты в uni-stream | +| Аутентификация блокирующая | да — сначала надо увидеть `233` | нет — `Connect` можно конвейеризовать | +| ALPN | `h3` (masquerade) | не задан; решает конфигурация сервера | +| Стоимость открытия TCP на живом соединении | 1 RTT (ожидание `TCPResponse`) | 0 RTT (пиши и работай) | +| Сигнал об ошибке TCP | `TCPResponse.status = 0x01` + сообщение | reset стрима, без сообщения | +| UDP | только datagrams | uni-streams **или** datagrams | +| Keepalive | на уровне QUIC (`KeepAlivePeriod`) | на уровне протокола, `Heartbeat` в datagram | +| CC | Brutal / BBR, подменяется клиентом | согласуется только по имени в конфигурации | +| Реализуемо на `System.Net.Quic`? | TCP: да. UDP: нет. Brutal: нет. | **Нет** (аутентификация заблокирована) | + +--- + +## 2. Что нам дает `System.Net.Quic` и чего он требует + +### 2.1 Три типа + +- `QuicConnection` — `IAsyncDisposable`, создается статическим + `QuicConnection.ConnectAsync(QuicClientConnectionOptions, CancellationToken)`. +- `QuicStream` — **наследуется от `System.IO.Stream`**, и это единственный по-настоящему + удачный факт во всем документе: `IProxyClient.ConnectAsync` возвращает + `ValueTask`, и `QuicStream` (или тонкий wrapper для framing) удовлетворяет + этому контракту без слоя адаптации. +- `QuicListener` — нам не нужен; мы всегда клиент. + +Полезные члены `QuicStream`, которые уже есть и которые нужны proxy-клиенту: +`CompleteWrites()`, `WriteAsync(ReadOnlyMemory, bool completeWrites, …)`, +`Abort(QuicAbortDirection, long)`, `ReadsClosed` / `WritesClosed` (оба `Task`), +`Type`, `Id`. `Seek`/`SetLength`/`Length`/`Position` существуют как переопределения, +которые бросают исключение, как и в любом сетевом стриме. + +`QuicStream.CanTimeout` равен `true`, а `ReadTimeout`/`WriteTimeout` настоящие — но см. +ловушку в [§3.6](#36-значения-readtimeout--writetimeout-по-умолчанию-бросают-исключение). + +### 2.2 MsQuic — нативная зависимость + +`System.Net.Quic` — это тонкая прослойка над [MsQuic](https://github.com/microsoft/msquic). +По документации .NET и по проверке на этой машине: + +- **Windows**: `msquic.dll` поставляется **внутри каталога рантайма** + (`shared/Microsoft.NETCore.App//msquic.dll` — присутствует здесь и в 9.0.16, и в + 10.0.10, версия 2.4.18). Требуется **Windows 11 / Windows Server 2022 или + новее** — в более ранних Windows нет криптографических API, нужных QUIC. Windows 10 + отпадает. +- **Linux**: `libmsquic` надо ставить отдельно (`apt/apk/dnf/zypper/yum`) с + packages.microsoft.com или, в случае Alpine, из репозитория дистрибутива. .NET 7+ + требует libmsquic 2.2+. Тянет за собой OpenSSL 1.1/3 и `libnuma1`. +- **macOS**: частично, через `brew install libmsquic`, причем приложение должно + запускаться с `DYLD_FALLBACK_LIBRARY_PATH`, указывающим на brew prefix. Явно вне + тестовой матрицы Microsoft. + +Для такой библиотеки, как эта, это действительно новый класс зависимости: сегодня +`QuickProxyNet` использует только BCL и работает везде, где работает .NET. QUIC-клиент +работает на *подмножестве* этого, определяемом в рантайме, на машине пользователя. + +### 2.3 `IsSupported` и что происходит, если он false + +И `QuicConnection.IsSupported`, и `QuicListener.IsSupported` — статические `bool`, +аннотированные `[SupportedOSPlatformGuard("windows"/"linux"/"osx")]`. Когда MsQuic +отсутствует или недоступен TLS 1.3, `QuicConnection.ConnectAsync` бросает +**`PlatformNotSupportedException`** (проверено в `QuicConnection.cs`: `if (!IsSupported) +throw new PlatformNotSupportedException(...)`), со строкой причины от MsQuic. + +Два следствия для нас: + +1. Правило AGENTS.md "никогда не понижать тихо" означает, что мы обязаны обнаружить это + *заранее* и упасть с сообщением, называющим требование к платформе, а не выпускать + сырой `PlatformNotSupportedException` из середины connect. +2. Сборка `System.Net.Quic` несет на уровне сборки + `[SupportedOSPlatform("windows")] [SupportedOSPlatform("linux")] + [SupportedOSPlatform("macos")]` (проверено по метаданным на всех трех TFM). + Анализатор совместимости платформ (CA1416) будет помечать каждое использование из + платформенно-нейтральной библиотеки. `[SupportedOSPlatformGuard]` на `IsSupported` + делает `if (QuicConnection.IsSupported) { … }` распознаваемой охраной, но анализатор + понимает охрану только в непосредственном потоке управления — кеширование результата + в поле его не заглушит. Практически: держать весь QUIC-код в выделенных типах, члены + которых аннотированы, либо ставить охрану в каждой точке входа. С учетом того, что + репозиторий собирается с **0 предупреждений на всех трех TFM**, это не + необязательная уборка. + +### 2.4 TLS, ALPN, сертификаты + +- QUIC обязывает использовать TLS 1.3 (RFC 9001). `QuicClientConnectionOptions.ClientAuthenticationOptions` + — это обычный `SslClientAuthenticationOptions`, поэтому `TargetHost` (SNI), + `RemoteCertificateValidationCallback`, `ClientCertificates`, + `CertificateRevocationCheckMode` и `CipherSuitesPolicy` работают как всегда. +- **ALPN обязателен.** `MsQuicConfiguration.Create` бросает `ArgumentException`, если + `ApplicationProtocols` равен null или пуст. Hysteria2 требует `new SslApplicationProtocol("h3")` + — `SslApplicationProtocol.Http3` это ровно он. Обратите внимание на контраст с + `VlessClient`/`TrojanClient`, где ALPN необязателен. +- **`EnabledSslProtocols` не пробрасывается** в том пути кода, который я изучил + (`MsQuicConfiguration.Create` его вообще не получает). Копирование значения по + умолчанию `SslProtocols = Tls12 | Tls13` из `VlessClient` в QUIC-клиент было бы тихо + бессмысленным, а не ошибкой. Не выставляйте это свойство на QUIC-клиенте — + игнорируемая ручка безопасности хуже, чем отсутствующая. +- `RemoteEndPoint` принимает `DnsEndPoint`, который резолвится до подключения ("May be a + `DnsEndPoint`, which will get resolved to an IP before connecting, or an + `IPEndPoint`"). Так что `ProxyHost`/`ProxyPort` отображаются напрямую и — в отличие от + `ProxyClient.CreateSocket()`, где жестко зашит `AddressFamily.InterNetwork`, — этот + путь не ограничен IPv4. +- `pinSHA256` (частый параметр `hysteria2://`) реализуем внутри + `RemoteCertificateValidationCallback`. +- `QuicConnection.RemoteCertificate` доступен после handshake на всех трех TFM. + +### 2.5 Мультитаргетинг: ограничение — `net8.0` + +Библиотека таргетит `net8.0;net9.0;net10.0`, и `System.Net.Quic` на самом старом из них +существенно другой. Diff снят с настоящих reference-сборок: + +**`net8.0` → `net9.0` добавляет:** + +| Член | Почему это важно | +| --- | --- | +| `QuicConnectionOptions.KeepAliveInterval` | Hysteria2 полагается на QUIC keepalive, чтобы держать простаивающий туннель открытым. **На `net8.0` не задается.** | +| `QuicConnectionOptions.HandshakeTimeout` | Ограничение времени proxy handshake. На `net8.0` не задается (внутреннее значение по умолчанию — 10 с). | +| `QuicConnectionOptions.InitialReceiveWindowSizes` + `QuicReceiveWindowSizes` | Hysteria тюнит ровно это (`InitialStreamReceiveWindow`, `InitialConnectionReceiveWindow`). На `net8.0` не настраивается; значения по умолчанию — 16 MB на соединение / 64 KB на stream. | +| `QuicConnectionOptions.StreamCapacityCallback` + `QuicStreamCapacityChangedArgs` | Back-pressure, когда исчерпан лимит стримов у пира. На `net8.0` недоступно. | + +**`net9.0` → `net10.0` добавляет:** `QuicConnection.NegotiatedCipherSuite`, +`QuicConnection.SslProtocol`. Только диагностика; ничего структурного. + +**Ни на одном шаге ничего не удалялось**, поэтому один файл исходника компилируется под +все три, если члены `net9.0`+ спрятаны за `#if NET9_0_OR_GREATER`. + +**И настоящая проблема:** на `net8.0` сборка `System.Net.Quic` несет +`[assembly: RequiresPreviewFeatures]` (проверено в таблице атрибутов уровня сборки +reference-сборки 8.0.27; в 9.0.16 и 10.0.10 отсутствует). Ее использование дает +**CA2252, по умолчанию — ошибку**. Поддерживаемое исправление — +`true`, но по дизайну preview-функций SDK +тогда сгенерирует `[assembly: RequiresPreviewFeatures]` на *нашей* сборке, что +распространит требование явного согласия на каждого потребителя NuGet-пакета. Это надо +подавлять через `false`, +ограниченный только TFM `net8.0`. Три жизнеспособные позиции, в порядке +предпочтительности: + +1. `EnablePreviewFeatures=true` + `GenerateRequiresPreviewFeaturesAttribute=false`, + только для `net8.0`. Держит публичный пакет чистым; принимаем, что поставляем код, + собранный против API, который Microsoft оставила за собой право менять в 8.0 (она + его не поменяла — diff `net8.0`→`net9.0` показывает только добавления, так что риск + оказался нулевым). +2. Поставлять QUIC-протоколы только на `net9.0`+, а в сборке под `net8.0` бросать + `PlatformNotSupportedException` из фабрики. Честно, некрасиво и создает публичную + поверхность, зависящую от TFM, — что противоречит правилу разработки "сохранять + совместимость мультитаргетинга". +3. Отказаться от `net8.0`. За рамками этого документа. + +### 2.6 Чего нет вообще + +Это отсутствия, проверенные по reference-сборкам, а не мнения. + +| Чего нет | Как проверено | Что блокирует | +| --- | --- | --- | +| **QUIC datagrams (RFC 9221)** | Нет типа `QuicDatagram*`, нет члена для отправки/приема datagram ни в `net8.0`, ни в `net9.0`, ни в `net10.0`, **ни в preview `net11.0`** | Hysteria2 UDP целиком; режим `native` UDP в TUIC; `Heartbeat` в TUIC. В upstream отслеживается как dotnet/runtime #53533 и #123418 — предложено, но не выпущено. | +| **TLS keying material exporter** | `ExportKeyingMaterial` не встречается нигде в reference pack .NET 10; у `SslStream` есть только `NegotiatedApplicationProtocol` / `NegotiatedCipherSuite` | **Аутентификацию TUIC, фатально.** У MsQuic есть `ConnectionExportKeyingMaterial`, но только под `QUIC_API_ENABLE_PREVIEW_FEATURES` и с пометкой "available from v2.6" — а .NET 10.0.10 поставляет msquic **2.4.18**, так что даже хак через приватную рефлексию и P/Invoke не найдет эту функцию в таблице API. | +| **Выбор congestion control** | У `QuicConnectionOptions` нет члена CC ни на одном TFM | Brutal в Hysteria2; `congestion_control=cubic\|new_reno\|bbr` в TUIC превращается в разобранный-и-проигнорированный параметр, а правило "никогда не понижать тихо" говорит, что мы обязаны его отклонить, а не игнорировать. | +| **Доступ к `QuicConnection` внутри HTTP/3 у `HttpClient`** | `SocketsHttpHandler` предоставляет только `ConnectCallback` (H1/H2) и `EnableMultipleHttp3Connections` | Переиспользование H3-стека из BCL для аутентификации Hysteria2. Придется писать свой минимальный H3. | +| **Управление Path MTU / path manager, тикеты 0-RTT resumption** | Соответствующих членов нет | Паритет с тюнингом клиентов на quic-go; практическое влияние низкое. | + +--- + +## 3. Центральный архитектурный конфликт + +Модель библиотеки такова: **`IProxyClient` — дешевый объект-значение без состояния; +каждый `ConnectAsync(host, port)` открывает собственный сокет, договаривается по нему и +отдает вызывающему `Stream`, которым тот владеет единолично.** `ProxyClient` хранит +только конфигурацию — ничего связанного с конкретным соединением не переживает вызов. +Два экземпляра `VlessClient` для одного сервера взаимозаменяемы и ничего не стоят. + +QUIC переворачивает каждое утверждение этого абзаца. Одно `QuicConnection` +мультиплексирует много стримов; дорогая часть (UDP-путь, TLS 1.3 handshake, проверка +сертификата, аутентификация протокола) выполняется **на соединение, а не на stream**; и +соединение обязано пережить каждый открытый на нем stream, потому что, когда оно +умирает, они умирают все разом. + +Вот что именно это ломает. + +### 3.1 Основная перегрузка `ConnectAsync` не виртуальна + +`QuickProxyNet/Clients/ProxyClient.cs`: + +- строка 105: `public async ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default)` — **без `virtual`**. Она безусловно вызывает `CreateSocket()`, `socket.ConnectAsync(...)`, заворачивает в `NetworkStream` и делегирует абстрактной перегрузке. +- строка 136: `public virtual async ValueTask ConnectAsync(string, int, TimeSpan, CancellationToken)` — виртуальная, но сама проделывает тот же танец с сокетом. +- строка 184: `public abstract ValueTask ConnectAsync(Stream source, string host, int port, CancellationToken)` — точка расширения, которую реализует каждый существующий клиент. + +Поэтому `Hysteria2Client : ProxyClient` **не может переопределить основную точку входа**. +Он мог бы скрыть ее через `new`, но фабрика раздает клиентов как `IProxyClient`, так что +каждый вызов через интерфейс шел бы по базовому TCP-пути и подключал `Socket` к +Hysteria-эндпоинту, работающему только по UDP. Либо `ConnectAsync(string, int, CancellationToken)` +становится `virtual` (совместимо на уровне исходников и бинарно, безопасно), либо +QUIC-клиенты вообще не наследуются от `ProxyClient`. + +### 3.2 `ConnectAsync(Stream source, …)` реализовать невозможно + +Абстрактный член существует потому, что вся конструкция предполагает: "дай мне байтовый +поток до прокси, и я договорюсь по нему". У QUIC такого шва нет: MsQuic владеет +собственным UDP-эндпоинтом, и нет API, позволяющего гонять QUIC-соединение поверх +переданного вызывающим дуплексного стрима. Единственная честная реализация в +QUIC-клиенте — `throw new NotSupportedException`. + +Отсюда побочные эффекты: + +- `Proxy.ConnectAsync(Uri proxyUri, Stream source, string host, int port, …)` + (`Proxy.cs:71`) — цепочка прокси **в** QUIC-прокси становится невозможной по + построению. (Цепочка **из** такого прокси в порядке: `QuicStream` — это `Stream`, так + что `Proxy.ConnectAsync(uri, quicStream, host, port)` работает, и это приятное + свойство, которое стоит задокументировать.) +- Член интерфейса, который всегда бросает исключение, — это дефект + подставляемости. Это корректное поведение с точки зрения правила "никогда не понижать + тихо", но означает, что `IProxyClient` перестал быть единообразным контрактом. + +### 3.3 В статическом быстром пути `Proxy` нет места для QUIC + +`ProxyConnector.ConnectToProxyAsync` (`ProxyConnector.cs:10`) устроен как +`Stream`-на-входе/`Stream`-на-выходе и диспетчеризуется по `proxyUri.Scheme` с +завершающим `throw new NotSupportedException($"Unsupported proxy scheme: {proxyUri.Scheme}")` +на строке 67. Добавить `hysteria2`/`tuic` некуда — проблема в сигнатуре функции. + +`Proxy.ConnectCoreAsync` (`Proxy.cs:78`) конструирует `Socket` прямо на месте, на строке +86. Doc-комментарий класса продает этот путь как *"No intermediate `IProxyClient` is allocated … +ideal for mass proxy checking."* Для QUIC такая подача ровно перевернута: без +промежуточного объекта соединение хранить негде, поэтому каждый вызов платил бы полный +handshake плюс аутентификацию. **Статические хелперы должны остаться только для +TCP-семейства, и это надо явно написать.** + +### 3.4 `ProxyClientFactory` возвращает то, что теперь нужно освобождать + +`ProxyType.Hysteria2` и `ProxyType.Tuic` уже есть в `ProxyType.cs`, но обе перегрузки +`Create` в `ProxyClientFactory.cs` проваливаются в +`throw new ArgumentOutOfRangeException(nameof(type), type, null)`. + +Заполнить это тривиально. Настоящая проблема в том, что **`IProxyClient` не расширяет ни +`IDisposable`, ни `IAsyncDisposable`**. Клиент, кеширующий `QuicConnection`, держит +нативный handle MsQuic, фоновый воркер и обязательство по keepalive. Ничто в текущем +контракте не говорит вызывающему, что это надо освободить, и ничто в документации +`ProxyClientFactory` не намекает, что у возвращенного объекта есть время жизни. Это +самое крупное последствие добавления QUIC для публичного API. + +### 3.5 Члены, устроенные под сокет, становятся бессмысленными или неверными + +`IProxyClient` выставляет `LingerState`, `NoDelay`, `LocalEndPoint`, `ReadTimeout`, +`WriteTimeout`. Применительно к QUIC: + +| Член | Судьба | +| --- | --- | +| `NoDelay` | Бессмыслен — Nagle это алгоритм TCP. | +| `LingerState` | Бессмыслен — закрытие QUIC это `CONNECTION_CLOSE` с кодом ошибки приложения (`QuicConnection.CloseAsync(long)` / `DefaultCloseErrorCode`). | +| `LocalEndPoint` | Выживает — отображается на `QuicClientConnectionOptions.LocalEndPoint`. Но он осмыслен только *в момент создания соединения*; выставление его позже на клиенте с живым закешированным соединением тихо ничего не делает. | +| `ReadTimeout` / `WriteTimeout` | Отображаются на `QuicStream.ReadTimeout`/`WriteTimeout` — но см. ниже. | + +Есть эффект второго порядка: настройки `ProxyClient` — это обычные изменяемые +автосвойства без синхронизации. Сегодня это безвредно, потому что каждый `ConnectAsync` +считывает их в новый сокет. Как только они начнут питать *закешированное* соединение, +изменение после подключения станет тихой пустой операцией — ровно та форма "тихого +понижения", о которой предупреждает AGENTS.md. + +### 3.6 Значения `ReadTimeout` / `WriteTimeout` по умолчанию бросают исключение + +`ProxyClient` инициализирует `WriteTimeout` и `ReadTimeout` нулем (строки 87–88) и +передает их в `Socket.SendTimeout`/`ReceiveTimeout`, где **0 означает бесконечность**. + +Сеттеры `QuicStream` (проверено в `QuicStream.Stream.cs`) выглядят так: + +```csharp +if (value <= 0 && value != Timeout.Infinite) + throw new ArgumentOutOfRangeException(nameof(value), SR.net_quic_timeout_use_gt_zero); +``` + +То есть наивная сквозная передача собственных значений библиотеки по умолчанию бросит +`ArgumentOutOfRangeException`. Отображение обязано быть `0 → Timeout.Infinite (-1)`. + +### 3.7 Механизм таймаута не переживает совместного использования + +`ProxyClient.ConnectAsync(host, port, timeout, ct)` реализует свой таймаут через +**освобождение `Socket` из колбэка таймера** и чтение `StrongBox`, чтобы решить, +какое исключение бросать (строки 144–182); `Proxy.ConnectCoreAsync` делает то же самое +(строки 92–140), с комментарием про освобождение таймера до передачи стрима наружу. + +С общим `QuicConnection` нет посокетного объекта на вызов, который можно уничтожить, а +уничтожение соединения оборвало бы **все остальные проксируемые стримы в полете**. +Таймаут должен превратиться в связанный `CancellationTokenSource`, передаваемый в +`QuicConnection.ConnectAsync` / `OpenOutboundStreamAsync`. Хуже того: если два +вызывающих в гонке запускают *один и тот же* ленивый handshake, таймаут одного не должен +отменять общую операцию для другого — стандартное решение состоит в том, чтобы +выполнять handshake отдельно, а каждый ожидающий ждал его под своим токеном. + +### 3.8 Отказы становятся коррелированными + +Сегодня падение одного возвращенного `Stream` ничего не говорит про другой. При QUIC +таймаут простоя, серверный `CONNECTION_CLOSE` или смена сети роняют **каждый** +незакрытый stream разом с `QuicException(ConnectionAborted / ConnectionIdle)`. +Вызывающие, построенные на текущей ментальной модели ("одно плохое соединение с прокси +⇒ один плохой `Stream`"), увидят коррелированные отказы, с которыми им раньше не +приходилось иметь дело. Это надо задокументировать, и это аргумент в пользу политики +повторного дозвона при смерти соединения в том компоненте, который им владеет. + +### 3.9 У `ProxyErrorCode` нет словаря для QUIC + +`ProxyErrorCode` устроен под SOCKS/HTTP. Отказы QUIC приходят как `QuicException` с +`QuicError` из `ConnectionRefused`, `ConnectionTimeout`, `ConnectionIdle`, +`TransportError`, `OperationAborted`, `StreamAborted`, `AlpnInUse`, +`VersionNegotiationError`, `CallbackError`, `InternalError`, плюс +`PlatformNotSupportedException` при отсутствующем MsQuic. Нужны новые члены-дополнения +(например, `QuicNotSupported`, `QuicHandshakeFailed`, `StreamAborted`, `ConnectionLost`) — +это изменение публичного enum, совместимое на уровне исходников, но его стоит сделать +один раз и осознанно. + +### 3.10 Ограничение на порядок, которого больше нигде в библиотеке нет + +Каждый текущий протокол — это "записать заголовок, может быть, прочитать ответ, готово" +на сокете, за который вызывающий только что заплатил. Hysteria2 навязывает +**двухфазный** порядок: обмен аутентификацией по HTTP/3 должен завершиться на соединении +*до того, как* будет открыт хоть один proxy stream, и происходит он один раз на N +последующих вызовов `ConnectAsync`. В `ProxyClient` некуда положить шаг, выполняемый +один раз на соединение. Это не деталь — это и есть причина, по которой правильный +примитив здесь объект сессии. + +--- + +## 4. Варианты дизайна жизненного цикла + +### Вариант A — соединение кешируется на эндпоинт внутри экземпляра клиента + +Клиент лениво создает и владеет одним `QuicConnection` (плюс завершенным состоянием +аутентификации), защищенным `SemaphoreSlim` или заменяемым полем `Task`, и +дозванивается заново, когда соединение умирает. `ConnectAsync` превращается в: +обеспечить соединение → открыть bidi stream → записать request-фрейм → вернуть stream. + +- **Плюсы.** Полностью сохраняет существующую форму вызова: `factory.Create(uri)`, затем + `ConnectAsync(host, port)`. Share-ссылки продолжают работать. Амортизирует handshake + + аутентификацию по вызовам, а в этом и весь смысл QUIC. Одно место, где реализуются + повторный дозвон, keepalive и back-pressure. +- **Минусы.** Клиент перестает быть объектом-значением: он владеет нативными ресурсами, + имеет состояние отказа и требует освобождения. Изменение `LocalEndPoint`/таймаутов + после первого подключения тихо перестает на что-либо влиять. Гонка первых вызывающих + требует аккуратного разделения handshake. Политика повторного дозвона — это + политическое решение, протекающее в тип "просто клиент". +- **Цена для API.** `IProxyClient` не получает новых членов, но конкретный клиент обязан + реализовать `IAsyncDisposable`, а контракт `ProxyClientFactory` должен документировать, + что возвращенный клиент может требовать освобождения (`if (client is IAsyncDisposable d) await d.DisposeAsync();`). + Вызывающие, которые это проигнорируют, утекут открытым QUIC-соединением, пока + соответствующий `SafeHandle` не будет финализирован — процесс держит живой туннель, о + котором забыл. + +### Вариант B — явно владеемый объект сессии + +Вызывающий создает и освобождает соединение явно: + +```csharp +await using var session = await Hysteria2Session.ConnectAsync(options, ct); +await using Stream s1 = await session.OpenAsync("example.com", 443, ct); +await using Stream s2 = await session.OpenAsync("other.com", 80, ct); +``` + +- **Плюсы.** Владение видно в системе типов — `await using` и есть весь контракт, и + ровно так устроен сам `System.Net.Quic`. Отказы handshake и аутентификации всплывают в + одной четко определенной точке, а не внутри произвольного `ConnectAsync`. Естественно + вмещает члены второй фазы, которым нет места в `IProxyClient` (будущий UDP associate, + задача `Connected`/`Closed`, согласованная полоса, флаг поддержки `Hysteria-UDP`). + Тестируем без фабрики. +- **Минусы.** Вторая форма API в библиотеке, где сейчас ровно одна. Не компонуется даром + с `ProxyClientFactory`/разбором share-ссылок. Вызывающим, которым действительно нужно + одноразовое поведение, придется написать больше кода. +- **Цена для API.** Чисто аддитивная — ничего существующего не меняется. + +### Вариант C — одно QUIC-соединение на каждый `ConnectAsync` + +Честная количественная оценка того, почему нет, потому что "расточительно" заслуживает +цифр: + +- **Задержка.** На живом соединении открытие стрима стоит **0 RTT** для TUIC и + **1 RTT** для Hysteria2 (ожидание `TCPResponse`). Свежее соединение стоит + QUIC/TLS 1.3 handshake (**1 RTT**, плюс DNS), затем, для Hysteria2, полный + HTTP/3-запрос/ответ аутентификации (**1 RTT**), который должен завершиться *до* того, + как будет открыт любой proxy stream, а затем ожидание `TCPResponse` (**1 RTT**) — + **итого 3 RTT**. На маршруте в 150 мс это ~**450 мс на `ConnectAsync`** против ~150 мс + при переиспользовании; для TUIC это ~150 мс против ~0 мс. Голый QUIC handshake (1 RTT) + действительно выигрывает у TCP+TLS 1.3 (2 RTT), но обязательный round-trip + аутентификации Hysteria2 стирает это преимущество и уходит в минус — дизайн + "на каждый вызов" *медленнее*, чем TCP-клиенты, которые библиотека уже поставляет. +- **CPU.** Каждый вызов выполняет полный TLS 1.3 handshake, включая построение цепочки + X.509 и проверку подписи, плюс настройку соединения/конфигурации MsQuic и привязку + UDP-сокета. Для нагрузки "массовая проверка прокси", которую рекламирует `Proxy`, это + ровно те затраты, ради устранения которых и существует пулинг. +- **Пропускная способность.** Каждое соединение заново стартует в slow start со свежим + congestion window. Смысл существования Hysteria2 — устойчивая отправка на высокой + скорости по путям с потерями — так никогда и не включается. Соединение на каждый вызов + делает протокол *хуже* обычного TCP-проксирования на коротких передачах. +- **Сторона сервера.** Каждое соединение заново прогоняет согласование полосы Hysteria2 + и считается отдельной сессией; серверы обычно ограничивают число одновременных + соединений на пользователя, а всплеск одноразовых соединений выглядит как + злоупотребление. + +Это не отдельный дизайн — это вариант A с размером пула в единицу и немедленным +вытеснением. Стоит поддержать как явный отказ от переиспользования ("не +переиспользовать"), но никогда как поведение по умолчанию. + +### Вариант D — статический пул на весь процесс с ключом эндпоинт + учетные данные + +Модель `SocketsHttpHandler`: статический кеш, вытеснение по простою, ограничение времени +жизни соединения. + +- **Плюсы.** `Proxy.ConnectAsync(uri, host, port)` — статический быстрый путь — мог бы + поддерживать QUIC без изменения сигнатуры. Амортизирует затраты между не связанными + между собой местами вызова. +- **Минусы.** Глобальное изменяемое состояние процесса с ключом по **учетным данным**, в + библиотеке, пользователи которой рутинно работают с тысячами сторонних прокси (см. + заметку про CorpusCheck в AGENTS.md о реальных IP, UUID и паролях). Неограниченный + рост без политики вытеснения; ограниченный кеш, тихо вытесняющий соединение под живым + стримом, еще хуже. Нет естественной точки завершения работы, поэтому тесты и + короткоживущие процессы утекают нативными handle и подвисают на выходе. Равенство + ключей кеша обязано учитывать настройки TLS и колбэки валидации, которые не + сравниваются по значению. +- **Цена для API.** Никакой видимой, и в этом-то и ловушка — неожиданное поведение не + видно в сигнатуре. + +**Отклонено.** Режим отказа (учетные данные в глобальном кеше процесса, нет точки +завершения) непропорционален удобству. + +### Вариант E — отдельный пакет `QuickProxyNet.Quic` + +`docs/tuic.md` предлагает именно это. Я не согласен, с одной оговоркой. + +Заявленное обоснование — сохранить "минимальное ядро только на BCL". Но +**`System.Net.Quic` входит в shared framework** — ссылка на него добавляет **ноль** +NuGet-зависимостей, и `QuickProxyNet.csproj` не нуждается в новом `PackageReference`. +То есть ядро остается BCL-only в любом случае, а разделение по этой оси не дает ничего, +но стоит второго пакета, расхождения версий между ними и раздробленного +`ProxyClientFactory` (фабрика в пакете A не может сконструировать клиента, который +существует только в пакете B, без механизма регистрации, которого сегодня нет). + +Оговорка, впрочем, реальна: разделение позволило бы локализовать проблему +preview-функций на `net8.0` в пакете, который пользователи подключают осознанно, и +сделало бы зависимость от *развертывания* libmsquic явной, а не скрытой. Если обходной +путь с preview-функциями из §2.5 окажется нерабочим, к этому стоит вернуться. + +### Рекомендация + +**Вариант B как примитив, с тонким адаптером варианта A поверх. Не D, не C, не E.** + +Конкретно: + +1. **`Hysteria2Session` (`IAsyncDisposable`)** — владеет `QuicConnection`, один раз + выполняет аутентификацию по HTTP/3, предоставляет `ValueTask OpenAsync(string host, int port, + CancellationToken)`, плюс `Task Closed` и согласованные значения `UdpEnabled` / `Tx`. + Это и есть настоящий API и то, что покрывается модульными тестами. +2. **`Hysteria2Client : IProxyClient, IAsyncDisposable`** — фасад, владеющий ровно одной + лениво созданной сессией, с повторным дозвоном при ее смерти, чтобы + `ProxyClientFactory.Create(uri)` и share-ссылки `hysteria2://` / `hy2://` продолжали + работать ровно так же, как для VLESS. `ConnectAsync(Stream, …)` бросает + `NotSupportedException` с сообщением, называющим причину (QUIC невозможно + согласовать поверх переданного стрима). +3. **Сделать `ProxyClient.ConnectAsync(string, int, CancellationToken)` `virtual`** — + безопасное неломающее изменение, которое снимает блокер §3.1 и полезно само по себе. +4. **Не добавлять QUIC в `Proxy.*` или `ProxyConnector`.** Задокументировать статические + хелперы как относящиеся только к TCP-семейству. Это стоит нам удобства "в один вызов" + для QUIC и является правильным разменом: API "в один вызов" для протокола, вся + экономика которого держится на переиспользовании соединения, — это ловушка + производительности, наряженная в удобство. +5. **Не расширять `IProxyClient` до `IAsyncDisposable`** — это сломает каждого внешнего + реализатора. Вместо этого реализовать его на конкретных QUIC-клиентах и + задокументировать паттерн `is IAsyncDisposable` на `ProxyClientFactory`. Это самое + слабое звено рекомендации, и я хочу быть честным: вызывающий, который никогда не + проверяет, утечет живым туннелем. Альтернатива (расширение интерфейса) — жесткое + ломающее изменение; если мажорная версия все равно на столе, расширение чище. +6. **Поставлять в основном пакете**, под охраной `QuicConnection.IsSupported`, с внятным + `ProxyProtocolException`, называющим требование к платформе, когда она равна false. + +И, неизбежно: **не реализовывать TUIC, пока BCL .NET не предоставит TLS keying +material exporter.** Разбор ссылок `tuic://` и их отклонение с поясняющим +`NotSupportedException` — легитимный и честный результат на это время; угаданный токен — +нет. + +--- + +## 5. Трудозатраты и риски + +Идеальные инженеро-дни для того, кто уже свободно ориентируется в этой кодовой базе, при +условии процесса "последовательные субагенты + состязательное ревью + независимая +эталонная реализация", который предписывает AGENTS.md. Считать эти числа с точностью ±50%. + +### Hysteria2 (только TCP) + +| Часть | Дни | Примечания | +| --- | --- | --- | +| Разбор `hysteria2://` / `hy2://`, `Hysteria2Options`, проводка фабрики + `ProxyType` | 1–2 | Зеркалит `VlessShareLink`. `hy2` — это алиас, а не протокол (`docs/hy2.md`). Самая дешевая и самая предсказуемая часть. | +| Настройка QUIC-соединения, охрана `IsSupported`, аннотации CA1416, условия по TFM, конфигурация preview-функций для `net8.0` | 2–3 | В основном конфигурация и сборочная обвязка; проверить **0 предупреждений на всех трех TFM**, прежде чем в это поверить. | +| **Минимальный HTTP/3-клиент для аутентификации** | **4–7** | Control stream + `SETTINGS`, кодирование по статической таблице/literal QPACK, `HEADERS`-фрейм, разбор статуса ответа. Динамическая таблица не нужна. Позиция с наибольшим разбросом; вероятнее всего именно она удвоится. | +| Объект сессии, жизненный цикл, повторный дозвон, разделение handshake, переработка отмены/таймаутов | 3–5 | Семантика отмены общего handshake из §3.7 тонкая и требует собственных тестов. | +| Framing `TCPRequest`/`TCPResponse`, padding, stream-wrapper, отображение `QuicException` → `ProxyErrorCode` | 2–3 | Прямолинейное кодирование varint + адреса. Переиспользовать соглашения `ProxyAddress`, но учесть, что формат — **строка** `"host:port"`, а не atyp-кодирование из AGENTS.md §4. | +| Адаптер `IProxyClient`, семантика освобождения, XML-документация | 1–2 | | +| Интеграционный тест в Docker (образ сервера, **публикация UDP-порта**, проводка сертификатов, пропуск при неподдерживаемой платформе) | 2–3 | См. ниже. | +| Модульные тесты + побайтово точные векторы фреймов из независимой реализации | 2–3 | | +| **Итого (только TCP-путь)** | **17–28** | | +| UDP relay | **заблокировано** | Нужны QUIC datagrams в BCL. Это не оценка. | +| Brutal congestion control | **заблокировано** | В `System.Net.Quic` нет точки подключения CC. Это не оценка. | +| Обфускация Salamander / Gecko | **заблокировано** | Обфускация оборачивает сырой UDP-payload под QUIC. Сокетом владеет MsQuic; хука на преобразование пакетов нет. | + +### TUIC + +| Часть | Дни | Примечания | +| --- | --- | --- | +| Разбор `tuic://` + опции | 1 | Можно сделать уже сегодня. | +| Команды/framing (`Connect`, `Packet`, `Dissociate`) | 2–3 | Просто и хорошо специфицировано; самый легкий wire-формат из всех протоколов в этом репозитории. | +| Сессия/жизненный цикл | 2–3 | Общее с Hysteria2, если правильно вынести. | +| **Токен `Authenticate`** | **заблокировано** | TLS keying material exporter. См. §2.6. | +| **Итого** | **н/д** | Без аутентификации все остальное — мертвый груз. | + +### Тестирование и docker + +`tests/docker/docker-compose.yml` сегодня только TCP: каждый опубликованный порт имеет +вид `"248xx:100xx"`, то есть TCP. QUIC требует явной публикации UDP (`"24820:10010/udp"`), +в чем легко ошибиться и получить тихий таймаут подключения вместо ошибки. +Помимо этого: сервер Hysteria2 (у `sing-box` есть inbound `hysteria2`; альтернатива — +upstream-образ `apernet/hysteria`; я не проверял ни тег образа, ни схему конфигурации ни +у одного из них), переиспользование `tests/docker/certs` и пропуск теста, срабатывающий +на `!QuicConnection.IsSupported` в дополнение к существующему гейту `QPN_DOCKER_TESTS=1`, +— через `Assert.Skip.When` из xUnit, как требует AGENTS.md, и никогда через ранний +`return`. Острый угол здесь — CI-раннеры: Linux-раннер без `libmsquic` пропустит все и +отчитается зеленым, а это ровно тот режим отказа "тестовый набор врет о том, что он +доказывает", о котором говорит AGENTS.md. Сообщение о пропуске обязано называть +отсутствующую зависимость. + +### Риски, худшие сначала + +1. **TUIC заблокирован, и, насколько это в нашей власти, навсегда.** Разблокировка + требует нового API в BCL *и* msquic ≥2.6 с включенными preview-функциями в сборке, + поставляемой с .NET. Не начинать работу над TUIC. +2. **"Поддержка Hysteria2" была бы частичным заявлением.** Только TCP, congestion + control от MsQuic, без обфускации. С учетом правила AGENTS.md против тихих понижений + парсер опций обязан **отклонять** `obfs=`, а также отклонять или громко + документировать конфигурацию полосы, которую он не может соблюсти. Поставка клиента, + который принимает `obfs=salamander` и игнорирует его, была бы багом того же класса, + что и понижение до plaintext, уже пойманное на ревью. +3. **Минимальный HTTP/3-клиент — риск для сроков.** Это единственная часть, у которой + нет аналога нигде в этом репозитории, а у framing QPACK/H3 много мелких способов + оказаться незаметно неверным против настоящего сервера. +4. **Утечка preview-функций на `net8.0`.** Если забыть + `GenerateRequiresPreviewFeaturesAttribute=false`, опубликованный пакет молча + потребует от каждого потребителя включить preview-функции на `net8.0`. Это регрессия + уровня пакета, которую модульные тесты не поймают, — нужен smoke-тест на + потребление пакета. +5. **Матрица платформ.** Пользователи Windows 10 не получают ничего. Пользователям Linux + нужен системный пакет. Пользователям macOS нужен Homebrew плюс переменная окружения. + Для библиотеки, которая сейчас ставится и работает везде, это изменение в объеме + поддержки не меньше, чем техническое, и его место в README, а не только здесь. +6. **Утечки жизненного цикла в реальной эксплуатации.** По пункту 5 рекомендации из §4 + вызывающий, игнорирующий `IAsyncDisposable`, утекает живым туннелем. Смягчать + документацией, формой API, не требующей подавления анализаторов, и — если когда-нибудь + будет выпущена мажорная версия — расширением `IProxyClient`. +7. **Коррелированные отказы** (§3.8) будут выглядеть как плавающие баги для тех, кому + никогда не приходилось думать о стримах с общей судьбой. + +--- + +## 6. Что было проверено, а что нет + +### Проверено напрямую + +Reference-сборки и метаданные на этой машине: + +- `Microsoft.NETCore.App.Ref` **8.0.27** (`~/.nuget/packages`), **9.0.16** и + **10.0.10** (`C:\Program Files\dotnet\packs`), плюс пакет **11.0.0-preview.5**. +- Полный diff публичных членов `System.Net.Quic.dll` между `net8.0`/`net9.0`/`net10.0`: + net9 добавляет `HandshakeTimeout`, `KeepAliveInterval`, `InitialReceiveWindowSizes`, + `StreamCapacityCallback`, `QuicReceiveWindowSizes`, `QuicStreamCapacityChangedArgs`; + net10 добавляет только `QuicConnection.NegotiatedCipherSuite` и `QuicConnection.SslProtocol`; + **ничего не удалено** ни на одном шаге. +- Пользовательские атрибуты уровня сборки: `[RequiresPreviewFeatures]` присутствует на + `net8.0`, отсутствует на `net9.0`/`net10.0`; `[SupportedOSPlatform]` для + windows/linux/macos на всех трех. +- Отсутствие какого-либо datagram API во всех четырех пакетах, включая preview `net11.0`. +- Отсутствие `ExportKeyingMaterial` где-либо в reference pack `net10.0`. +- `msquic.dll` присутствует в `shared/Microsoft.NETCore.App/{9.0.16,10.0.10}`, версия + файла **2.4.18**. +- Публичная поверхность `SocketsHttpHandler` — доступа к HTTP/3-соединению нет. + +Исходники `dotnet/runtime` (ветка main): + +- `QuicStream.Stream.cs`: `CanTimeout => true`; сеттеры `ReadTimeout`/`WriteTimeout` + бросают исключение при `value <= 0 && value != Timeout.Infinite`. +- `QuicConnection.cs`: `if (!IsSupported) throw new PlatformNotSupportedException(...)`; + `[SupportedOSPlatformGuard]` на `IsSupported`. +- `MsQuicConfiguration.cs`: null/пустой `ApplicationProtocols` ⇒ `ArgumentException`; + `EnabledSslProtocols` в этом пути кода не пробрасывается. +- `QuicDefaults.cs`: таймаут handshake 10 с, `initial_max_data` 16 MB, + `initial_max_stream_data_*` 64 KB, максимум входящих стримов у клиента по умолчанию **0**. + +Источники по протоколам: + +- `apernet/hysteria`: `PROTOCOL.md`, `core/internal/protocol/http.go`, + `core/client/client.go` (аутентификация через `http3.Transport`, `StatusAuthOK = 233`, + `congestion.UseBrutal` / `UseConfigured`, `EnableDatagrams: true`). +- `tuic-protocol/tuic`: `SPEC.md` (v `0x05`, TOKEN, выведенный экспортером, типы стримов + по командам). +- `microsoft/msquic`: `src/inc/msquic.h`: `QUIC_KEYING_MATERIAL_CONFIG` и + `ConnectionExportKeyingMaterial` находятся внутри `#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES` + и помечены "Available from v2.6". +- Microsoft Learn: платформенные зависимости QUIC; `QuicClientConnectionOptions.RemoteEndPoint`, + принимающий `DnsEndPoint`; дизайн-документ по preview-функциям (`EnablePreviewFeatures`, + `GenerateRequiresPreviewFeaturesAttribute`, уровень серьезности по умолчанию `error`) и CA2252. + +### Не проверено — считать открытыми вопросами + +- **Собирается ли `msquic.dll`, поставляемый с .NET, с `QUIC_API_ENABLE_PREVIEW_FEATURES`.** + На 2.4.18 это не имеет значения (функция появилась позже), но станет важным, если + будущий рантайм начнет поставлять ≥2.6. +- **Бывает ли `libmsquic` на Linux/macOS версии, в которой экспортер существует**, и + может ли рефлексия по внутреннему `SafeHandle` у `QuicConnection` до него добраться. Я + счел это выходящим за рамки поддерживаемой реализации и не пробовал. +- **Точная строка ALPN у Hysteria2 в том виде, в каком она проверяется на сервере.** Это + `h3`, потому что так выставляет `http3.Transport` из quic-go и потому что на это + указывает дизайн masquerade; явной константы я не нашел и против живого сервера не + проверял. +- **Точное поведение `EnabledSslProtocols` на QUIC-соединении.** Я подтвердил, что он не + пробрасывается в `MsQuicConfiguration.Create`; я не прогрепал исчерпывающе все дерево + `System.Net.Quic`, поэтому "игнорируется" — сильный вывод, а не доказательство. +- **Отправляет ли неосвобожденный `QuicConnection` `CONNECTION_CLOSE` при финализации.** + Нативный handle — это `SafeHandle`, и он будет освобожден рано или поздно; увидит ли + пир чистое закрытие, неясно. Утверждение об утечке в §4/§5 сформулировано + консервативно. +- **Теги образов sing-box / apernet и схема конфигурации для inbound `hysteria2`.** Не + проверялось на `tests/docker/`. +- **Все числа по трудозатратам.** Суждение, а не измерение. + +--- + +## Источники + +- Hysteria2 protocol: and + +- Hysteria client implementation: `core/client/client.go`, + `core/internal/protocol/http.go` in +- TUIC spec: +- sing-box TUIC outbound: +- QUIC in .NET: +- `QuicStream`: +- `QuicClientConnectionOptions.RemoteEndPoint`: +- CA2252: +- Preview features design: +- QUIC datagram API proposals: , + +- MsQuic headers: +- RFC 9000 (QUIC), RFC 9001 (QUIC-TLS), RFC 9221 (datagrams), RFC 9114 (HTTP/3) diff --git a/docs/reality-fingerprint-plan.md b/docs/reality-fingerprint-plan.md new file mode 100644 index 0000000..9f941f7 --- /dev/null +++ b/docs/reality-fingerprint-plan.md @@ -0,0 +1,204 @@ +# Making the managed REALITY ClientHello look like Chrome + +`QuickProxyNet/Internal/Reality/TlsClientHello.cs` currently emits a valid TLS 1.3 hello that a +real REALITY server accepts. It is not a browser fingerprint, and until it is, the managed client +is a protocol implementation rather than a censorship-resistance tool — a hello that merely +*works* puts its user in a smaller and stranger bucket than one that fails. + +This is what closing that gap requires. Everything below was read out of the reference sources +rather than inferred; where a claim could not be verified it says so. + +## The target is uTLS, not Chrome + +Xray maps `fp=chrome` to `utls.HelloChrome_Auto` (`Xray-core/transport/internet/tls/tls.go`), +and uTLS defines `HelloChrome_Auto = HelloChrome_133` (`u_common.go`). So the population a +REALITY user blends into is *uTLS's model of Chrome*, not a live browser. Reproduce uTLS; when +Chrome drifts, follow uTLS rather than getting ahead of it. + +## What Chrome 133 sends + +**Header.** `legacy_version 0x0303`, 32-byte random, **32-byte** `legacy_session_id` — which is +exactly what REALITY needs, so the sealed blob and the fingerprint do not conflict — and +`legacy_compression_methods = 01 00`. + +**Cipher suites,** 16 entries, order fixed and never permuted: + +``` +GREASE, 1301, 1302, 1303, c02b, c02f, c02c, c030, +cca9, cca8, c013, c014, 009c, 009d, 002f, 0035 +``` + +The TLS 1.2 suites are not optional set dressing: a three-suite list is a tell on its own. + +**Extensions,** 18 slots (16 real plus two GREASE), canonical order before permutation: + +| # | id | extension | body | +| --- | --- | --- | --- | +| 1 | GREASE₁ | filler | empty — always first | +| 2 | `0000` | server_name | `00 00 ` | +| 3 | `0017` | extended_master_secret | empty | +| 4 | `ff01` | renegotiation_info | `00` | +| 5 | `000a` | supported_groups | `GREASE, 11ec, 001d, 0017, 0018` | +| 6 | `000b` | ec_point_formats | `01 00` | +| 7 | `0023` | session_ticket | empty | +| 8 | `0010` | ALPN | `02 "h2" 08 "http/1.1"` | +| 9 | `0005` | status_request | `01 0000 0000` | +| 10 | `000d` | signature_algorithms | `0403,0804,0401,0503,0805,0501,0806,0601` | +| 11 | `0012` | signed_certificate_timestamp | empty | +| 12 | `0033` | key_share | see below | +| 13 | `002d` | psk_key_exchange_modes | `01 01` | +| 14 | `002b` | supported_versions | `GREASE, 0304, 0303` | +| 15 | `001b` | compress_certificate | `02 0002` (brotli) | +| 16 | `44cd` | application_settings | `0003 02 "h2"` — note 133 uses `44cd`, ≤131 used `4469` | +| 17 | `fe0d` | encrypted_client_hello | GREASE ECH, below | +| 18 | GREASE₂ | filler | `00` — always last | + +**No padding extension.** BoringSSL dropped the pad-to-512 rule, and a hello carrying an ML-KEM +key share is ~1.7 KB anyway — far outside the window that rule ever applied to. + +**`signature_algorithms` contains no ed25519.** Verified here by experiment, not just by reading: +removing `0x0807` leaves the real-server handshake and tunnel tests green. An earlier comment in +this repo claimed it was required because a REALITY server answers with an Ed25519 certificate — +it is not, because the server generates that certificate only after authenticating the client and +never consults the extension. The list above is already what the code sends. + +## GREASE + +Values are `0x0a0a, 0x1a1a, … 0xfafa` — one byte of randomness per slot, `(b & 0xf0) | 0x0a` +doubled into both bytes. Six independent draws per connection, with two rules that matter: + +- The two extension-slot values **must differ**; BoringSSL fixes a collision with `^= 0x1010`. +- The group value in `supported_groups[0]` and the one in `key_share[0]` are **the same draw**. + Drawing them independently is directly detectable. + +`key_share`'s GREASE entry is ` 0001 00`. + +## GREASE ECH (`fe0d`) + +Pure randomness, no crypto dependency, and about 250 of the missing bytes: + +``` +1B 0x00 outer ClientHello +2B kdf_id = 0x0001 HKDF-SHA256 +2B aead_id = 0x0001 AES-128-GCM (uTLS always; Chrome picks 0x0003 without AES-NI) +1B config_id random +2B 0x0020 +32B enc public half of a fresh, discarded X25519 keypair +2B payload length +NB payload random; length is exactly one of 144, 176, 208, 240 +``` + +REALITY servers have no ECH keys configured, so the extension is ignored — verified in +`handshake_server_tls13.go`, where `retry_configs` are only built when ECH keys exist. + +## Extension permutation, and why JA3 is the wrong metric + +BoringSSL builds a Fisher–Yates permutation of every extension index once per handshake. GREASE₁ +is emitted before the loop and GREASE₂ after it, so those two stay pinned; **everything else +moves, including `server_name`, `key_share` and `supported_versions`**. There is no "SNI first" +convention any more. + +So JA3 — which hashes the extension list in order — changes almost every connection and is +useless as an acceptance criterion. JA4 strips GREASE and sorts the cipher and extension lists +before hashing; signature algorithms are appended **unsorted**, and `0000`/`0010` are removed from +the sorted list because they are already encoded elsewhere in the fingerprint. JA4 is the metric +to test against. + +## X25519MLKEM768 — the hard part + +Group `0x11EC`. The client's key share is **1216 bytes, ML-KEM first**: + +``` +1184B ML-KEM-768 encapsulation key + 32B X25519 public key +``` + +The server replies with 1120 bytes (`ML-KEM ciphertext 1088 || X25519 public 32`), and the shared +secret fed to the key schedule is **64 bytes, ML-KEM part first**: +`MLKEM768.Decap(ct) || X25519(sk, pk)`. + +It cannot be faked. REALITY's server sorts post-quantum groups first among those the client +advertises, so offering `0x11ec` guarantees it is selected and a real decapsulation is required. + +**Availability is the problem.** `System.Security.Cryptography.MLKem` arrives in .NET 10 but is +OS-gated — it needs Windows 11 with the PQC CNG updates or OpenSSL 3.5+, and throws +`PlatformNotSupportedException` otherwise. A client that produces a Chrome fingerprint on a +patched Windows 11 and a different one on Debian 12 is **worse** than one that is consistently +wrong, because the fingerprint then leaks the host OS. The same gating applies to `Shake128`/ +`Shake256`, which ML-KEM needs. + +That argues for one managed ML-KEM-768 used on every target framework, validated against the NIST +ACVP vectors — in character for a repo that already hand-rolls X25519 and the TLS 1.3 key +schedule. `MLKem` may be used where supported only after asserting byte-identical output against +the managed path for the same seed. + +**Until ML-KEM exists, omit `0x11ec` from both `supported_groups` and `key_share`.** JA4 is +unchanged by that (it hashes extension ids, not group lists), and the hello simply drops to +~500 bytes — which is itself a tell, since no current Chrome sends a hello that small, so it must +be documented as degraded. The tempting alternative — offering the hybrid key share while leaving +the group out of `supported_groups` to force a fallback — violates RFC 8446 §4.2.8 and is a +one-line check for any DPI box. It is worse, not better. + +## What this means for REALITY's sealing + +REALITY's server looks for a standalone X25519 share **first**, and only falls back to the X25519 +tail of a hybrid entry when there is none (`XTLS/REALITY/tls.go`). Chrome sends both. So: + +- `RealityAuth.DeriveAuthKey` keeps using the private key of the standalone `001d` share. No + change needed there. +- The two X25519 keypairs must be **independent**. Chrome's are, and reusing one would be a + trivial byte-equality check for an observer. +- The TLS handshake itself then runs on X25519MLKEM768 while `authKey` comes from the other + keypair. Keeping those two separate is the obvious place to introduce a bug. +- `SessionIdOffset = 39` and the AAD remain correct — the header layout does not change. + +Two client-side changes come with the hybrid group: `ParseServerHello` currently accepts only +`0x001D` with a 32-byte share and must also accept `0x11EC` with 1120; and the +HelloRetryRequest error text stops being accurate once three groups are offered. + +## Order of work + +Build the instrument first. Without a JA4 implementation, "does our hello match" is an opinion. + +1. **`Ja4.Compute` as a test helper**, emitting `JA4_r` as well — the raw string is what names the + list that diverged when a test fails. +2. **A reference corpus.** The strongest oracle is uTLS itself: ~20 lines of Go calling + `utls.UClient(..., HelloChrome_133)` and dumping `HandshakeState.Hello.Raw`, a few hundred + times, checked in as hex so CI needs no Go toolchain. A live Chrome capture on a loopback + listener is the second oracle, and tells you whether Chrome has drifted past 133. +3. **Restructure the builder** around a GREASE seed and an ordered list of extension writers, then + permute indices `1 .. n-2`. +4. **The cheap extensions** — rows 2–11 and 13–16, exact bodies. Key share still bare X25519. +5. **GREASE ECH.** ~30 lines, no crypto dependency. +6. **Permutation.** After this the JA4 is already correct; only the wire length and the group list + are still wrong. +7. **ML-KEM-768.** Keygen and decapsulation, ACVP vectors, then the hybrid key share, the 64-byte + secret, and the `ParseServerHello` change. + +Steps 3–6 are roughly 400 lines and need no new dependency. + +## The honest acceptance criterion + +> The hello is *structurally indistinguishable* from uTLS `HelloChrome_133`: identical JA4 and +> JA4_r, identical extension set and cipher list, byte-identical extension bodies outside an +> enumerated set of per-connection random fields, and a matching length distribution. + +Three things it does not claim, and which belong in the type's documentation: + +- **Not byte-identical.** Chrome randomises its hello by design; byte-identity is not meaningful. +- **Not "matches Chrome today".** It matches uTLS's model of Chrome 133, which is the right target + precisely because that is what the rest of the ecosystem sends. +- **Not indistinguishable end-to-end.** The hello is one layer. What follows is VLESS, not a + browser: no Chrome-shaped HTTP/2 SETTINGS, no browser request timing, and an ALPN of `h2` + describing nothing the tunnel actually does. Fixing the hello removes the cheapest + discriminator. It does not make the flow look like a browser. + +## Sources + +- uTLS `u_parrots.go`, `u_common.go`, `u_tls_extensions.go`, `u_ech.go`, `common.go` +- BoringSSL `ssl/extensions.cc`, `ssl/handshake.cc`, `ssl/ssl_key_share.cc`, + `ssl/encrypted_client_hello.cc` +- XTLS/REALITY `tls.go`, `handshake_server_tls13.go` +- Xray-core `transport/internet/tls/tls.go` (the `fp` name table) +- FoxIO JA4 specification, `technical_details/JA4.md` +- RFC 8701 (GREASE), RFC 8446 §4.2.8, draft-kwiatkowski-tls-ecdhe-mlkem-02 §3.1.2–3.1.3 diff --git a/docs/trojan.md b/docs/trojan.md new file mode 100644 index 0000000..d7773fd --- /dev/null +++ b/docs/trojan.md @@ -0,0 +1,160 @@ +# Trojan + +Trojan - proxy-протокол, который маскируется под обычный TLS-сервис. Для +QuickProxyNet он очень удобен: после TLS handshake клиент отправляет короткий +Trojan request, читает/не читает дополнительный ответ в зависимости от режима, +и дальше поток становится обычным TCP stream к target. + +## Стек + +Базовый поток: + +```text +TCP connect -> TLS -> Trojan request -> target TCP stream +``` + +В отличие от VLESS `security=none`, Trojan по спецификации ожидает TLS. Сам +Trojan header идет внутри TLS. + +## URI + +Распространенная форма: + +```text +trojan://@:?sni=example.com#name +trojan://@:?security=tls&type=tcp&sni=example.com#name +``` + +Параметры: + +| Параметр | Значение | +| --- | --- | +| userinfo/password | пароль пользователя | +| `sni` | SNI для TLS | +| `allowInsecure` | клиентская политика проверки сертификата | +| `type` / `network` | `tcp`, `ws`, `grpc` в разных клиентах | +| `alpn` | TLS ALPN | +| `path`, `host`, `serviceName` | transport-specific параметры | + +## Wire format + +Официальная схема: + +```text ++-----------------------+---------+----------------+---------+----------+ +| hex(SHA224(password)) | CRLF | Trojan request | CRLF | payload | ++-----------------------+---------+----------------+---------+----------+ +| 56 bytes | 2 bytes | variable | 2 bytes | variable | ++-----------------------+---------+----------------+---------+----------+ +``` + +Первый блок - ASCII hex от SHA-224 пароля: + +```text +56 ASCII bytes: [0-9a-f] +0D 0A +``` + +В .NET важный нюанс: BCL не дает готовый `SHA224`. Для zero-dependency +реализации придется добавить внутренний SHA-224 или принимать заранее +посчитанный 56-символьный hash как advanced option. + +## Trojan request + +Request похож на SOCKS5 request: + +```text ++-----+------+----------+----------+ +| CMD | ATYP | DST.ADDR | DST.PORT | ++-----+------+----------+----------+ +| 1 | 1 | variable | 2 | ++-----+------+----------+----------+ +``` + +Команды: + +```text +01 = CONNECT (TCP) +03 = UDP ASSOCIATE +``` + +Address types совпадают с SOCKS5: + +```text +01 = IPv4, 4 bytes +03 = domain, 1 byte length + domain bytes +04 = IPv6, 16 bytes +``` + +Port идет после address, big-endian. + +Для Minecraft `mc.example.com:25565`: + +```text +<56 ascii hex chars> +0D 0A +01 CONNECT +03 domain +0E length +6D 63 2E 65 78 61 6D 70 6C 65 2E 63 6F 6D +63 DD port +0D 0A + +``` + +## Response + +У Trojan нет HTTP-like `200 OK`. Если authentication или connect fail, сервер +обычно закрывает соединение или ведет fallback/masquerade как обычный TLS +сервер. Поэтому для client API ошибка часто проявится как EOF/reset на первом +read/write после request. + +У Xray fallback-механика описана через эвристику первого пакета: fallback может +сработать, если пакет слишком короткий для Trojan auth, если байт после +56-байтового hash не равен `\r`, или если authentication не прошла. Это не +часть минимальной wire-спеки `trojan-gfw`, но важно для совместимости с +реальными серверами. + +## Transport modes + +| Режим | Оценка для QuickProxyNet | +| --- | --- | +| TCP + TLS | Хороший первый кандидат | +| WebSocket + TLS | Нужен WS stream adapter | +| gRPC/H2 | Нужен HTTP/2/gRPC adapter | +| UDP associate | Не `Stream`; нужна datagram модель | + +В Xray/V2Fly/sing-box transport (`raw`, WebSocket, gRPC, HTTP upgrade, XHTTP и +т.п.) является нижним способом доставки байтов до Trojan-слоя. Эти режимы +лучше документировать как implementation-specific расширения поверх базового +`TLS + Trojan request`. + +## Stream-модель + +Для TCP CONNECT: + +```text +NetworkStream -> SslStream -> write Trojan request -> return SslStream +``` + +После request `SslStream` можно вернуть пользователю как прозрачный TCP stream. +Это ближе к HTTP CONNECT, чем VMess. + +## Заметки для реализации + +- Добавить `TrojanClient`. +- Требовать TLS по умолчанию. +- Поддержать `sni` и certificate validation callbacks аналогично + `HttpsProxyClient`. +- Реализовать SHA-224 или отдельный режим `trojan+hash`. +- Для domain address использовать SOCKS5-compatible address writer. +- Ошибки authentication/connect могут быть диагностированы только косвенно, + если сервер не присылает явный ответ. +- UDP поддержка требует отдельного решения: V2Fly, например, различает + stream-like `None` и packet-mode `Packet` encoding. + +## Источники + +- Trojan protocol: https://trojan-gfw.github.io/trojan/protocol.html +- V2Fly Trojan config: https://www.v2fly.org/en_US/v5/config/proxy/trojan.html +- sing-box Trojan outbound: https://sing-box.sagernet.org/configuration/outbound/trojan/ diff --git a/docs/tuic.md b/docs/tuic.md new file mode 100644 index 0000000..35d1b1c --- /dev/null +++ b/docs/tuic.md @@ -0,0 +1,223 @@ +# TUIC + +TUIC - proxy-протокол поверх QUIC. Он ближе к Hysteria2 по транспортной модели: +нижний слой UDP+QUIC+TLS 1.3, а пользовательские TCP соединения отображаются +на QUIC streams. + +Актуальная upstream-спецификация описывает protocol version `0x05`. + +## Стек + +```text +UDP socket -> QUIC + TLS 1.3 -> TUIC commands -> QUIC stream -> target TCP stream +``` + +Для QuickProxyNet это не "написать CONNECT header в TCP socket". Нужен QUIC +client и stream-wrapper. + +## URI + +Распространенная форма: + +```text +tuic://:@:?sni=example.com&congestion_control=bbr#name +``` + +Частые параметры: + +| Параметр | Значение | +| --- | --- | +| `uuid` | user UUID | +| `password` | raw password | +| `sni` | TLS SNI | +| `congestion_control` | `cubic`, `new_reno`, `bbr` | +| `udp_relay_mode` | режим UDP relay | +| `alpn` | QUIC/TLS ALPN | +| `allowInsecure` | политика сертификата | +| `disable_sni` | отключение SNI в некоторых клиентах | + +## Authentication + +TUIC specification описывает команду Authenticate: + +```text ++------+-------+ +| UUID | TOKEN | ++------+-------+ +| 16 | 32 | ++------+-------+ +``` + +`TOKEN` - 256-bit token, полученный через TLS Keying Material Exporter текущей +TLS-сессии. Label - UUID клиента, context - raw password. + +Это важное отличие от простых password header-ов: токен связан с текущей QUIC +TLS-сессией. + +## Byte-level команды + +Все числовые поля в specification идут big-endian, если не сказано иначе. + +Общая форма команды: + +```text ++-----+------+----------+ +| VER | TYPE | OPT | ++-----+------+----------+ +| 1 | 1 | variable | ++-----+------+----------+ +``` + +`VER` для актуальной версии: `0x05`. + +Типы команд: + +```text +00 = Authenticate +01 = Connect +02 = Packet +03 = Dissociate +04 = Heartbeat +``` + +`Authenticate`: + +```text ++------+-------+ +| UUID | TOKEN | ++------+-------+ +| 16 | 32 | ++------+-------+ +``` + +`Connect` содержит целевой адрес: + +```text ++------+ +| ADDR | ++------+ +``` + +`Packet` для UDP relay: + +```text ++----------+--------+------------+---------+------+--------+---------+ +| ASSOC_ID | PKT_ID | FRAG_TOTAL | FRAG_ID | SIZE | ADDR | PAYLOAD | ++----------+--------+------------+---------+------+--------+---------+ +| 2 | 2 | 1 | 1 | 2 | var | var | ++----------+--------+------------+---------+------+--------+---------+ +``` + +`Dissociate`: + +```text ++----------+ +| ASSOC_ID | ++----------+ +| 2 | ++----------+ +``` + +`Heartbeat` не несет полезной нагрузки. + +Адрес: + +```text ++------+----------+------+ +| TYPE | ADDR | PORT | ++------+----------+------+ +| 1 | variable | 2 | ++------+----------+------+ +``` + +Типы адреса: + +```text +FF = None +00 = FQDN +01 = IPv4 +02 = IPv6 +``` + +`None` используется в UDP packet flow, например не для первого фрагмента. + +## Protocol flow + +Типовой flow: + +1. Клиент устанавливает QUIC connection. +2. Клиент открывает stream/control path и выполняет Authenticate. +3. Для TCP target клиент открывает QUIC bidirectional stream. +4. Клиент отправляет command/request с target address. +5. Дальше байты target TCP идут внутри этого QUIC stream. + +TUIC также поддерживает UDP relay и 0-RTT-related оптимизации в реализациях. + +Для `Connect` specification не задает отдельный success response: клиент +открывает bidirectional QUIC stream, отправляет command и может сразу писать +payload. Ошибки обычно выражаются закрытием/reset QUIC stream или connection, +а не отдельным стандартизированным response frame. + +## Шифрование + +TUIC всегда использует QUIC, а QUIC включает TLS 1.3. Практического режима +"без шифрования" нет. Можно менять certificate validation и ALPN/SNI, но не +убирать QUIC crypto. + +## Stream-модель + +Для TCP: + +```text +TUIC connection -> open bidirectional QUIC stream -> send CONNECT command -> return Stream wrapper +``` + +Возможная C# форма: + +```csharp +public sealed class TuicStream : Stream +{ + // wraps QuicStream and handles TUIC close/reset semantics +} +``` + +Но `ConnectAsync` должен где-то хранить/reuse QUIC connection, иначе каждый +target TCP stream будет платить дорогой QUIC handshake. + +## Производительность + +TUIC интересен для высокой пропускной способности и потерь сети за счет QUIC +congestion control. Но для QuickProxyNet появляются новые вопросы: + +- какая QUIC библиотека; +- как настраивать BBR/CUBIC/new_reno; +- как маппить QUIC reset/close на `Stream`; +- как поддерживать UDP relay без `Stream`; +- как переиспользовать QUIC connection между несколькими `ConnectAsync`. + +sing-box документирует `congestion_control`: `cubic`, `new_reno`, `bbr` +(`cubic` по умолчанию). Для UDP relay там же есть режимы `native` и `quic`; +`udp_over_stream` является расширением sing-box, а не базовой TUIC-спеки. + +## Оценка реализации + +| Часть | Сложность | +| --- | --- | +| URI parser | Низкая | +| QUIC/TLS connection | Средняя/высокая | +| TLS exporter token | Средняя | +| TUIC commands/framing | Средняя | +| TCP `Stream` wrapper | Средняя | +| UDP relay | Отдельный API | + +TUIC реалистичнее делать отдельным optional package, чем добавлять прямо в +минимальное BCL-only ядро. + +## Источники + +- TUIC protocol spec: https://github.com/tuic-protocol/tuic/blob/master/SPEC.md +- TUIC repository: https://github.com/tuic-protocol/tuic +- sing-box TUIC outbound: https://sing-box.sagernet.org/configuration/outbound/tuic/ +- sing-box TUIC inbound: https://sing-box.sagernet.org/configuration/inbound/tuic/ +- QUIC RFC 9000: https://datatracker.ietf.org/doc/rfc9000/ +- QUIC datagrams RFC 9221: https://datatracker.ietf.org/doc/html/rfc9221 diff --git a/docs/vless.md b/docs/vless.md new file mode 100644 index 0000000..54e28e7 --- /dev/null +++ b/docs/vless.md @@ -0,0 +1,228 @@ +# VLESS + +VLESS - легкий proxy-протокол семейства Xray/V2Ray. Важная мысль для +QuickProxyNet: сам VLESS - это не транспорт и не шифрование. Это короткий +request/response header, который идет поверх уже установленного канала. + +Типичные стеки: + +```text +TCP -> VLESS -> target TCP stream +TCP -> TLS -> VLESS -> target TCP stream +TCP -> REALITY -> VLESS -> target TCP stream +TCP -> WebSocket/gRPC/XHTTP -> VLESS -> target stream +``` + +Для Minecraft интересен режим `network=tcp`/`type=tcp`/`type=raw` и команда +`TCP`. После успешного VLESS handshake можно вернуть обычный `Stream`. + +## URI + +Распространенная форма: + +```text +vless://@:?type=tcp&security=none#name +vless://@:?type=tcp&security=tls&sni=example.com#name +vless://@:?type=tcp&security=reality&pbk=...&sid=...&sni=...&fp=chrome&flow=xtls-rprx-vision#name +``` + +Частые параметры: + +| Параметр | Значение | +| --- | --- | +| `uuid` / userinfo | 16-байтовый идентификатор пользователя | +| `type` / `network` | транспорт: `tcp`/`raw`, `ws`, `grpc`, `xhttp`, `httpupgrade` | +| `security` | `none`, `tls`, `reality` | +| `sni` / `serverName` | имя для TLS/REALITY handshake | +| `flow` | например `xtls-rprx-vision`; влияет на XTLS/REALITY режим | +| `pbk` | REALITY public key | +| `sid` | REALITY short id | +| `fp` | uTLS/browser fingerprint: `chrome`, `firefox`, `safari`, etc. | +| `alpn` | ALPN для TLS/REALITY | +| `path`, `host`, `serviceName` | параметры WebSocket/gRPC/XHTTP транспортов | + +## Wire format: базовый TCP CONNECT + +Xray `EncodeRequestHeader` пишет: + +```text ++---------+----------+-------------+---------+-------------+ +| Version | UUID | Addons | Command | Destination | ++---------+----------+-------------+---------+-------------+ +| 1 byte | 16 bytes | variable | 1 byte | variable | ++---------+----------+-------------+---------+-------------+ +``` + +`Version` сейчас `0x00`. + +`UUID` - 16 байт в RFC/network byte order. В .NET нельзя бездумно брать +`Guid.ToByteArray()` для старых overload-ов, потому что там mixed-endian порядок. +Для .NET 8+ лучше использовать `Guid.TryWriteBytes(..., bigEndian: true, ...)` +или вручную парсить canonical UUID string. + +`Addons` для обычного режима без XTLS: + +```text +00 +``` + +Это длина protobuf/addons-блока. Для простого TCP она равна нулю. + +`Command`: + +```text +01 = TCP +02 = UDP +03 = Mux +04 = Reverse / RVS +``` + +Для Minecraft нужен `0x01`. + +`Destination` у VLESS кодируется как `port then address`: + +```text ++------+----------+ +| Port | Address | ++------+----------+ +| 2 BE | variable | ++------+----------+ +``` + +Address: + +```text +01 + 4 bytes IPv4 +02 + 1 byte domain length + domain bytes +03 + 16 bytes IPv6 +``` + +Пример для target `mc.example.com:25565` и UUID +`11223344-5566-7788-99aa-bbccddeeff00`: + +```text +00 version +11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF 00 +00 addons length +01 command TCP +63 DD port 25565 +02 domain address type +0E domain length +6D 63 2E 65 78 61 6D 70 6C 65 2E 63 6F 6D +``` + +После этих байт клиент может писать первый пакет целевого протокола, например +Minecraft handshake/status/login. + +## Response header + +Сервер отвечает: + +```text ++---------+--------+ +| Version | Addons | ++---------+--------+ +| 1 byte | var | ++---------+--------+ +``` + +В простом случае это: + +```text +00 00 +``` + +После response header поток прозрачен: следующие байты - это уже ответ target-а. + +## Режимы security + +### `security=none` + +Самый простой вариант: + +```text +TCP connect -> VLESS request -> VLESS response -> raw Stream +``` + +Это хорошо ложится в QuickProxyNet и похоже на SOCKS5 CONNECT. Runtime +dependencies не нужны. + +### `security=tls` + +Порядок: + +```text +TCP connect -> SslStream.AuthenticateAsClientAsync -> VLESS -> Stream +``` + +Для C# это тоже реалистично. Возвращаемым `Stream` будет `SslStream`. +Нужно поддержать `sni`, ALPN и certificate validation options. + +### `security=reality` + +Реализовано, в ядре. REALITY занимает место TLS, но не является `SslStream` — +рукопожатие написано своё (`Internal/Reality/`: X25519, HKDF key schedule, record +layer, ClientHello с запечатанным в `session_id` ключом): + +```text +TCP connect -> RealityTlsClient.HandshakeAsync -> VLESS -> Stream +``` + +Из ссылки берутся `pbk` (X25519 public key, base64url), `sid`, `sni`; ALPN по +умолчанию `h2, http/1.1`, как у Xray. Сервер, не узнавший нас, отдаёт настоящий +сертификат decoy-сайта — это приходит как `RealityHandshakeException` с кодом +`AuthFailed`. Чего нет: браузерного отпечатка ClientHello (`fp=chrome` сегодня +декоративен) — см. `reality-fingerprint-plan.md`. + +### `flow=xtls-rprx-vision` + +Реализовано. Flow добавляет в request header блок addons — protobuf-сообщение с +одним полем: `0x0A`, длина, `xtls-rprx-vision`. Сервер, у которого пользователь +настроен с этим flow, без него просто рвёт соединение. + +Дальше меняется и сам поток. Сервер отвечает не чистым VLESS: после двухбайтового +response header идёт UUID пользователя (один раз), а за ним кадры + +``` +command(1) | contentLen(2 BE) | paddingLen(2 BE) | content | padding +``` + +пока не придёт команда `0x01` (end) или `0x02` (direct) — после неё соединение +сырое. Команда `0x02` в жизни встречается чаще: её Xray шлёт, решив, что внутри +TLS. Клиент, который её игнорирует, зависает на следующем заголовке. + +`VisionStream` снимает эти кадры на приёме и один раз паддит первую отправку. +Вторая половина Vision — splice в прямое копирование при обнаружении TLS-в-TLS — +не реализована: это оптимизация пропускной способности, на формат провода она не +влияет. + +## Stream-модель + +| Режим | Можно вернуть `Stream` | Сложность | +| --- | --- | --- | +| TCP + none | Да, `NetworkStream` | Низкая | +| TCP + TLS | Да, `SslStream` | Средняя | +| TCP + REALITY | Да, но нужен custom stream/engine | Высокая | +| WebSocket | Да, но нужен WS stream adapter | Средняя | +| gRPC/XHTTP | Не обычный TCP stream без HTTP/2/3 слоя | Высокая | +| UDP | Не `Stream`; нужен datagram API | Отдельная модель | + +## Заметки для реализации + +- Начать с `VlessClient : ProxyClient` и `VlessHelper`. +- URI scheme: `vless`. +- Для `security=none` достаточно построить header в stack/pooled buffer. +- Для `security=tls` сначала завернуть socket в `SslStream`. +- UUID byte order покрыть unit-тестом. +- Domain length ограничен одним байтом. +- Response header читается лениво, на первом `Read` (`VlessResponseStream`): ни + Xray, ни sing-box не шлют его, пока target не ответил, и чтение до возврата + stream дедлочит любой client-speaks-first протокол. См. AGENTS.md, правило 2. + +## Источники + +- Xray VLESS encoding: https://github.com/XTLS/Xray-core/blob/main/proxy/vless/encoding/encoding.go +- Xray protocol address parser: https://github.com/XTLS/Xray-core/blob/main/common/protocol/address.go +- Xray VLESS outbound docs: https://xtls.github.io/en/config/outbounds/vless.html +- Xray transport docs: https://xtls.github.io/en/config/transport.html +- REALITY notes: https://github.com/XTLS/REALITY/blob/main/README.en.md diff --git a/docs/vmess-aead-body.md b/docs/vmess-aead-body.md new file mode 100644 index 0000000..ffe8429 --- /dev/null +++ b/docs/vmess-aead-body.md @@ -0,0 +1,450 @@ +# VMessAEAD (alterId=0) — Body Framing & Server Response — Byte-Exact Spec + +Scope: the **encrypted body stream** and the **AEAD server response header** for a +VMessAEAD client, `alterId = 0`, body security `aes-128-gcm` or +`chacha20-poly1305`. This document does **not** cover the request header AEAD +envelope (auth-id / `VMess Header AEAD *` KDF) — only what happens *after* the +request header has been sent: how request body chunks are sealed, how the server +response header is opened, and how response body chunks are opened. + +All multi-byte integers are **big-endian** unless stated otherwise. All AEAD tags +are **16 bytes**. All values in tables are **synthetic** illustrations. + +**Baseline profile** used throughout = request option `S` (`RequestOptionChunkStream`) +set, and options `M` (`RequestOptionChunkMasking`), `P` (`RequestOptionGlobalPadding`), +and `A` (`RequestOptionAuthenticatedLength`) **cleared**. This is the simplest +interoperable VMessAEAD body. The masking (SHAKE128) and padding paths are +described but marked out-of-scope-for-baseline. + +Sources verified against `v2fly/v2ray-core` @ `master` (cross-checked vs +`XTLS/Xray-core` @ `main`): +- `proxy/vmess/encoding/client.go` — `NewClientSession`, `EncodeRequestBody`, + `DecodeResponseHeader`, `DecodeResponseBody`, `GenerateChunkNonce`. +- `proxy/vmess/encoding/auth.go` — `GenerateChacha20Poly1305Key`, `ShakeSizeParser`. +- `common/crypto/auth.go` — `AuthenticationReader` / `AuthenticationWriter`. +- `common/crypto/chunk.go` — `PlainChunkSizeParser`, `AEADChunkSizeParser`. +- `proxy/vmess/aead/kdf.go` + `consts.go` — `KDF`, `KDF16`, label constants. +- `common/buf/buffer.go` — buffer `Size` constant. + +--- + +## 1. Body encryption keys, IVs, and per-chunk nonce + +### 1.1 Session key material + +`NewClientSession` draws 33 random bytes and splits them (verbatim logic): + +| Field | Source | Size | +| --- | --- | ---: | +| `requestBodyKey` | `randomBytes[0:16]` | 16 | +| `requestBodyIV` | `randomBytes[16:32]` | 16 | +| `responseHeader` (a.k.a. `respV`) | `randomBytes[32]` | 1 | + +For AEAD (`alterId = 0`, `isAEAD == true`): + +``` +responseBodyKey = SHA256(requestBodyKey)[0:16] // 16 bytes +responseBodyIV = SHA256(requestBodyIV) [0:16] // 16 bytes +``` + +(The non-AEAD/legacy path uses `MD5` instead; not used here.) + +### 1.2 aes-128-gcm + +- AEAD key = `requestBodyKey` (16 bytes) directly. `cipher.NewGCM(aes.NewCipher(requestBodyKey))`. +- `NonceSize()` = 12, tag = 16. + +### 1.3 chacha20-poly1305 — the MD5 key expansion + +The 16-byte `requestBodyKey` is expanded to a 32-byte ChaCha20 key by +`GenerateChacha20Poly1305Key` (verbatim behavior confirmed): + +``` +key = new byte[32] +key[0:16] = MD5(requestBodyKey) // MD5 of the 16-byte body key +key[16:32] = MD5(key[0:16]) // MD5 of the first half +return key +``` + +- `chacha20poly1305.New(key)`; `NonceSize()` = 12, tag = 16. + +### 1.4 Per-chunk nonce — `GenerateChunkNonce` (VERIFIED VERBATIM) + +```go +func GenerateChunkNonce(nonce []byte, size uint32) crypto.BytesGenerator { + c := append([]byte(nil), nonce...) // copy of the 16-byte body IV + count := uint16(0) + return func() []byte { + binary.BigEndian.PutUint16(c, count) // overwrite c[0..2) with the counter + count++ + return c[:size] // size == AEAD NonceSize() == 12 + } +} +``` + +Therefore, for **every chunk** the 12-byte AEAD nonce is: + +``` +nonce[0:2] = uint16 BE chunk counter (0,1,2,...) +nonce[2:12] = requestBodyIV[2:12] // 10 bytes of the body IV, unchanged +``` + +Byte layout (12 bytes total): + +| Offset | Bytes | Content | +| ---: | ---: | --- | +| 0 | 2 | `count` (uint16, big-endian) | +| 2 | 10 | `requestBodyIV[2..12]` (constant for the whole stream) | + +- Counter **starts at 0** and increments by 1 after each `Seal`/`Open`. +- The counter is a **`uint16`**: it wraps `0xFFFF -> 0x0000` (Go `uint16` overflow; + `PutUint16` rewrites only bytes `[0..2)`, leaving `[2..12)` intact). No nonce + bytes beyond the first two ever change. +- The **request body** stream uses `GenerateChunkNonce(requestBodyIV, 12)`; the + **response body** stream uses `GenerateChunkNonce(responseBodyIV, 12)`. Each + direction has its own independent counter starting at 0. + +> Note: the same `count`/`iv[2:]` nonce scheme is used for both AES-GCM and +> ChaCha20-Poly1305; only the key/cipher differs. + +--- + +## 2. Chunk framing (`AuthenticationReader` / `AuthenticationWriter`, option `S`) + +### 2.1 On-the-wire chunk + +Each chunk (baseline, `PlainChunkSizeParser`, no padding): + +``` ++----------------+-------------------------------+ +| length (2, BE) | AEAD sealed payload (N+16) | ++----------------+-------------------------------+ +``` + +- `length` is a **plain unmasked uint16, big-endian** (`PlainChunkSizeParser`, + `SizeBytes() == 2`). +- `length` = size of the **sealed** payload that follows = `plaintextLen + 16` + (plaintext length **plus the 16-byte AEAD tag**), NOT the plaintext length. + + Confirmed from `AuthenticationWriter.seal`: + `encryptedSize = len(plaintext) + auth.Overhead()` (Overhead = 16), and the + size field encoded is `uint16(encryptedSize + paddingSize)`. With baseline + `paddingSize == 0`, wire `length == plaintextLen + 16`. + +- The AEAD nonce for this chunk is the current `GenerateChunkNonce()` value + (§1.4). AAD is **empty/none** (`AdditionalDataGenerator = GenerateEmptyBytes()`). + +Worked example (aes-128-gcm, first data chunk, synthetic): + +``` +plaintext = "hello" (5 bytes) +counter = 0 -> nonce = 00 00 | requestBodyIV[2..12] +sealed = AES-128-GCM.Seal(plaintext) = 5 + 16 = 21 bytes +length = 21 = 0x0015 +wire bytes = 00 15 <21 bytes of ciphertext||tag> +``` + +### 2.2 Maximum chunk size + +- The `length` field is `uint16`, so the **wire-format hard cap** on a sealed + chunk is `65535` bytes → **max plaintext per chunk = 65535 − 16 = 65519**. +- v2ray-core / Xray-core do **not** emit chunks that large. The writer bounds each + aggregated stream chunk by: + `payloadSize = buf.Size − Overhead(16) − SizeBytes(2) − maxPadding`. + - `v2ray-core`: `buf.Size = 2048` → max plaintext per emitted chunk ≈ **2030**. + - `Xray-core`: `buf.Size = 8192` → max plaintext per emitted chunk ≈ **8174**. +- **The commonly cited "2^14 = 16384" is NOT a v2ray/Xray constant** — see the + uncertainty list. A correct reader must accept any sealed chunk up to the + `uint16` cap (65535); do not hard-code 16384 as a receive limit. + +### 2.3 EOF / termination + +End-of-stream is an **empty final chunk**: a sealed payload of zero-length +plaintext (i.e. just the 16-byte tag), framed with the length field. + +Reader EOF condition (verbatim logic from `AuthenticationReader`): + +```go +if size + r.sizeOffset == uint16(r.auth.Overhead()) + padding { + r.done = true + return io.EOF +} +``` + +For baseline (`PlainChunkSizeParser` → `sizeOffset = 0`, `padding = 0`, +`Overhead = 16`): EOF is signaled when the decoded `length == 16`. + +Exact bytes on the wire for the terminating chunk (aes-128-gcm or chacha20): + +``` +00 10 <16-byte AEAD tag of an empty plaintext, under the current chunk nonce> +``` + +i.e. `length = 0x0010 = 16`, followed by exactly 16 bytes (the tag). The reader +must still **verify** that tag with `Open` before treating the stream as cleanly +closed (the empty chunk is authenticated). The writer produces it by calling +`WriteMultiBuffer` with an empty buffer → `seal([]byte{})`. + +### 2.4 Length masking (`ShakeSizeParser`) — out of scope for baseline + +Active only when option `M` (`RequestOptionChunkMasking`) is set; then +`sizeParser = NewShakeSizeParser(bodyIV)` replaces `PlainChunkSizeParser`. +Verified behavior: + +- Fields: `shake sha3.ShakeHash`, `buffer [2]byte`. `SizeBytes() == 2`. +- Seed: `sha3.NewShake128()`, then `shake.Write(bodyIV)` (request uses + `requestBodyIV`, response uses `responseBodyIV`). +- `next()`: read 2 bytes from the SHAKE128 stream, interpret big-endian → `mask` (uint16). +- `Decode(b)`: `size = mask ^ binary.BigEndian.Uint16(b)`. +- `Encode(size, b)`: `binary.BigEndian.PutUint16(b, mask ^ size)`. + +So with masking the 2-byte length is XOR-masked by a per-chunk SHAKE128 keystream +word (a fresh `mask` per chunk, in lock-step between the two peers). For the +baseline (`M` off) the length is a plain uint16 — **implement `PlainChunkSizeParser` +first; `ShakeSizeParser` is future work.** .NET has `System.Security.Cryptography.Shake128` +(§5) for when masking is added. + +### 2.5 Global padding (`P`) — out of scope for baseline + +Active only when option `P` (`RequestOptionGlobalPadding`) is set, and it requires +`M` (the size parser must implement `PaddingLengthGenerator`, which only +`ShakeSizeParser` does). Each chunk then carries `NextPaddingLen()` random padding +bytes appended **after** the sealed payload, where: + +``` +NextPaddingLen() = ShakeSizeParser.next() % 64 // 0..63 bytes, from the same SHAKE128 stream +``` + +and the wire `length` field counts `sealedSize + paddingLen`. Baseline: `P` off, +`paddingLen == 0` always. Confirmed. + +--- + +## 3. Server response header (AEAD, alterId=0) + +The response header is **always AES-128-GCM**, independent of the body cipher. + +### 3.1 KDF (`proxy/vmess/aead/kdf.go`, VERIFIED VERBATIM) + +```go +func KDF(key []byte, path ...string) []byte { + hmacCreator := &hMacCreator{value: []byte("VMess AEAD KDF")} + for _, v := range path { + hmacCreator = &hMacCreator{value: []byte(v), parent: hmacCreator} + } + hmacf := hmacCreator.Create() + hmacf.Write(key) + return hmacf.Sum(nil) // 32 bytes +} +func KDF16(key, path...) []byte { return KDF(key, path...)[:16] } + +func (h *hMacCreator) Create() hash.Hash { + if h.parent == nil { return hmac.New(sha256.New, h.value) } + return hmac.New(h.parent.Create, h.value) // nested HMAC: parent HMAC used as the "hash" ctor +} +``` + +This is a **recursive/nested HMAC-SHA256**. For a single-label path +`KDF(key, LABEL)` it evaluates to: + +``` +inner = HMAC-SHA256 keyed by "VMess AEAD KDF" (used as the block/hash function) +outer = HMAC keyed by LABEL, whose underlying hash constructor is `inner` +result = outer.Update(key).Final() (32 bytes) +``` + +A plain `HMACSHA256` call is **not** sufficient — a nested-HMAC wrapper is required +(see §5 note). `KDF16` truncates to `[0:16]`; IVs below truncate to `[0:12]`. + +### 3.2 The four response-header KDF labels (VERIFIED VERBATIM strings) + +| Purpose | Constant | Exact string | Derivation | +| --- | --- | --- | --- | +| Length key | `KDFSaltConstAEADRespHeaderLenKey` | `AEAD Resp Header Len Key` | `KDF16(responseBodyKey, "AEAD Resp Header Len Key")` (16B) | +| Length IV | `KDFSaltConstAEADRespHeaderLenIV` | `AEAD Resp Header Len IV` | `KDF(responseBodyIV, "AEAD Resp Header Len IV")[0:12]` (12B) | +| Payload key | `KDFSaltConstAEADRespHeaderPayloadKey` | `AEAD Resp Header Key` | `KDF16(responseBodyKey, "AEAD Resp Header Key")` (16B) | +| Payload IV | `KDFSaltConstAEADRespHeaderPayloadIV` | `AEAD Resp Header IV` | `KDF(responseBodyIV, "AEAD Resp Header IV")[0:12]` (12B) | + +Note the length labels contain `Len` and the payload labels do **not** — copy +exactly, including single spaces. Keys derive from **`responseBodyKey`**, IVs from +**`responseBodyIV`** (§1.1). + +### 3.3 Response header envelope on the wire + +``` ++------------------------------------------+------------------------------------------------+ +| encrypted length: 2 + 16 tag (18 bytes) | encrypted header: L + 16 tag (L+16 bytes) | ++------------------------------------------+------------------------------------------------+ +``` + +Step 1 — read exactly **18 bytes**, decrypt to get `L`: + +``` +L_plain(2 bytes) = AES128GCM(key=lenKey, nonce=lenIV, aad=EMPTY).Open(cipher18) +L = uint16_BE(L_plain) // length of the header plaintext, tag-excluded +``` + +Step 2 — read exactly **`L + 16` bytes**, decrypt to get the header plaintext: + +``` +headerPlain(L bytes) = AES128GCM(key=payloadKey, nonce=payloadIV, aad=EMPTY).Open(cipherL16) +``` + +- **AAD is empty/none** for both opens (`Open(nil, iv, data, nil)`). Confirmed. +- Each of the two AEAD operations uses its own fixed 12-byte nonce (from the KDF), + used exactly once — there is no counter here. + +### 3.4 Response header plaintext layout + +The client reads the **first 4 bytes**, then optional command data: + +| Offset | Size | Field | Meaning / check | +| ---: | ---: | --- | --- | +| 0 | 1 | `responseHeader` (respV) | **MUST equal** the `session.responseHeader` byte the client generated (§1.1). Mismatch → reject the connection. | +| 1 | 1 | `option` | response option bitmask (e.g. dynamic-port / reuse hints). | +| 2 | 1 | `command` | command id; `0` = no command. | +| 3 | 1 | `commandLength` | length `M` of command data (only meaningful if `command != 0`). | +| 4 | M | `commandData` | present only if `command != 0`; parsed by `UnmarshalCommand(command, data)`. | + +Verbatim client check: `if buffer.Byte(0) != c.responseHeader { reject }`. If +`buffer.Byte(2) != 0`, it reads `dataLen = buffer.Byte(3)` more bytes and calls +`UnmarshalCommand`. Known commands are **dynamic-port / switch-account** style +directives (`commandData` carries host/port/id/alterId/valid-time). A minimal +client **may parse-and-ignore** them (skip `commandLength` bytes) — decrypt/verify +correctness does not depend on acting on them. + +### 3.5 Response body + +Immediately after the response header, the response body follows as chunks using +**exactly the framing of §2**, but keyed with `responseBodyKey` / `responseBodyIV`: + +- aes-128-gcm: `NewAesGcm(responseBodyKey)`, nonce `GenerateChunkNonce(responseBodyIV,12)`. +- chacha20: `chacha20poly1305.New(GenerateChacha20Poly1305Key(responseBodyKey))`, + nonce `GenerateChunkNonce(responseBodyIV,12)`. +- Same `PlainChunkSizeParser` length semantics, same empty-chunk EOF, same + masking/padding gating on options `M`/`P` (baseline: neither). + +--- + +## 4. Half-close / cancellation semantics for a `Stream` wrapper + +- **Writer signals EOF** by emitting the empty terminating chunk (§2.3): seal an + empty plaintext under the next chunk nonce and frame it (`00 10` + 16-byte tag). + In a .NET `Stream`, do this on graceful close / `FlushFinalBlock`-equivalent — + once, then write no further body bytes. (TCP FIN alone is not the in-band EOF; + the authenticated empty chunk is.) +- **Reader detects EOF** when it decodes a chunk whose `length == Overhead (16)` + (baseline) — i.e. an authenticated empty chunk → surface as normal end-of-stream + (`Read` returns 0). The tag on that empty chunk must still verify. +- **Half-close**: request-body and response-body streams are independent + (separate keys, IVs, and counters). One direction may send its EOF chunk while + the other keeps flowing. Model as two half-duplex encrypted streams over one + transport. +- **Unexpected mid-chunk close**: if the underlying connection closes while a chunk + is partially received (fewer than `length` sealed bytes available, or the length + prefix itself is truncated), this is a **truncation error**, NOT clean EOF — + surface it as an `IOException`/`EndOfStreamException`. Clean EOF is *only* the + authenticated empty chunk. A failed `Open` (bad tag) is likewise a hard error + (tampering / desync), never treated as EOF. + +--- + +## 5. .NET BCL notes (net8 / net9 / net10) + +- **AES-GCM** — `System.Security.Cryptography.AesGcm`. In .NET 8+ the tag-length + is mandatory: construct as `new AesGcm(key, tagSizeInBytes: 16)` (the + length-less ctor is obsolete/removed in net8). `AesGcm.IsSupported` exists. + Nonce 12 bytes, tag 16 bytes — matches VMess. +- **ChaCha20-Poly1305** — `System.Security.Cryptography.ChaCha20Poly1305`. Nonce + 12, tag 16 (fixed) — matches. **`ChaCha20Poly1305.IsSupported` may be `false`** + on some platforms (it wraps OS primitives: OpenSSL on Linux/macOS, CNG on + Windows — unsupported on older Windows builds). **Always gate usage on + `ChaCha20Poly1305.IsSupported`**; if false, fall back (e.g. BouncyCastle + `ChaCha20Poly1305` or restrict `security` to `aes-128-gcm`). Do not assume it + is present. +- **MD5** — `MD5.HashData(...)` (one-shot). Needed for the ChaCha key expansion + (§1.3) and legacy paths. (Cryptographically weak, but the protocol mandates it + here.) +- **SHA-256** — `SHA256.HashData(...)` for `responseBodyKey/IV` derivation (§1.1). +- **HMAC-SHA256** — `HMACSHA256` / `HMACSHA256.HashData(...)`. **Caveat:** the VMess + `KDF` is a *nested* HMAC (§3.1) where an inner HMAC is used as the outer HMAC's + hash function. `HMACSHA256` alone cannot express that; implement a small + recursive-HMAC helper (a custom `HashAlgorithm` that wraps an `HMACSHA256`, or a + hand-rolled HMAC over an HMAC PRF). Verify it against a known VMess test vector. +- **SHAKE128** — `System.Security.Cryptography.Shake128` exists in **.NET 8+** + (`Shake128.IsSupported`). Needed only for the future `ShakeSizeParser` masking / + global-padding support (§2.4–2.5); not required for the baseline. + +--- + +## 6. Per-chunk pseudocode (baseline, synthetic) + +### 6.1 Send request body + +``` +counter = 0 +key = (security==AESGCM) ? requestBodyKey + : Chacha20KeyExpand(requestBodyKey) // §1.3 +foreach plaintext block (<= ~2030 or up to 65519 bytes): + nonce = putUint16BE(counter) || requestBodyIV[2:12] + sealed = AEAD_Seal(key, nonce, plaintext, aad=EMPTY) // len = |plaintext| + 16 + write( uint16BE(|sealed|) ) // = |plaintext| + 16 + write( sealed ) + counter = (counter + 1) mod 65536 +// EOF: +nonce = putUint16BE(counter) || requestBodyIV[2:12] +sealed = AEAD_Seal(key, nonce, EMPTY, aad=EMPTY) // 16-byte tag +write( uint16BE(16) ); write( sealed ) // 00 10 +``` + +### 6.2 Read server response header + +``` +respKey = SHA256(requestBodyKey)[0:16] +respIV = SHA256(requestBodyIV)[0:16] +lenKey = KDF16(respKey, "AEAD Resp Header Len Key") +lenIV = KDF (respIV, "AEAD Resp Header Len IV")[0:12] +hdrKey = KDF16(respKey, "AEAD Resp Header Key") +hdrIV = KDF (respIV, "AEAD Resp Header IV")[0:12] + +read 18 bytes -> encLen +Lbytes = AES128GCM_Open(lenKey, lenIV, encLen, aad=EMPTY) // 2 bytes +L = uint16BE(Lbytes) + +read (L+16) bytes -> encHdr +hdr = AES128GCM_Open(hdrKey, hdrIV, encHdr, aad=EMPTY) // L bytes +assert hdr[0] == session.responseHeader // MUST verify +option = hdr[1]; command = hdr[2]; cmdLen = hdr[3] +if command != 0: cmdData = next cmdLen bytes of hdr // parse or skip +``` + +### 6.3 Read response body + +``` +counter = 0 +key = (security==AESGCM) ? respKey : Chacha20KeyExpand(respKey) +loop: + read 2 bytes -> length (uint16 BE) // truncated read here => error, not EOF + if length == 16: // empty chunk => clean EOF + tag = read 16 bytes + nonce = putUint16BE(counter) || respIV[2:12] + AEAD_Open(key, nonce, tag, aad=EMPTY) // must verify; then stream is closed + break + sealed = read (length) bytes // short read => error + nonce = putUint16BE(counter) || respIV[2:12] + plaintext = AEAD_Open(key, nonce, sealed, aad=EMPTY) // bad tag => hard error + deliver(plaintext) + counter = (counter + 1) mod 65536 +``` + +--- + +## 7. Reference test vectors + +No official byte-level VMessAEAD **body**/response test vectors are published in +the v2ray-core / Xray-core repos (the encoding tests use randomized round-trips, +not fixed vectors). Recommended approach: generate vectors by running the Go +reference (`proxy/vmess/encoding`) with a fixed RNG seed and capturing wire bytes, +then assert the C# implementation reproduces them. Nested-HMAC `KDF` in particular +should be pinned to a captured `(key, label) -> 16/12 bytes` vector. **Flagged as +not-independently-verified** — see below. diff --git a/docs/vmess-aead-request.md b/docs/vmess-aead-request.md new file mode 100644 index 0000000..f1f02a1 --- /dev/null +++ b/docs/vmess-aead-request.md @@ -0,0 +1,471 @@ +# VMessAEAD Client Request Header — Byte-Exact Specification + +Target: a C#/.NET reimplementation of the **VMessAEAD** (alterId = 0) client +request header and its authentication envelope. + +Scope: this document covers **only** the client-side request header generation +(what the client writes onto the wire immediately after TCP connect). It does not +cover the body/data chunk framing, the response header, or legacy (non-AEAD, +alterId > 0) VMess. + +Sources cross-verified (raw Go source, not blog summaries): + +- `v2fly/v2ray-core` @ `master` + - `proxy/vmess/aead/kdf.go`, `.../aead/authid.go`, `.../aead/encrypt.go`, `.../aead/consts.go` + - `common/protocol/id.go` (cmdKey), `common/protocol/headers.go`, `common/protocol/headers.pb.go`, `common/protocol/address.go` + - `proxy/vmess/encoding/client.go`, `proxy/vmess/encoding/encoding.go` +- `XTLS/Xray-core` @ `main` — `proxy/vmess/aead/consts.go` (identical constant values; used as a cross-check) +- V2Fly developer docs: + +All multi-byte integers on the VMess wire are **big-endian (network order)** unless +stated otherwise. All AEAD key labels below are copied **verbatim** from the Go +`const` values in `aead/consts.go`. + +> Convention in this doc: `‖` = concatenation. `KDF16(k, L, a, b)` = first 16 +> bytes of `KDF(k, L, a, b)`. Byte offsets are 0-based. + +--- + +## 0. Constant reference (verbatim label strings) + +From `proxy/vmess/aead/consts.go` (v2fly and Xray agree byte-for-byte): + +| Go const name | Verbatim string value | Used for | +|---|---|---| +| `KDFSaltConstVMessAEADKDF` | `VMess AEAD KDF` | KDF seed / innermost HMAC key | +| `KDFSaltConstAuthIDEncryptionKey` | `AES Auth ID Encryption` | AuthID AES-128-ECB key | +| `KDFSaltConstVMessHeaderPayloadLengthAEADKey` | `VMess Header AEAD Key_Length` | length-AEAD key | +| `KDFSaltConstVMessHeaderPayloadLengthAEADIV` | `VMess Header AEAD Nonce_Length` | length-AEAD nonce | +| `KDFSaltConstVMessHeaderPayloadAEADKey` | `VMess Header AEAD Key` | payload-AEAD key | +| `KDFSaltConstVMessHeaderPayloadAEADIV` | `VMess Header AEAD Nonce` | payload-AEAD nonce | + +Response-direction labels (NOT needed for the request, listed for completeness): +`AEAD Resp Header Len Key`, `AEAD Resp Header Len IV`, `AEAD Resp Header Key`, +`AEAD Resp Header IV`. + +cmdKey magic string (from `common/protocol/id.go`): +`c48619fe-8f02-49e0-b9e9-edf763e17e21` + +> ⚠️ These label strings are the exact bytes hashed. Any deviation (trailing space, +> underscore vs space, capitalization) breaks interop. Note the length labels use an +> **underscore**: `..._Length`. Copy them exactly. + +--- + +## 1. cmdKey derivation (16 bytes) + +`cmdKey = MD5( uuid16 ‖ "c48619fe-8f02-49e0-b9e9-edf763e17e21" )` + +From `common/protocol/id.go`: + +```go +md5hash := md5.New() +md5hash.Write(uuid.Bytes()) // 16 bytes +md5hash.Write([]byte("c48619fe-8f02-49e0-b9e9-edf763e17e21")) // 36 ASCII bytes +md5hash.Sum(id.cmdKey[:0]) // 16-byte digest +``` + +- `uuid.Bytes()` returns the **raw 16 RFC 4122 (big-endian / network-order) bytes** + of the UUID — the same order as the canonical text form reads left to right. + This is exactly what the project's `UuidCodec.WriteBigEndian` produces + (`Guid.TryWriteBytes(dest, bigEndian: true, ...)`). Do **not** use + `Guid.ToByteArray()` (that overload is little-endian for the first three fields). +- The magic string is appended as **ASCII bytes** (36 chars: 32 hex digits + 4 + dashes). It is a literal string, NOT parsed as a UUID. +- `cmdKey` is the 16-byte MD5 output; it is the `key` argument to every KDF below. + +MD5 total input = 16 + 36 = **52 bytes**. + +--- + +## 2. The VMessAEAD KDF (nested recursive HMAC-SHA256) + +From `proxy/vmess/aead/kdf.go`: + +```go +func KDF(key []byte, path ...string) []byte { + hmacCreator := &hMacCreator{value: []byte(KDFSaltConstVMessAEADKDF)} + for _, v := range path { + hmacCreator = &hMacCreator{value: []byte(v), parent: hmacCreator} + } + hmacf := hmacCreator.Create() + hmacf.Write(key) + return hmacf.Sum(nil) +} + +func (h *hMacCreator) Create() hash.Hash { + if h.parent == nil { + return hmac.New(sha256.New, h.value) // base: HMAC-SHA256 keyed by the seed + } + return hmac.New(h.parent.Create, h.value) // HMAC keyed by value, hash = parent HMAC +} + +func KDF16(key []byte, path ...string) []byte { return KDF(key, path...)[:16] } +``` + +### What it computes + +`KDF` builds a chain of HMAC constructors where **each path label becomes the key of +an HMAC whose underlying hash function is the previous (parent) HMAC**. The innermost +(base) HMAC is keyed by the literal seed `"VMess AEAD KDF"` over plain SHA-256. The +final HMAC is fed the `key` (cmdKey, or cmdKey for all our uses) as its message. + +For labels `L1, L2, ... Ln` (in the order passed), the result is: + +``` +KDF(key, L1, L2, ..., Ln) = + HMAC_{Ln}( + h = HMAC_{L(n-1)}( + h = ... HMAC_{L1}( + h = HMAC_{"VMess AEAD KDF"}(h = SHA256) + ) ... + ) + ) applied to message = key +``` + +- The **last** path label is the key of the **outermost** HMAC. +- The **seed** `"VMess AEAD KDF"` is the key of the **innermost** HMAC (hash = SHA-256). +- The **message** passed to the final HMAC is `key` (the 16-byte cmdKey). +- Output is 32 bytes (SHA-256 block). `KDF16` = first 16 bytes. `KDF12` (nonce) = + first 12 bytes (the code writes `KDF(...)[:12]` inline; there is no named `KDF12`). + +### Reference pseudocode (recursion made explicit) + +``` +function KDF_bytes(key, labels[]): # returns 32 bytes + # Build innermost first + inner = HMAC_SHA256_init(macKey = "VMess AEAD KDF") # hash = SHA-256 + current = inner + for L in labels: # in given order, first label wraps the seed + current = HMAC_init(macKey = L, innerHashFactory = current) + current.update(key) # message = cmdKey + return current.finalize() # 32 bytes + +KDF16(key, labels) = KDF_bytes(key, labels)[0..16] +KDF12(key, labels) = KDF_bytes(key, labels)[0..12] +``` + +> Implementation note for .NET: an HMAC "whose hash function is another HMAC" is not +> directly expressible with `HMACSHA256` (which is hard-wired to SHA-256). You must +> implement the generic HMAC construction manually: +> `HMAC_K(m) = H( (K⊕opad) ‖ H( (K⊕ipad) ‖ m ) )` with block size 64, where the inner +> `H` at each level is itself a full HMAC evaluation. Keys longer than 64 bytes are +> hashed first per RFC 2104 (none of our labels exceed 64 bytes, so this branch is not +> hit here — but implement it for correctness). Verify against a captured trace. + +--- + +## 3. AuthID (the 16-byte EAuID) + +From `proxy/vmess/aead/authid.go` — `CreateAuthID(cmdKey, time.Now().Unix())`: + +### 3a. Plaintext (16 bytes, pre-encryption) + +| Offset | Size | Field | Encoding | +|---|---|---|---| +| 0 | 8 | Unix timestamp (seconds) | **int64, big-endian** | +| 8 | 4 | Random | 4 bytes from `crypto/rand` | +| 12 | 4 | CRC32-IEEE of bytes `[0..12)` | **uint32, big-endian** | + +```go +buf := bytes.NewBuffer(nil) +binary.Write(buf, binary.BigEndian, time) // int64 seconds +buf.Write(random4) // 4 random bytes +zero := crc32.ChecksumIEEE(buf.Bytes()) // CRC32 over the first 12 bytes +binary.Write(buf, binary.BigEndian, zero) // uint32, big-endian +// buf now holds exactly 16 bytes +``` + +- CRC is **CRC-32/IEEE** (poly `0xEDB88320` reflected; the standard zlib/PKZIP CRC-32, + init `0xFFFFFFFF`, final XOR `0xFFFFFFFF`, input & output reflected) — this is + `crc32.ChecksumIEEE`. +- CRC input = the **first 12 bytes** (timestamp ‖ random), computed **before** the + CRC field is appended. +- CRC output is serialized **big-endian**. + +### 3b. Encryption + +```go +key := KDF16(cmdKey, KDFSaltConstAuthIDEncryptionKey) // "AES Auth ID Encryption" +block, _ := aes.NewCipher(key) // AES-128 +block.Encrypt(authid[:], buf.Bytes()) // single 16-byte block +``` + +- Cipher: **AES-128**, single block, `block.Encrypt` = raw ECB of one block = + **no padding, no IV, no chaining**. (16-byte plaintext → 16-byte `authid`.) +- Key = `KDF16(cmdKey, "AES Auth ID Encryption")`. +- Result `authid` (16 bytes) is the first field written on the wire. + +> In .NET: use `Aes` with `Mode = CipherMode.ECB`, `Padding = PaddingMode.None`, or +> the one-shot `EncryptEcb(plaintext, PaddingMode.None)` (net6+), on a single 16-byte +> block. Do not use `AesGcm` here. + +--- + +## 4. Request header sealing (AEAD envelope) + +From `proxy/vmess/aead/encrypt.go` — `SealVMessAEADHeader(cmdKey [16]byte, data []byte)`, +where `data` is the plaintext instruction/command section from §5. + +### 4a. Wire order (exact) + +| Offset | Size | Field | +|---|---|---| +| 0 | 16 | `authid` (§3, the EAuID) | +| 16 | 2 + 16 | `encryptedLength` = AES-128-GCM(len(data) as uint16 BE) + 16-byte tag = **18 bytes** | +| 34 | 8 | `connectionNonce` (8 random bytes from `crypto/rand`) | +| 42 | L + 16 | `encryptedHeader` = AES-128-GCM(data) + 16-byte tag, where `L = len(data)` | + +Total request-header size = `16 + 18 + 8 + (L + 16)` = **58 + L** bytes. + +```go +generatedAuthID := CreateAuthID(cmdKey[:], time.Now().Unix()) // 16 bytes (§3) +connectionNonce := random(8) + +lenBytes := uint16BE(len(data)) // 2 bytes + +// --- length AEAD --- +lenKey := KDF16(cmdKey, "VMess Header AEAD Key_Length", authid, connectionNonce) +lenNonce := KDF (cmdKey, "VMess Header AEAD Nonce_Length", authid, connectionNonce)[:12] +encryptedLength = AES128GCM(lenKey).Seal(nonce=lenNonce, plaintext=lenBytes, aad=authid) + +// --- payload AEAD --- +payKey := KDF16(cmdKey, "VMess Header AEAD Key", authid, connectionNonce) +payNonce := KDF (cmdKey, "VMess Header AEAD Nonce", authid, connectionNonce)[:12] +encryptedHeader = AES128GCM(payKey).Seal(nonce=payNonce, plaintext=data, aad=authid) + +output = authid ‖ encryptedLength ‖ connectionNonce ‖ encryptedHeader +``` + +### 4b. The four KDF derivations (verbatim labels + args) + +Both AEADs are keyed off `cmdKey` and mixed with **both** `authid` and +`connectionNonce` as extra KDF path elements (passed as raw byte strings, in that +order): + +| Purpose | Function | Label (verbatim) | Extra KDF args | Output | +|---|---|---|---|---| +| Length key | `KDF16` | `VMess Header AEAD Key_Length` | `authid`, `connectionNonce` | 16 B | +| Length nonce | `KDF`→`[:12]` | `VMess Header AEAD Nonce_Length` | `authid`, `connectionNonce` | 12 B | +| Payload key | `KDF16` | `VMess Header AEAD Key` | `authid`, `connectionNonce` | 16 B | +| Payload nonce | `KDF`→`[:12]` | `VMess Header AEAD Nonce` | `authid`, `connectionNonce` | 12 B | + +So each key/nonce is `KDF(cmdKey, LABEL, authid, connectionNonce)` — i.e. a 3-label +KDF path: `[LABEL, authid_bytes, connectionNonce_bytes]`. + +### 4c. AEAD parameters + +- Algorithm: **AES-128-GCM** (`aes.NewCipher` + `cipher.NewGCM`), 16-byte (128-bit) + authentication tag, 12-byte nonce (standard GCM). +- **AAD = `authid` (the 16-byte EAuID) for BOTH** the length AEAD and the payload + AEAD. (Confirmed: `Seal(nil, nonce, plaintext, generatedAuthID[:])` in both blocks.) +- GCM `Seal` output = ciphertext (same length as plaintext) followed by the 16-byte + tag. So encryptedLength = 2 + 16 = 18 bytes; encryptedHeader = L + 16 bytes. + +> In .NET: `AesGcm` (net5+). Construct with the 16-byte key; the constructor now +> requires the tag size (`new AesGcm(key, 16)` on net8+). Call +> `Encrypt(nonce12, plaintext, ciphertext, tag16, associatedData: authid)`. + +--- + +## 5. Plaintext instruction/command section (`data`, what §4 seals) + +Built in `proxy/vmess/encoding/client.go` (`EncodeRequestHeader`). This is the +plaintext that becomes `encryptedHeader`. + +### 5a. Field layout + +| Offset | Size | Field | Value / encoding | +|---|---|---|---| +| 0 | 1 | Version | `0x01` (constant `Version = byte(1)`) | +| 1 | 16 | `requestBodyIV` | random (used later for body cipher) | +| 17 | 16 | `requestBodyKey` | random (used later for body cipher) | +| 33 | 1 | `responseHeader` (respV) | random byte; server echoes it in its response header | +| 34 | 1 | Option (`Opt`) | bitflags, see §5b | +| 35 | 1 | `(paddingLen << 4) \| security` | high nibble = padding length; low nibble = security type (§5c) | +| 36 | 1 | Reserved | `0x00` | +| 37 | 1 | Command | `0x01` TCP, `0x02` UDP, `0x03` Mux | +| 38 | 2 | Port | **big-endian uint16** | +| 40 | 1 | Address type (`T`) | `0x01` IPv4, `0x02` domain, `0x03` IPv6 | +| 41 | var | Address | see §5d | +| … | `paddingLen` | Random padding | `paddingLen` random bytes | +| end−4 | 4 | Checksum `F` | **FNV-1a-32, big-endian**, over all preceding bytes (§5e) | + +> ⚠️ Field order for port vs address: the VMess command section writes **port first, +> then address type, then address** (`addrParser` is configured `PortThenAddress`). +> This is the opposite of SOCKS5/Trojan/VLESS ordering. Confirmed via +> `PortThenAddress()` in `encoding.go`. + +### 5b. Option byte bitflags (`common/protocol/headers.go`) + +| Flag | Value | Meaning | +|---|---|---| +| `RequestOptionChunkStream` (S) | `0x01` | body sent as chunked stream (standard) | +| `RequestOptionConnectionReuse` (R) | `0x02` | connection reuse (deprecated) | +| `RequestOptionChunkMasking` (M) | `0x04` | chunk length obfuscation | +| `RequestOptionGlobalPadding` (P) | `0x08` | global padding | +| `RequestOptionAuthenticatedLength` (A) | `0x10` | authenticated chunk length | + +The exact option value is a **client configuration choice** (depends on the negotiated +security and features), not a fixed constant. For a typical modern AEAD client using +AES-128-GCM or ChaCha20-Poly1305, v2ray sets +`ChunkStream | ChunkMasking | GlobalPadding | AuthenticatedLength` (`0x01|0x04|0x08|0x10 = 0x1D`). +These flags govern **body framing**, which is out of this document's scope — but they +must be written correctly here because the server reads them from this header. + +### 5c. Security type (low nibble of byte 35) + +The low nibble is `byte(header.Security)`, i.e. the numeric `SecurityType` +(`common/protocol/headers.pb.go`). Wire values (confirmed identical in the v2fly doc's +"Sec" table): + +| SecurityType | Numeric / nibble value | Meaning | +|---|---|---| +| `UNKNOWN` | `0` (`0x0`) | unknown (not written) | +| `LEGACY` | `1` (`0x1`) | AES-128-CFB (legacy, non-AEAD body) | +| `AUTO` | `2` (`0x2`) | auto — resolved to a concrete cipher before writing | +| `AES128_GCM` | `3` (`0x3`) | AES-128-GCM | +| `CHACHA20_POLY1305` | `4` (`0x4`) | ChaCha20-Poly1305 | +| `NONE` | `5` (`0x5`) | no body encryption | +| `ZERO` | `6` (`0x6`) | "zero" — no encryption and no auth (implies NONE + no chunk stream) | + +- For a normal AEAD client you will write `3` (AES-128-GCM) or `4` + (ChaCha20-Poly1305) in the low nibble. +- `AUTO (2)` and `UNKNOWN (0)` should not appear on the wire: the client resolves + `AUTO` to `AES128_GCM` (or ChaCha20-Poly1305 on platforms without AES hardware) + before serializing. +- High nibble = `paddingLen` (0–15, see §6). + +### 5d. Address encoding + +| Address type | Bytes written | +|---|---| +| IPv4 (`0x01`) | 4 raw bytes | +| Domain (`0x02`) | 1-byte length `n`, then `n` ASCII/IDNA bytes | +| IPv6 (`0x03`) | 16 raw bytes | + +(Port — 2 bytes big-endian — is written **before** the address-type byte; see §5a.) + +### 5e. Checksum: FNV-1a-32 + +From `encoding/client.go`: `fnv1a := fnv.New32a(); fnv1a.Write(buffer.Bytes()); fnv1a.Sum(hashBytes[:0])`. + +- Algorithm: **FNV-1a, 32-bit**. + - Offset basis = `2166136261` (`0x811C9DC5`) + - Prime = `16777619` (`0x01000193`) + - Per byte: `hash ^= b; hash *= prime` (XOR first, then multiply — the "1a" variant), + all arithmetic mod 2³². +- Input = **every byte of the command section written so far**, i.e. version through + random padding **inclusive** (the padding IS covered by the checksum; the checksum + is appended last). +- Output = 4 bytes **big-endian** (Go `hash.Hash32.Sum` emits the uint32 big-endian). + +> The 4-byte checksum is part of `data` (the payload plaintext). The §4 payload AEAD +> then seals the whole thing (including this FNV checksum) and adds its own GCM tag on +> top. So the header has two independent integrity layers: the inner FNV-1a and the +> outer GCM tag. + +--- + +## 6. Time window, jitter, and padding length + +### 6a. Timestamp used by the client + +- The AuthID timestamp (§3a) is `time.Now().Unix()` — the **current UTC time in + whole seconds** (Unix epoch), int64 big-endian. For **VMessAEAD the client uses the + exact current second** (no random jitter is added to the AEAD AuthID timestamp; + `SealVMessAEADHeader` calls `CreateAuthID(key, time.Now().Unix())` directly). + +> ⚠️ Distinction from **legacy VMess**: the old (alterId>0, MD5-auth) format hashed a +> timestamp randomized within **±30 seconds** of now (the "±30s" figure in the v2fly +> developer doc refers to that legacy path, not to AEAD). Do not apply ±30s jitter to +> the AEAD AuthID. + +### 6b. Server-side acceptance window (informational — client just uses "now") + +From `proxy/vmess/aead/authid.go`, the server's `AuthIDDecoder.Match` rejects any +decrypted AuthID whose timestamp differs from the server's current time by **more than +120 seconds**: + +```go +if math.Abs(math.Abs(float64(t)) - float64(time.Now().Unix())) > 120 { continue } +``` + +The replay filter is likewise sized for a **120-second** window (`cacheDurationSec = 120` +in `validator.go`, giving a ±120 s generation range). **Practical implication for the +client: its clock must be within ~120 s of the server's.** The client itself sends +"now"; it does not choose a window. + +### 6c. Random padding length + +- `paddingLen := dice.RollWith(16, rand.Reader)` → a random integer in **[0, 16)**, + i.e. **0–15** inclusive (fits the 4-bit high nibble of byte 35). +- `paddingLen` random bytes (from `crypto/rand`) are appended after the address and + **before** the FNV-1a checksum, and are covered by that checksum (§5e). + +--- + +## 7. .NET BCL availability notes (net8 / net9 / net10) + +Available in `System.Security.Cryptography` — use directly: + +| Primitive | .NET API | Notes | +|---|---|---| +| MD5 (cmdKey) | `MD5.HashData(...)` | one-shot, static; fine for the 52-byte input | +| SHA-256 | `SHA256`, `SHA256.HashData` | base hash for the KDF's HMAC | +| HMAC-SHA256 | `HMACSHA256`, `HMACSHA256.HashData(key, data)` | see caveat below | +| AES-128-ECB single block (AuthID) | `Aes` with `Mode=ECB, Padding=None`, or `aes.EncryptEcb(pt, PaddingMode.None)` | one 16-byte block; NOT AesGcm | +| AES-128-GCM (envelope) | `AesGcm` | net8+ constructor requires tag size: `new AesGcm(key, 16)` | + +Must be implemented manually: + +| Primitive | Why | Guidance | +|---|---|---| +| **VMessAEAD nested KDF** | The KDF nests HMAC-inside-HMAC (the "hash function" of an outer HMAC is itself an HMAC). `HMACSHA256` is hard-wired to SHA-256 and cannot be nested via the BCL. | Implement the RFC 2104 HMAC construction generically (block size 64, ipad `0x36`, opad `0x5C`), so the inner hash at each level can be another HMAC evaluation. Then layer per §2. | +| **FNV-1a-32** | No BCL type. | Trivial: `uint hash = 2166136261; foreach(b) { hash ^= b; hash *= 16777619; }` then write big-endian. | +| **CRC-32/IEEE** (AuthID) | Not in `System.Security.Cryptography`. | `System.IO.Hashing.Crc32` exists but is an **out-of-band NuGet package** (`System.IO.Hashing`), not part of the base runtime — flag this dependency. It computes CRC-32/IEEE and matches `crc32.ChecksumIEEE`; **verify its output byte order** (the type's `GetCurrentHash`/`GetHashAndReset` emits **little-endian** bytes per its docs, whereas VMess needs the value **big-endian** — reverse or re-serialize accordingly). A ~15-line table-based inline implementation avoids the dependency entirely and is easy to get right. | + +> ChaCha20-Poly1305 (for `SecurityType 4`) is only needed for the **body** cipher, not +> the request header (the header envelope is always AES-128-GCM). `ChaCha20Poly1305` +> exists in the BCL (net7+) but availability is platform-dependent +> (`ChaCha20Poly1305.IsSupported`). Out of scope here. + +--- + +## 8. Worked layout summary (example values only — no real UUID) + +Using synthetic placeholders (do NOT use as test vectors — these are illustrative): + +- UUID (example): `b831381d-6324-4d53-ad4f-8cda48b30811` → `uuid16` = its 16 RFC 4122 + big-endian bytes → `cmdKey = MD5(uuid16 ‖ magic)` (16 bytes). +- Suppose the resolved target is IPv4 `93.184.216.34:443`, TCP, AES-128-GCM, + `paddingLen = 5`. + +Plaintext command section (`data`): + +``` +01 version +<16 bytes> requestBodyIV +<16 bytes> requestBodyKey +<1 byte> responseHeader (respV) +1D option (S|M|P|A) — config-dependent +53 (paddingLen=5)<<4 | security=3(GCM) = 0x53 +00 reserved +01 command = TCP +01 BB port 443, big-endian +01 address type = IPv4 +5D B8 D8 22 address 93.184.216.34 +<5 random bytes> random padding (paddingLen=5) +<4 bytes> FNV-1a-32(all bytes above), big-endian +``` + +Then the wire header = `authid(16) ‖ encLen(18) ‖ connNonce(8) ‖ encHeader(len(data)+16)`. + +--- + +## Verification status + +Every constant, label, offset, and endianness above was read from the raw Go source of +`v2fly/v2ray-core@master` and cross-checked against `XTLS/Xray-core@main` for the AEAD +constants (identical). Items a reader should still re-confirm against source or a live +capture before shipping are listed in the accompanying summary. diff --git a/docs/vmess.md b/docs/vmess.md new file mode 100644 index 0000000..4222cb3 --- /dev/null +++ b/docs/vmess.md @@ -0,0 +1,263 @@ +# VMess + +VMess - оригинальный зашифрованный протокол V2Ray. В отличие от VLESS, VMess +не является маленьким CONNECT header-ом: он включает аутентификацию, +шифрование header-а и шифрование/маскирование тела. Для QuickProxyNet это +значит, что после handshake нельзя просто вернуть исходный `NetworkStream`: +нужен stream-wrapper, который шифрует `Write` и расшифровывает `Read`. + +## Стек + +Типичные варианты: + +```text +TCP -> VMess encrypted stream +TCP -> TLS -> VMess encrypted stream +TCP -> WebSocket -> VMess +TCP -> HTTP/2/gRPC -> VMess +``` + +VMess зависит от времени: клиент и сервер должны иметь близкое UTC-время, +потому что timestamp участвует в защите от replay. + +## URI + +Классическая share-ссылка часто выглядит как base64-encoded JSON: + +```text +vmess://base64({ + "v": "2", + "ps": "name", + "add": "server.example.com", + "port": "443", + "id": "uuid", + "aid": "0", + "scy": "auto", + "net": "tcp", + "type": "none", + "host": "", + "path": "", + "tls": "tls", + "sni": "example.com" +}) +``` + +Также встречаются URI-форматы, но JSON-base64 все еще широко распространен. + +Поля: + +| Поле | Значение | +| --- | --- | +| `add` | сервер | +| `port` | порт | +| `id` | UUID пользователя | +| `aid` / `alterId` | legacy параметр; современный AEAD обычно использует `0` | +| `scy` / `security` | шифрование тела: `auto`, `aes-128-gcm`, `chacha20-poly1305`, `none` в некоторых клиентах | +| `net` | `tcp`, `ws`, `grpc`, `h2`, etc. | +| `tls` | `tls`, пусто, иногда `reality` в Xray-экосистеме | +| `sni` | SNI для TLS | +| `host`, `path` | transport-specific параметры | + +## Header authentication + +Официальная V2Fly developer-документация разделяет два режима: + +- AEAD authentication - современный вариант, обеспечивает целостность header-а. +- MD5 authentication - legacy вариант через MD5 + AES-128-CFB; deprecated. + +Для новой реализации надо ориентироваться на AEAD и не начинать с legacy MD5, +если не нужна совместимость со старыми серверами. + +## Request structure на уровне полей + +VMess request асимметричен: client request и server response имеют разные +форматы. Wire bytes зависят от режима authentication/encryption, поэтому +ниже структура полей до шифрования/оберток: + +Логически запрос состоит из: + +```text +Authentication Info +Command Section +Data Section +``` + +В современном AEAD-формате request выглядит так: + +```text ++---------------------+---------+-------+---------+--------------+ +| Authentication Info | ALength | Nonce | AHeader | Data Section | ++---------------------+---------+-------+---------+--------------+ +| 16 | 18 | 8 | var | var | ++---------------------+---------+-------+---------+--------------+ +``` + +`Authentication Info` на plaintext-уровне строится из timestamp, random bytes +и CRC32, затем шифруется/аутентифицируется. Поэтому VMess чувствителен к +синхронизации времени. + +```text +Auth / encrypted header: + version + user credential / UUID-derived auth + timestamp / nonce + request body key + request body IV + response header byte + options + padding/security + command + destination port + destination address type + address + random padding + checksum / AEAD tag + +Encrypted body: + length/security framing + encrypted payload chunks +``` + +Команды близки к другим V2Ray protocol structs: + +```text +01 = TCP +02 = UDP +03 = Mux +``` + +Destination обычно несет port/address для target, но конкретный byte layout +надо брать из выбранной VMess AEAD реализации, потому что header завернут в +AEAD-authenticated envelope. + +Command Section до защиты содержит: + +| Поле | Размер | Назначение | +| --- | ---: | --- | +| `Version` | 1 | protocol version, обычно `1` | +| `Request Encryption IV` | 16 | IV для payload | +| `Request Encryption Key` | 16 | ключ для payload | +| `Response Auth V` | 1 | байт, который должен вернуться в response | +| `Option` | 1 | bit flags | +| `Margin P` | 4 бита | размер random padding | +| `Encryption Sec` | 4 бита | режим шифрования data section | +| `Reserved` | 1 | должен быть `0` | +| `Command Cmd` | 1 | TCP/UDP | +| `Port` | 2 | target port, big-endian | +| `Address Type` | 1 | IPv4/domain/IPv6 | +| `Address` | variable | target address | +| `Random` | `P` | padding | +| `Checksum` | 4 | FNV1a от command section без checksum | + +Command values: + +```text +01 = TCP data +02 = UDP data +``` + +Address type: + +```text +01 = IPv4 +02 = domain name +03 = IPv6 +``` + +Domain address: `1 byte length + domain bytes`. + +Option flags: + +| Flag | Смысл | +| --- | --- | +| `S` | standard chunked data stream, обычно включен | +| `R` | reuse TCP connection, deprecated | +| `M` | metadata obfuscation | +| `P` | global padding | +| `A` | authenticated packet length experiment | +| `X` | reserved | + +`R`, `M`, `P`, `A` зависят от `S` и поддержки конкретной реализации. + +## Security тела + +Распространенные варианты: + +| Значение | Смысл | +| --- | --- | +| `aes-128-gcm` | AEAD encryption тела | +| `chacha20-poly1305` | AEAD encryption тела | +| `auto` | клиент/реализация выбирает подходящий cipher | +| `none` | встречается в конфиг-экосистемах, но не делает VMess таким же простым как VLESS | +| `zero` | raw stream copy для payload, но header/auth остаются | +| `aes-128-ctr` | legacy compatibility в некоторых реализациях | + +Даже если body security отключена/упрощена, VMess header authentication остается +существенно сложнее VLESS. + +`alterId` сейчас legacy: + +```text +alterId = 0 -> VMessAEAD +alterId = 1+ -> legacy compatibility +``` + +Для новой реализации разумный baseline: canonical UUID, `alterId = 0`, AEAD, +`aes-128-gcm` или `chacha20-poly1305`. + +## Transport/security режимы + +| Слой | Для `Stream` | +| --- | --- | +| `net=tcp`, без TLS | Нужен `VmessaeadStream`, который шифрует/дешифрует body | +| `net=tcp`, `tls=tls` | `SslStream` снаружи + VMess stream-wrapper внутри | +| WebSocket | нужен WebSocket stream adapter + VMess wrapper | +| gRPC/H2 | нужен HTTP/2/gRPC transport adapter | +| REALITY | те же проблемы, что у VLESS REALITY, плюс VMess | + +В Xray transport/security разделены: transport methods (`raw`, `websocket`, +`grpc`, `httpupgrade`, `xhttp`, etc.) и transport security (`none`, `tls`, +`reality`). В sing-box похожие части вынесены в `tls`, `transport`, +`packet_encoding` и `network`. + +## Практическая оценка для QuickProxyNet + +VMess сложнее Trojan и VLESS: + +- нужен AEAD KDF/authentication; +- нужен body framing; +- нужен replay/time behavior; +- нужен encrypted stream wrapper; +- много legacy-совместимости: alterId, MD5 auth, разные `security` значения. +- `zero` упрощает только payload, но не убирает VMess header/auth state machine. +- UDP нельзя честно выразить одним `Stream`; нужен packet/datagram API. + +Если цель - быстро получить TCP `Stream` для Minecraft, VMess не лучший первый +кандидат. Лучше сначала VLESS none/TLS и Trojan. VMess стоит добавлять только +после решения, какую совместимость поддерживать: modern AEAD-only или legacy +тоже. + +Для C# рабочая модель выглядит так: + +```text +Socket/NetworkStream +optional SslStream +optional transport adapter +VMess protocol adapter +Stream/PipeReader/PipeWriter для payload +``` + +Самая большая сложность - совместить time-based auth, AEAD/legacy режимы, +chunk framing, half-close/cancellation semantics и совместимость с выбранным +core. + +## Источники + +- V2Fly VMess developer protocol docs: https://www.v2fly.org/en_US/developer/protocols/vmess.html +- Project X VMess protocol docs: https://xtls.github.io/en/development/protocols/vmess.html +- V2Fly VMess config docs: https://www.v2fly.org/en_US/v5/config/proxy/vmess.html +- Xray VMess inbound docs: https://xtls.github.io/en/config/inbounds/vmess.html +- Xray VMess outbound docs: https://xtls.github.io/en/config/outbounds/vmess.html +- Xray transport docs: https://xtls.github.io/en/config/transport.html +- sing-box VMess outbound: https://sing-box.sagernet.org/configuration/outbound/vmess/ +- sing-box V2Ray transport: https://sing-box.sagernet.org/configuration/shared/v2ray-transport/ +- V2Fly VMess AEAD source: https://github.com/v2fly/v2ray-core/tree/master/proxy/vmess/aead diff --git a/tests/docker/README.md b/tests/docker/README.md new file mode 100644 index 0000000..f886bd4 --- /dev/null +++ b/tests/docker/README.md @@ -0,0 +1,141 @@ +# Docker integration servers + +Real Xray-core and sing-box servers for `QuickProxyNet.Tests/Integration/DockerProtocolTests.cs`. + +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. + +Two implementations are here on purpose: they disagree about what they tolerate, so one alone +would silently bless a bug the other rejects. + +## Running + +The tests bring the stack up and tear it down themselves. Enable them with: + +```bash +QPN_DOCKER_TESTS=1 dotnet test QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +``` + +Without `QPN_DOCKER_TESTS=1` every case reports as **skipped** — never as passed. + +Manually, always with the fixed project name: + +```bash +docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml up -d --wait +docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml down -v +``` + +If a run is interrupted, the `down -v` above is the cleanup. After a completed run `docker ps` +must be empty. + +The stack is a **machine-global singleton** — one fixed project name, one fixed set of host +ports — so only one test process can own it at a time. Since the test project multi-targets, +`dotnet test` runs the TFMs concurrently, and `DockerComposeFixture` serializes them on a lock +file (`%TEMP%/quickproxynet-docker-compose.lock`): each run brings the stack up, uses it and +tears it down before the next starts. If a process is killed hard and a later run reports the +lock as held for over ten minutes, delete that file. + +## Images + +All three are expected to be present locally; nothing here builds an image. + +| Image | Role | +| --- | --- | +| `ghcr.io/xtls/xray-core:latest` | Xray inbounds (verified against 26.3.27; `freedom.finalRules: allow` is required from 26.x, which otherwise blackholes the private `echo` target) | +| `ghcr.io/sagernet/sing-box:latest` | sing-box inbounds (verified against 1.13.14) | +| `alpine:3.20` | HTTP echo target | + +## Host port map + +Container ports are `10001..10010`; the host ports differ per server so both can run at once. + +| Host port | Server | Inbound | Credential | +| --- | --- | --- | --- | +| 24801 | xray | vless, `security=none` | `11111111-1111-4111-8111-111111111111` | +| 24802 | xray | vless, `security=tls` | `22222222-2222-4222-8222-222222222222` | +| 24803 | xray | trojan (always TLS) | `qpn-test-trojan-password` | +| 24804 | xray | vmess, driven with `aes-128-gcm` | `33333333-3333-4333-8333-333333333333` | +| 24805 | xray | vmess, driven with `chacha20-poly1305` | `44444444-4444-4444-8444-444444444444` | +| 24806 | xray | vless, `security=none`, **non-UUID id** | `not-a-uuid` | +| 24807 | xray | vless over `ws`, path `/qpn-ws` | `55555555-5555-4555-8555-555555555555` | +| 24808 | xray | vmess over `ws`, path `/qpn-vmess-ws` | `66666666-6666-4666-8666-666666666666` | +| 24809 | xray | trojan over `ws` + TLS, path `/qpn-trojan-ws` | `qpn-test-trojan-password` | +| 24810 | xray | vless over `httpupgrade`, path `/qpn-hu` | `77777777-7777-4777-8777-777777777777` | +| 24811 | sing-box | vless, `security=none` | `11111111-1111-4111-8111-111111111111` | +| 24812 | sing-box | vless, `security=tls` | `22222222-2222-4222-8222-222222222222` | +| 24813 | sing-box | trojan (always TLS) | `qpn-test-trojan-password` | +| 24814 | sing-box | vmess, driven with `aes-128-gcm` | `33333333-3333-4333-8333-333333333333` | +| 24815 | sing-box | vmess, driven with `chacha20-poly1305` | `44444444-4444-4444-8444-444444444444` | +| 24817 | sing-box | vless over `ws`, path `/qpn-ws` | `55555555-5555-4555-8555-555555555555` | +| 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 +`QuickProxyNet.Tests/Integration/DockerEndpoints.cs`; keep the two in sync. + +### The two VMess ports + +VMess picks its body cipher **client-side**: the cipher is the security nibble of the request +header, and neither Xray nor sing-box lets a `vmess` inbound restrict it. So ports 24804/24814 and +24805/24815 are server-side identical; what differs is the `VmessSecurityKind` the test drives +them with. They are kept separate so a failure names the cipher directly. + +### The non-UUID port + +Port 24806 is configured with the literal id `not-a-uuid`. Xray's `common/uuid.ParseString` maps +any id of length 1..30 to `UUIDv5(nil-namespace, utf8(id))`, and `UuidCodec` mirrors that. The +VLESS id is compared byte for byte on the server, so `Vless_NonUuidId_DerivesSameIdAsXray` +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 echo target + +`alpine:3.20` running `nc -lk -p 8080 -e /bin/sh /echo/serve.sh`. Alpine's busybox has no `httpd` +applet (it lives in `busybox-extras`), but `nc -lk … -e PROG` is a persistent accept loop that +execs `PROG` per connection — a real server, with no gap between connections and no extra image +pull. `serve.sh` drains the request and answers: + +``` +HTTP/1.1 200 OK +Content-Type: text/plain +Content-Length: 11 +Connection: close + +QPN-ECHO-OK +``` + +It is reachable only from inside the compose network, as `echo:8080`. Tests target it by that DNS +name, which also exercises each protocol's **domain** address type rather than the IPv4 one. + +`serve.sh` reads the request before replying: replying first lets `nc` close the socket while the +client is still sending, which surfaces on Windows as an RST that discards the queued response. + +## TLS certificate + +`certs/server.crt` + `certs/server.key` are a self-signed keypair used by the `vless security=tls` +and `trojan` inbounds. **Committing them is correct and intended** — they are our test data, not a +third-party secret, and the private key protects nothing. + +Regenerate with: + +```bash +cd tests/docker/certs +openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 36500 \ + -keyout server.key -out server.crt \ + -subj "/CN=QuickProxyNet Test" \ + -addext "subjectAltName=DNS:localhost,DNS:xray,DNS:singbox,DNS:qpn.test,IP:127.0.0.1" +``` + +Tests connect to `127.0.0.1` with SNI `qpn.test` and accept the certificate through +`ServerCertificateValidationCallback`, which checks the subject CN is `QuickProxyNet Test`. +That is deliberately *not* accept-anything: the TLS cases are supposed to prove a real TLS +session with our server took place. `AllowInsecure` is left `false` for the same reason. + +## Line endings + +`.gitattributes` pins `tests/docker/**` to `eol=lf`. A CRLF checkout breaks `serve.sh` inside the +container. diff --git a/tests/docker/certs/server.crt b/tests/docker/certs/server.crt new file mode 100644 index 0000000..c7e3d62 --- /dev/null +++ b/tests/docker/certs/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVDCCAjygAwIBAgIUYKAMQFhvT+xK5frTTPZRpYHQdRwwDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSUXVpY2tQcm94eU5ldCBUZXN0MCAXDTI2MDcyNDA3MTEz +MVoYDzIxMjYwNjMwMDcxMTMxWjAdMRswGQYDVQQDDBJRdWlja1Byb3h5TmV0IFRl +c3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCXAf2Sts+pU9Lt5RGf +ExCYeS/UCTSVxkfydZiez/N+OC7qFijdhfb+MWxvpfOr+iQGobLvTgCOgv6m2htC +r01QhIPUCcMdt/Nu8sbUXO3KZ/Xxy/Q5lOmodK0i3INQJvDbbMzTqVJkKGyf5cYJ +iM0UVwZLtapb2/TpkDroVu+gkwXd9p0upVlY+OBsq9PTwvRvU5kiSoW6pU7a/PFx +rXniiD4XooK9RhV1pWdHkPBg8uaLxQeJQUlkDmmQFwEUKx/O16sdTwpQqNuf9tHK +TGARwKOX3ZhyR+EkIm5vEFMT/6/ju92gF97e+duw1Pbppxxs7yLFV7D7MAPa0qpC +z1zzAgMBAAGjgYkwgYYwHQYDVR0OBBYEFI3KXSzvF2zrdy3KTAuDdrtuV7OMMB8G +A1UdIwQYMBaAFI3KXSzvF2zrdy3KTAuDdrtuV7OMMA8GA1UdEwEB/wQFMAMBAf8w +MwYDVR0RBCwwKoIJbG9jYWxob3N0ggR4cmF5ggdzaW5nYm94gghxcG4udGVzdIcE +fwAAATANBgkqhkiG9w0BAQsFAAOCAQEAcJORhKAXbCxm64GwHcIb1Mn2ut98iW2K +BzBqBwpz82E97ALrhILXw2l2E05PVdkepX829WXInSADvk7M1jeC0RmPZo4BhznN +wesi6u2EnnoBDalvc+SKtcrhoJcNJS0zhVoVHiMDakHBskByZwpzsqZDHMJUlvce +Je+J2dzy+x5jVJTMm1eEhknKQdkwCE0+PPyQqF+76xkWoOfXaHhdxoubz3ojcyw6 +rsNW4M0N2bqW9wB03BHIpBsNUs1Y7JoFsSRhGIgzVWMOuIjfmmZMj9SItUiFU7+L +XALFoJ9/atYqjHlSPog/S0Nu8B7Zve/BZm9rZrqYo8SJo4lujS9XSQ== +-----END CERTIFICATE----- diff --git a/tests/docker/certs/server.key b/tests/docker/certs/server.key new file mode 100644 index 0000000..6b83d2c --- /dev/null +++ b/tests/docker/certs/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXAf2Sts+pU9Lt +5RGfExCYeS/UCTSVxkfydZiez/N+OC7qFijdhfb+MWxvpfOr+iQGobLvTgCOgv6m +2htCr01QhIPUCcMdt/Nu8sbUXO3KZ/Xxy/Q5lOmodK0i3INQJvDbbMzTqVJkKGyf +5cYJiM0UVwZLtapb2/TpkDroVu+gkwXd9p0upVlY+OBsq9PTwvRvU5kiSoW6pU7a +/PFxrXniiD4XooK9RhV1pWdHkPBg8uaLxQeJQUlkDmmQFwEUKx/O16sdTwpQqNuf +9tHKTGARwKOX3ZhyR+EkIm5vEFMT/6/ju92gF97e+duw1Pbppxxs7yLFV7D7MAPa +0qpCz1zzAgMBAAECggEAIA3onTWYFBn6iswWtv0thygmWmyP0IEz8Yg72u5Kgg78 +Cm2kxA01VlT4byS7elSRRCUb3gdhP98XniRrJ/cdQsu2ThK6a7sJ/hGj2h1VzqLN +xsNj9rsg9ES8IcvMRf3WH8XTHtTw69jW4gQ8yvZSjhBBAl4wKOqibNfUYVBe8mcf +mRzzI21PmNwFdFTtGB7DlndMvu6CmkNSoLZTT7Krq93xEGvcPghL4/lr+DXU7BYc +hNpd++rB1vOWGnR48Q1KNQ4aj98QwrediZ8ld2haJjNbIOQNHqLmpLPEAdQ73H4Q +jma6BLT6x3rcPsjGdITjBYkpf5vluCM19sis9AjclQKBgQDIg5tA7aS+bMshxkwZ +Naz7pDrq1BO7e6k+70g1oFi2AkISrrgm1iYmK5aOVfSRjqKP4Afa4OuZLNK0aaDG +DSdCnRV6wrK09NM8k4un8dLUx52w0zY/bYGl/j+WcWhgy9cZV9V+txGYJSGJHMob +CrRhCB+1X4cjzW/xyIxUS+SyhQKBgQDAy1zpJGgR+LCdQL3dsOLY3urf0jdjMIf7 +73qxdPi6z/fCfjxRlM1wlhSNmB27+77SjdZ2O57Gyo1Y5mmB6V/VKivS2oe2JfSf +wKwjssW34s8X5iIGVbcghLHyFUxYxJN8EQrrLlhUy4k3ndwKJ+M35OwBTJQDDWaZ +FlbKxIH3FwKBgB3l4cRwqtvqBO/oTXiE1GJBPre9H6QY8Ed+DlpQqmmZNJjsjHDe +BZozbaOTlYAOsJabZRBx8S9Jy7Ey/tIJLA12trkzRspMpyKlLXHBURqBGTZAiBo1 +DdveaUTZbCLiwhP5UNAwI+N3xeRX8prNoc/GElRNBi2EeGio6qO3HUaxAoGBAL+E +hWx4pQVNRa1BFht2zzJe53WmLy1SlZNYx0onh7qUQ2wq2KK2Lgrsm8g41zjZkSs6 +iVP0T/rsVdN9OEw8V926wcP5IB16wPI9hQMFYVIVdmIoU551YbBlARwZujjoNhZm +G7Ga3VaGxm3AXEiebSImP6feuZ36nvPudBODeBPLAoGAZtNlF0azO1jNjfXtMsEY +9kr+z1teTvACdPJsVyBTAGiSr39ZYKYIyxMDhG0h/16ZTwhrfdyUVDN1J8jIIsHh +EwGWGHyuHapksJ7RCW3Xog1ujPftz6g4J+RVrJE6IoYlG+33W14kdkDyKKf0asXX +F0b5XFzDUrBVkj4e+bRHOfQ= +-----END PRIVATE KEY----- diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml new file mode 100644 index 0000000..e6b61e1 --- /dev/null +++ b/tests/docker/docker-compose.yml @@ -0,0 +1,80 @@ +# Integration-test servers for QuickProxyNet's VLESS / Trojan / VMess clients. +# +# ALWAYS drive this file with the fixed project name so nothing is orphaned: +# +# docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml up -d +# docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml down -v +# +# See README.md in this directory for the host-port map and the credentials. + +services: + # Plain HTTP target the proxies forward to. alpine's busybox has no `httpd` + # applet, but `nc -lk ... -e PROG` is a persistent server that execs PROG per + # connection — a real accept loop, no gap between connections, no extra image. + echo: + image: alpine:3.20 + container_name: quickproxynet-test-echo + # /bin/sh is named explicitly so the bind-mounted script does not need the + # executable bit (which does not survive a Windows checkout). + command: ["nc", "-lk", "-p", "8080", "-e", "/bin/sh", "/echo/serve.sh"] + volumes: + - ./echo/serve.sh:/echo/serve.sh:ro + networks: + - qpn + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "-T", "2", "http://127.0.0.1:8080/"] + interval: 1s + timeout: 3s + retries: 30 + start_period: 2s + + xray: + image: ghcr.io/xtls/xray-core:latest + container_name: quickproxynet-test-xray + command: ["-config", "/etc/xray/config.json"] + volumes: + - ./xray/config.json:/etc/xray/config.json:ro + - ./certs:/certs:ro + ports: + - "24801:10001" # vless, security=none + - "24802:10002" # vless, security=tls + - "24803:10003" # trojan (tls) + - "24804:10004" # vmess, driven with aes-128-gcm + - "24805:10005" # vmess, driven with chacha20-poly1305 + - "24806:10006" # vless, security=none, NON-UUID user id "not-a-uuid" + - "24807:10007" # vless over ws, path /qpn-ws + - "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 + depends_on: + echo: + condition: service_healthy + networks: + - qpn + + singbox: + image: ghcr.io/sagernet/sing-box:latest + container_name: quickproxynet-test-singbox + command: ["-c", "/etc/sing-box/config.json", "run"] + volumes: + - ./singbox/config.json:/etc/sing-box/config.json:ro + - ./certs:/certs:ro + ports: + - "24811:10001" # vless, security=none + - "24812:10002" # vless, security=tls + - "24813:10003" # trojan (tls) + - "24814:10004" # vmess, driven with aes-128-gcm + - "24815:10005" # vmess, driven with chacha20-poly1305 + - "24817:10007" # vless over ws, path /qpn-ws + - "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 + depends_on: + echo: + condition: service_healthy + networks: + - qpn + +networks: + qpn: + driver: bridge diff --git a/tests/docker/echo/serve.sh b/tests/docker/echo/serve.sh new file mode 100644 index 0000000..fdc0778 --- /dev/null +++ b/tests/docker/echo/serve.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# One HTTP transaction. busybox `nc -lk -p 8080 -e /echo/serve.sh` execs a fresh +# copy of this per inbound connection, with the socket on stdin/stdout. +# +# The request is drained before the response is written: replying without reading +# would let nc close the socket while the client is still sending, which shows up +# on Windows as an RST that discards the already-queued response. +CR=$(printf '\r') +while IFS= read -r line; do + case "$line" in + "" | "$CR") break ;; + esac +done + +BODY='QPN-ECHO-OK' +printf 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' \ + "${#BODY}" "$BODY" diff --git a/tests/docker/singbox/config.json b/tests/docker/singbox/config.json new file mode 100644 index 0000000..e45183d --- /dev/null +++ b/tests/docker/singbox/config.json @@ -0,0 +1,159 @@ +{ + "log": { + "level": "warn", + "timestamp": true + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-none", + "listen": "0.0.0.0", + "listen_port": 10001, + "users": [ + { + "name": "qpn", + "uuid": "11111111-1111-4111-8111-111111111111" + } + ] + }, + { + "type": "vless", + "tag": "vless-tls", + "listen": "0.0.0.0", + "listen_port": 10002, + "users": [ + { + "name": "qpn", + "uuid": "22222222-2222-4222-8222-222222222222" + } + ], + "tls": { + "enabled": true, + "server_name": "qpn.test", + "certificate_path": "/certs/server.crt", + "key_path": "/certs/server.key" + } + }, + { + "type": "trojan", + "tag": "trojan", + "listen": "0.0.0.0", + "listen_port": 10003, + "users": [ + { + "name": "qpn", + "password": "qpn-test-trojan-password" + } + ], + "tls": { + "enabled": true, + "server_name": "qpn.test", + "certificate_path": "/certs/server.crt", + "key_path": "/certs/server.key" + } + }, + { + "type": "vmess", + "tag": "vmess-aes", + "listen": "0.0.0.0", + "listen_port": 10004, + "users": [ + { + "name": "qpn", + "uuid": "33333333-3333-4333-8333-333333333333", + "alterId": 0 + } + ] + }, + { + "type": "vmess", + "tag": "vmess-chacha", + "listen": "0.0.0.0", + "listen_port": 10005, + "users": [ + { + "name": "qpn", + "uuid": "44444444-4444-4444-8444-444444444444", + "alterId": 0 + } + ] + }, + { + "type": "vless", + "tag": "vless-ws", + "listen": "0.0.0.0", + "listen_port": 10007, + "users": [ + { + "name": "qpn", + "uuid": "55555555-5555-4555-8555-555555555555" + } + ], + "transport": { + "type": "ws", + "path": "/qpn-ws" + } + }, + { + "type": "vmess", + "tag": "vmess-ws", + "listen": "0.0.0.0", + "listen_port": 10008, + "users": [ + { + "name": "qpn", + "uuid": "66666666-6666-4666-8666-666666666666", + "alterId": 0 + } + ], + "transport": { + "type": "ws", + "path": "/qpn-vmess-ws" + } + }, + { + "type": "trojan", + "tag": "trojan-ws", + "listen": "0.0.0.0", + "listen_port": 10009, + "users": [ + { + "name": "qpn", + "password": "qpn-test-trojan-password" + } + ], + "tls": { + "enabled": true, + "server_name": "qpn.test", + "certificate_path": "/certs/server.crt", + "key_path": "/certs/server.key" + }, + "transport": { + "type": "ws", + "path": "/qpn-trojan-ws" + } + }, + { + "type": "vless", + "tag": "vless-httpupgrade", + "listen": "0.0.0.0", + "listen_port": 10010, + "users": [ + { + "name": "qpn", + "uuid": "77777777-7777-4777-8777-777777777777" + } + ], + "transport": { + "type": "httpupgrade", + "path": "/qpn-hu" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ] +} diff --git a/tests/docker/xray/config.json b/tests/docker/xray/config.json new file mode 100644 index 0000000..2c9de3a --- /dev/null +++ b/tests/docker/xray/config.json @@ -0,0 +1,237 @@ +{ + "log": { + "loglevel": "warning" + }, + "inbounds": [ + { + "tag": "vless-none", + "listen": "0.0.0.0", + "port": 10001, + "protocol": "vless", + "settings": { + "clients": [ + { + "id": "11111111-1111-4111-8111-111111111111" + } + ], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + }, + { + "tag": "vless-tls", + "listen": "0.0.0.0", + "port": 10002, + "protocol": "vless", + "settings": { + "clients": [ + { + "id": "22222222-2222-4222-8222-222222222222" + } + ], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "tls", + "tlsSettings": { + "serverName": "qpn.test", + "certificates": [ + { + "certificateFile": "/certs/server.crt", + "keyFile": "/certs/server.key" + } + ] + } + } + }, + { + "tag": "trojan", + "listen": "0.0.0.0", + "port": 10003, + "protocol": "trojan", + "settings": { + "clients": [ + { + "password": "qpn-test-trojan-password" + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "tls", + "tlsSettings": { + "serverName": "qpn.test", + "certificates": [ + { + "certificateFile": "/certs/server.crt", + "keyFile": "/certs/server.key" + } + ] + } + } + }, + { + "tag": "vmess-aes", + "listen": "0.0.0.0", + "port": 10004, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "33333333-3333-4333-8333-333333333333", + "alterId": 0 + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + }, + { + "tag": "vmess-chacha", + "listen": "0.0.0.0", + "port": 10005, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "44444444-4444-4444-8444-444444444444", + "alterId": 0 + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + }, + { + "tag": "vless-ws", + "listen": "0.0.0.0", + "port": 10007, + "protocol": "vless", + "settings": { + "clients": [ + { + "id": "55555555-5555-4555-8555-555555555555" + } + ], + "decryption": "none" + }, + "streamSettings": { + "network": "ws", + "security": "none", + "wsSettings": { + "path": "/qpn-ws" + } + } + }, + { + "tag": "vmess-ws", + "listen": "0.0.0.0", + "port": 10008, + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "66666666-6666-4666-8666-666666666666", + "alterId": 0 + } + ] + }, + "streamSettings": { + "network": "ws", + "security": "none", + "wsSettings": { + "path": "/qpn-vmess-ws" + } + } + }, + { + "tag": "trojan-ws", + "listen": "0.0.0.0", + "port": 10009, + "protocol": "trojan", + "settings": { + "clients": [ + { + "password": "qpn-test-trojan-password" + } + ] + }, + "streamSettings": { + "network": "ws", + "security": "tls", + "wsSettings": { + "path": "/qpn-trojan-ws" + }, + "tlsSettings": { + "serverName": "qpn.test", + "certificates": [ + { + "certificateFile": "/certs/server.crt", + "keyFile": "/certs/server.key" + } + ] + } + } + }, + { + "tag": "vless-httpupgrade", + "listen": "0.0.0.0", + "port": 10010, + "protocol": "vless", + "settings": { + "clients": [ + { + "id": "77777777-7777-4777-8777-777777777777" + } + ], + "decryption": "none" + }, + "streamSettings": { + "network": "httpupgrade", + "security": "none", + "httpupgradeSettings": { + "path": "/qpn-hu" + } + } + }, + { + "tag": "vless-nonuuid", + "listen": "0.0.0.0", + "port": 10006, + "protocol": "vless", + "settings": { + "clients": [ + { + "id": "not-a-uuid" + } + ], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "none" + } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom", + "settings": { + "finalRules": [ + { + "action": "allow" + } + ] + } + } + ] +} diff --git a/tools/CorpusCheck/CorpusCheck.csproj b/tools/CorpusCheck/CorpusCheck.csproj new file mode 100644 index 0000000..a56cf83 --- /dev/null +++ b/tools/CorpusCheck/CorpusCheck.csproj @@ -0,0 +1,24 @@ + + + + + Exe + net10.0 + enable + enable + latest + + false + + + + + + + diff --git a/tools/CorpusCheck/LiveProbe.cs b/tools/CorpusCheck/LiveProbe.cs new file mode 100644 index 0000000..a2e6e6f --- /dev/null +++ b/tools/CorpusCheck/LiveProbe.cs @@ -0,0 +1,569 @@ +// CorpusCheck --live — MANUAL diagnostic only. +// +// This mode opens real connections to a small SAMPLE of third-party nodes taken from the +// "Checked" corpus (nodes the upstream list has already probed for reachability), tunnels +// one HTTP request to a neutral connectivity endpoint through each of them, and reports +// whether OUR client could complete the exchange. +// +// Deliberate limits, because this touches other people's infrastructure: +// +// * It is NEVER run by CI or by the test suite. tools/CorpusCheck is not in +// QuickProxyNet.slnx, and Main refuses to run --live when a CI environment variable is +// present. +// * It is a SAMPLE (default 30, hard-capped at MaxSampleSize), never a sweep of the whole +// ~11k-line list. Nodes are de-duplicated by endpoint, so one server is touched once. +// * Connections are SEQUENTIAL with a short per-node timeout (default 5s). One request, +// one response, then the tunnel is closed. This is a client-correctness check, not a +// scan and not a throughput test. +// * Only configurations this library can actually speak are sampled (raw tcp transport, +// security none/tls, no REALITY, no XTLS flow, no ws/grpc/xhttp). Dialling a node we +// are guaranteed to reject teaches nothing about the client. +// +// Redaction rules are the same as the parse report and are non-negotiable: these are real +// servers with real credentials belonging to other people. Group keys come from a fixed +// taxonomy (never a raw exception message, which would embed host:port), and every example +// is a REDACTED SHAPE produced by Redactor. No response byte is ever printed. + +using System.Diagnostics; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; +using QuickProxyNet; + +namespace CorpusCheck; + +/// One sampled node: everything the probe needs, plus the values it must scrub. +internal sealed record LiveNode( + string Protocol, + string Mode, + string Shape, + string Host, + int Port, + IReadOnlyList Secrets, + Func CreateClient); + +/// Outcome of one probe: either a success mode or a taxonomy failure reason. +internal readonly record struct LiveResult(bool Ok, string Detail, TimeSpan Elapsed); + +/// +/// Picks a small, protocol-balanced sample of nodes this library can actually dial. +/// +internal static class LiveSampler +{ + /// Protocols sampled, in round-robin order. + private static readonly string[] Order = ["vless", "trojan", "vmess"]; + + public static LiveSample Build(string corpusPath, int count, int seed) + { + var pools = new Dictionary(StringComparer.Ordinal) + { + ["vless"] = new(), + ["trojan"] = new(), + ["vmess"] = new() + }; + + // One server, one probe: several share links often point at the same endpoint. + var seenEndpoints = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string rawLine in File.ReadLines(corpusPath)) + { + string line = rawLine.Trim(); + if (line.Length == 0) + continue; + + if (line.StartsWith("vless://", StringComparison.OrdinalIgnoreCase)) + ConsiderVless(pools["vless"], line, seenEndpoints); + else if (line.StartsWith("trojan://", StringComparison.OrdinalIgnoreCase)) + ConsiderTrojan(pools["trojan"], line, seenEndpoints); + else if (line.StartsWith("vmess://", StringComparison.OrdinalIgnoreCase)) + ConsiderVmess(pools["vmess"], line, seenEndpoints); + } + + var rng = new Random(seed); + foreach (var pool in pools.Values) + Shuffle(pool.Eligible, rng); + + // Round-robin across protocols so the report says something about each, spilling + // over to whichever pools still have nodes when one runs dry. + var sample = new List(count); + var cursors = new Dictionary(StringComparer.Ordinal); + foreach (string protocol in Order) + cursors[protocol] = 0; + + bool progressed = true; + while (sample.Count < count && progressed) + { + progressed = false; + foreach (string protocol in Order) + { + if (sample.Count >= count) + break; + var pool = pools[protocol]; + int cursor = cursors[protocol]; + if (cursor >= pool.Eligible.Count) + continue; + sample.Add(pool.Eligible[cursor]); + cursors[protocol] = cursor + 1; + progressed = true; + } + } + + return new LiveSample(sample, pools); + } + + private static void ConsiderVless(Pool pool, string line, HashSet seen) + { + pool.Seen++; + + if (IsHtmlEscaped(pool, line)) + return; + + if (!VlessShareLink.TryParse(line, out var options)) + { + pool.Exclude("does not parse"); + return; + } + + string transport = options.Transport.ToLowerInvariant(); + if (!IsRawTcp(transport)) + { + pool.Exclude($"transport={transport} (not implemented)"); + return; + } + if (options.Security == VlessSecurity.Reality) + { + pool.Exclude("security=reality (not implemented)"); + return; + } + if (!string.IsNullOrEmpty(options.Flow)) + { + // A flow value is an XTLS mode name ("xtls-rprx-vision"), not a credential. + pool.Exclude($"flow={options.Flow.ToLowerInvariant()} (XTLS not implemented)"); + return; + } + if (!IsDialable(pool, options.Host, options.Port, seen)) + return; + + string security = options.Security == VlessSecurity.Tls ? "tls" : "none"; + pool.Eligible.Add(new LiveNode( + "vless", + $"security={security}", + Redactor.RedactUriLink(line), + options.Host, + options.Port, + [options.Host, options.Sni, options.Id, options.Remark], + () => new VlessClient(options))); + } + + private static void ConsiderTrojan(Pool pool, string line, HashSet seen) + { + pool.Seen++; + + if (IsHtmlEscaped(pool, line)) + return; + + if (!TrojanShareLink.TryParse(line, out var options)) + { + pool.Exclude("does not parse"); + return; + } + + string transport = options.Transport.ToLowerInvariant(); + if (!IsRawTcp(transport)) + { + pool.Exclude($"transport={transport} (not implemented)"); + return; + } + if (!IsDialable(pool, options.Host, options.Port, seen)) + return; + + pool.Eligible.Add(new LiveNode( + "trojan", + "security=tls", + Redactor.RedactUriLink(line), + options.Host, + options.Port, + [options.Host, options.Sni, options.Password, options.Remark], + () => new TrojanClient(options))); + } + + private static void ConsiderVmess(Pool pool, string line, HashSet seen) + { + pool.Seen++; + + if (IsHtmlEscaped(pool, line)) + return; + + // A non-zero alterId is rejected by the parser (legacy MD5 header), so anything that + // parses is already AEAD/alterId=0. + if (!VmessShareLink.TryParse(line, out var options)) + { + pool.Exclude("does not parse"); + return; + } + + string transport = options.Transport.ToLowerInvariant(); + if (!IsRawTcp(transport)) + { + pool.Exclude($"net={transport} (not implemented)"); + return; + } + if (!IsDialable(pool, options.Host, options.Port, seen)) + return; + + pool.Eligible.Add(new LiveNode( + "vmess", + $"tls={(options.UseTls ? "on" : "off")}", + Redactor.RedactVmessLink(line), + options.Host, + options.Port, + [options.Host, options.Sni, options.Id, options.Remark], + () => new VmessClient(options))); + } + + /// + /// Rejects share links whose query separators arrived HTML-escaped as &amp;. + /// + /// + /// The parser splits the query on '&', so such a link yields keys named + /// amp;security, amp;flow, amp;pbk … — the real mode is invisible + /// and a REALITY/XTLS node parses as security=none. The first live run sampled + /// three of these and they failed exactly as an un-speakable config would. They are + /// excluded (not silently dropped) so the sample measures the client while the count + /// keeps the parser behaviour visible in the report. + /// + private static bool IsHtmlEscaped(Pool pool, string line) + { + if (!line.Contains("&", StringComparison.OrdinalIgnoreCase)) + return false; + + pool.Exclude("HTML-escaped '&' query — real mode is unreadable, so connectability is unknown"); + return true; + } + + /// + /// Endpoint-level filters shared by all three protocols: a usable port, an IPv4-capable + /// host, and no endpoint we have already queued. + /// + private static bool IsDialable(Pool pool, string host, int port, HashSet seen) + { + if (string.IsNullOrEmpty(host) || port is <= 0 or > 65535) + { + pool.Exclude("unusable host/port"); + return false; + } + + // ProxyClient creates an AddressFamily.InterNetwork socket, so an IPv6 literal can + // never be dialled. Excluded rather than counted as a node failure. + if (host.Contains(':')) + { + pool.Exclude("IPv6 literal host (client socket is IPv4-only)"); + return false; + } + + if (!seen.Add($"{host}:{port}")) + { + pool.Exclude("duplicate endpoint"); + return false; + } + + return true; + } + + private static bool IsRawTcp(string transport) => + transport is "tcp" or "raw"; + + private static void Shuffle(List nodes, Random rng) + { + for (int i = nodes.Count - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (nodes[i], nodes[j]) = (nodes[j], nodes[i]); + } + } + + internal sealed class Pool + { + public int Seen; + public readonly List Eligible = []; + public readonly Dictionary Exclusions = new(StringComparer.Ordinal); + + public void Exclude(string reason) => + Exclusions[reason] = Exclusions.GetValueOrDefault(reason) + 1; + } +} + +/// The chosen sample plus the eligibility bookkeeping behind it. +internal sealed record LiveSample(List Nodes, Dictionary Pools); + +/// +/// Dials one node and tunnels a single HTTP request to a neutral connectivity endpoint. +/// +/// +/// The request/response round trip is the whole point: VLESS and VMess both validate their +/// response header LAZILY on the first read, so ConnectAsync returning a stream +/// proves nothing at all. A rejected handshake only surfaces once we have written a request +/// and tried to read the answer. +/// +internal sealed class LiveProber(TimeSpan timeout) +{ + // Neutral, purpose-built connectivity endpoint: answers "204 No Content" with no body. + // Plain HTTP on purpose — a second TLS layer inside the tunnel would only add a failure + // mode that says nothing about our proxy client. + public const string TargetHost = "cp.cloudflare.com"; + public const int TargetPort = 80; + public const string TargetPath = "/generate_204"; + + private static readonly byte[] Request = Encoding.ASCII.GetBytes( + $"GET {TargetPath} HTTP/1.1\r\n" + + $"Host: {TargetHost}\r\n" + + "User-Agent: QuickProxyNet-CorpusCheck/1.0\r\n" + + "Accept: */*\r\n" + + "Connection: close\r\n\r\n"); + + public async Task ProbeAsync(LiveNode node, CancellationToken cancellationToken) + { + var sw = Stopwatch.StartNew(); + string phase = "connect"; + Stream? stream = null; + + 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. + stream = await client.ConnectAsync(TargetHost, TargetPort, timeout, cancellationToken) + .AsTask() + .WaitAsync(timeout + TimeSpan.FromSeconds(3), cancellationToken) + .ConfigureAwait(false); + + phase = "request"; + await stream.WriteAsync(Request, cancellationToken) + .AsTask().WaitAsync(Remaining(sw), cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken) + .WaitAsync(Remaining(sw), cancellationToken).ConfigureAwait(false); + + phase = "response"; + var buffer = new byte[256]; + int total = 0; + while (total < 16) + { + int read = await stream.ReadAsync(buffer.AsMemory(total), cancellationToken) + .AsTask().WaitAsync(Remaining(sw), cancellationToken).ConfigureAwait(false); + if (read == 0) + break; + total += read; + } + + return Evaluate(buffer, total, sw.Elapsed); + } + catch (Exception ex) + { + return new LiveResult(false, Classify(ex, phase, node), sw.Elapsed); + } + finally + { + if (stream is not null) + await stream.DisposeAsync().ConfigureAwait(false); + } + } + + /// Time left in this node's budget, floored so WaitAsync never gets a negative. + private TimeSpan Remaining(Stopwatch sw) + { + var left = timeout - sw.Elapsed; + return left < TimeSpan.FromMilliseconds(250) ? TimeSpan.FromMilliseconds(250) : left; + } + + /// + /// Decides whether a plausible HTTP response came back. Only the status code and the + /// byte count ever reach the report — never the bytes themselves. + /// + private static LiveResult Evaluate(byte[] buffer, int total, TimeSpan elapsed) + { + if (total == 0) + return new LiveResult(false, "response: tunnel opened but server closed without sending a byte", elapsed); + + string head = Encoding.ASCII.GetString(buffer, 0, Math.Min(total, 32)); + if (!head.StartsWith("HTTP/1.", StringComparison.Ordinal)) + return new LiveResult(false, $"response: not HTTP ({total} bytes returned)", elapsed); + + string[] parts = head.Split(' '); + string status = parts.Length > 1 && parts[1].Length == 3 && parts[1].All(char.IsAsciiDigit) + ? parts[1] + : "unparseable status"; + return new LiveResult(true, $"HTTP {status}", elapsed); + } + + /// + /// Maps an exception onto a stable, non-identifying group key. Exception messages from + /// this library embed host:port, so they are never used verbatim: the key is built + /// from types, and , and any BCL + /// message that does get quoted is scrubbed of this node's own secrets first. + /// + private static string Classify(Exception ex, string phase, LiveNode node) + { + switch (ex) + { + case TimeoutException: + return $"{phase}: timed out"; + + case OperationCanceledException: + return $"{phase}: cancelled"; + + // The library's own timer aborts the socket, so a timeout arrives as + // SocketError.OperationAborted wrapped in ProxyErrorCode.Timeout. Report what it + // means, not the mechanism, and keep it in the same group as other timeouts. + case ProxyProtocolException { ErrorCode: ProxyErrorCode.Timeout }: + return $"{phase}: timed out"; + + case ProxyProtocolException pex when Inner(pex) is { } socket: + return $"{phase}: SocketError.{socket.SocketErrorCode} (ProxyErrorCode.{pex.ErrorCode})"; + + case ProxyProtocolException pex when Inner(pex) is { } auth: + return $"{phase}: TLS handshake failed (ProxyErrorCode.{pex.ErrorCode}): {Detail(auth, node)}"; + + case ProxyProtocolException pex: + return $"{phase}: ProxyErrorCode.{pex.ErrorCode}{Hint(pex.ErrorCode, phase)}"; + + case AuthenticationException auth: + return $"{phase}: TLS handshake failed: {Detail(auth, node)}"; + + case SocketException socket: + return $"{phase}: SocketError.{socket.SocketErrorCode}"; + + case IOException io when Inner(io) is { } socket: + return $"{phase}: I/O SocketError.{socket.SocketErrorCode}"; + + case IOException io: + return $"{phase}: IOException: {Detail(io, node)}"; + + // The sampler is supposed to exclude everything the clients reject; if one slips + // through, that mismatch is itself the finding. + case NotSupportedException: + return $"{phase}: NotSupportedException — sampling filter let an unsupported config through: {Detail(ex, node)}"; + + default: + return $"{phase}: unexpected {ex.GetType().Name}: {Detail(ex, node)}"; + } + } + + /// + /// Fixed explanatory suffix for the codes whose meaning depends on the phase. During + /// the response phase a is not a TCP + /// failure at all: it is the lazy response-header read finding the connection dropped, + /// which is how VLESS and VMess servers reject a handshake. + /// + private static string Hint(ProxyErrorCode code, string phase) => (code, phase) switch + { + (ProxyErrorCode.ConnectionFailed, "response") => " (server closed mid-handshake — rejected id or refused target)", + (ProxyErrorCode.InvalidResponse, "response") => " (server answered with a malformed protocol header)", + _ => "" + }; + + private static TException? Inner(Exception ex) where TException : Exception + { + for (Exception? current = ex; current is not null; current = current.InnerException) + if (current is TException match) + return match; + return null; + } + + /// First sentence of a BCL message, scrubbed of this node's own values. + private static string Detail(Exception ex, LiveNode node) + { + string message = ex.Message.ReplaceLineEndings(" ").Trim(); + if (message.Length > 160) + message = message[..160] + "…"; + return Redactor.Scrub(message, node.Secrets); + } +} + +/// Accumulates live-probe results and renders the report. +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 ProtocolStats[] All => [_vless, _trojan, _vmess]; + + public void Record(LiveNode node, LiveResult result) + { + var stats = node.Protocol switch + { + "vless" => _vless, + "trojan" => _trojan, + _ => _vmess + }; + + if (result.Ok) + stats.RecordOk($"{node.Mode}, {result.Detail}"); + else + stats.RecordFailure(result.Detail, node.Shape, static shape => shape); + } + + public string BuildReport(string corpusPath, LiveSample sample, int requested, int seed, TimeSpan timeout) + { + var sb = new StringBuilder(); + sb.AppendLine("# QuickProxyNet live-node probe"); + sb.AppendLine(); + sb.AppendLine($"- Corpus: `{corpusPath}`"); + sb.AppendLine($"- Date: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"); + sb.AppendLine($"- Sample: {sample.Nodes.Count} node(s) requested {requested}, seed {seed} (re-run with `--seed {seed}` to repeat)"); + sb.AppendLine($"- Dialled sequentially, {timeout.TotalSeconds:0.#}s budget per node, one request each"); + sb.AppendLine($"- Target through the tunnel: `http://{LiveProber.TargetHost}{LiveProber.TargetPath}` (expects `204`)"); + sb.AppendLine(); + sb.AppendLine("A node counts as a success only when a well-formed HTTP status line came back"); + sb.AppendLine("through the tunnel: VLESS and VMess validate their response header on the first"); + sb.AppendLine("read, so a returned stream on its own proves nothing."); + sb.AppendLine(); + + sb.AppendLine("## Result"); + sb.AppendLine(); + sb.AppendLine("| Protocol | Attempted | Succeeded | Failed | OK % |"); + sb.AppendLine("| --- | ---: | ---: | ---: | ---: |"); + foreach (var stats in All) + sb.AppendLine( + $"| {stats.Name} | {stats.Total} | {stats.Ok} | {stats.Failed} | " + + $"{(stats.Total == 0 ? 0 : 100.0 * stats.Ok / stats.Total):F1}% |"); + sb.AppendLine(); + + sb.AppendLine("## Eligible pool (before sampling)"); + sb.AppendLine(); + sb.AppendLine("Only configurations this library can speak are sampled — dialling a node we are"); + sb.AppendLine("guaranteed to reject measures nothing."); + sb.AppendLine(); + sb.AppendLine("| Protocol | Lines in corpus | Connectable | Sampled |"); + sb.AppendLine("| --- | ---: | ---: | ---: |"); + foreach (var stats in All) + { + var pool = sample.Pools[stats.Name]; + sb.AppendLine($"| {stats.Name} | {pool.Seen} | {pool.Eligible.Count} | {stats.Total} |"); + } + sb.AppendLine(); + + foreach (var stats in All) + { + var pool = sample.Pools[stats.Name]; + if (pool.Exclusions.Count == 0) + continue; + sb.AppendLine($"### {stats.Name}: excluded from the pool"); + sb.AppendLine(); + foreach (var (reason, count) in pool.Exclusions.OrderByDescending(kv => kv.Value)) + sb.AppendLine($"- {reason} — {count}"); + sb.AppendLine(); + } + + foreach (var stats in All) + stats.AppendBreakdown(sb); + + foreach (var stats in All) + stats.AppendFailures(sb); + + return sb.ToString(); + } +} diff --git a/tools/CorpusCheck/Program.cs b/tools/CorpusCheck/Program.cs new file mode 100644 index 0000000..5aff1e8 --- /dev/null +++ b/tools/CorpusCheck/Program.cs @@ -0,0 +1,745 @@ +// CorpusCheck — one-shot diagnostic that runs QuickProxyNet's share-link parsers over a +// large corpus of real-world proxy share links and reports parse rates plus failures +// grouped by reason. +// +// A second, opt-in mode (`--live [N]`) dials a small SAMPLE of nodes from the "Checked" +// corpus and tunnels one HTTP request through each, to see whether the clients — not just +// the parsers — work against real servers. See LiveProbe.cs for the rules that mode obeys; +// it is manual-only and refuses to run under CI. +// +// Everything downloaded and everything written lives OUTSIDE the repository, in +// Path.GetTempPath()/QuickProxyNet-CorpusCheck. No corpus data may land in the repo. +// +// All example links in the report are REDACTED: these are real servers belonging to other +// people, so no output line may ever contain a usable credential (uuid/password), host, +// SNI, path or remark. See Redactor below. + +using System.Text; +using System.Text.Json; +using QuickProxyNet; + +namespace CorpusCheck; + +internal static class Program +{ + private const string CorpusUrl = + "https://raw.githubusercontent.com/heops6767/PypsCFG/main/output/merged_all.txt"; + + /// + /// Sibling of with unreachable nodes already filtered out — the + /// only sensible input for --live. + /// + private const string CheckedCorpusUrl = + "https://raw.githubusercontent.com/heops6767/PypsCFG/main/output/merged_all_Checked.txt"; + + private const int DefaultLiveCount = 30; + + /// + /// Hard ceiling on --live N. This mode exists to diagnose our client against a + /// handful of real servers; anything bigger is a scan of other people's infrastructure. + /// + private const int MaxSampleSize = 100; + + private const string Usage = """ + usage: + CorpusCheck [--refresh] [--file ] + Parse mode (default): runs the share-link parsers over merged_all.txt and + reports parse rates with failures grouped by reason. + + CorpusCheck --live [N] [--refresh] [--file ] [--seed ] [--timeout ] + Live mode (MANUAL ONLY): samples N connectable nodes (default 30, max 100) + from merged_all_Checked.txt, dials each sequentially through this library and + tunnels one HTTP request to a neutral connectivity endpoint. + + options: + --refresh re-download the corpus instead of using the temp-dir cache + --file use a local corpus file instead of downloading + --seed RNG seed for the live sample (reported, so a run repeats) + --timeout per-node budget in live mode (default 5) + """; + + private static async Task Main(string[] args) + { + bool refresh = false; + bool live = false; + string? localFile = null; + int liveCount = DefaultLiveCount; + int seed = Random.Shared.Next(); + double timeoutSeconds = 5; + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--refresh": + refresh = true; + break; + case "--file" when i + 1 < args.Length: + localFile = args[++i]; + break; + case "--live": + live = true; + // N is optional: "--live" and "--live 20" are both valid. + if (i + 1 < args.Length && int.TryParse(args[i + 1], out int parsedCount)) + { + liveCount = parsedCount; + i++; + } + break; + case "--seed" when i + 1 < args.Length && int.TryParse(args[i + 1], out int parsedSeed): + seed = parsedSeed; + i++; + break; + case "--timeout" when i + 1 < args.Length && double.TryParse(args[i + 1], out double parsedTimeout): + timeoutSeconds = parsedTimeout; + i++; + break; + case "--help" or "-h": + Console.WriteLine(Usage); + return 0; + default: + Console.Error.WriteLine($"Unknown or malformed argument '{args[i]}'. Try --help."); + return 2; + } + } + + if (live) + { + if (liveCount is < 1 or > MaxSampleSize) + { + Console.Error.WriteLine( + $"--live N must be between 1 and {MaxSampleSize}. This is a diagnostic sample, not a sweep."); + return 2; + } + if (timeoutSeconds is < 1 or > 30) + { + Console.Error.WriteLine("--timeout must be between 1 and 30 seconds."); + return 2; + } + if (DetectCi() is { } ciVariable) + { + Console.Error.WriteLine( + $"Refusing to run --live: environment variable '{ciVariable}' indicates CI. " + + "This mode opens connections to third-party servers and is manual-only."); + return 2; + } + } + + string tempDir = Path.Combine(Path.GetTempPath(), "QuickProxyNet-CorpusCheck"); + Directory.CreateDirectory(tempDir); + + string corpusPath; + if (localFile is not null) + { + if (!File.Exists(localFile)) + { + Console.Error.WriteLine($"File not found: {localFile}"); + return 2; + } + corpusPath = localFile; + } + else + { + corpusPath = await EnsureCorpusAsync( + tempDir, + live ? "merged_all_Checked.txt" : "merged_all.txt", + live ? CheckedCorpusUrl : CorpusUrl, + refresh); + } + + return live + ? await RunLiveAsync(corpusPath, tempDir, liveCount, seed, TimeSpan.FromSeconds(timeoutSeconds)) + : await RunParseAsync(corpusPath, tempDir); + } + + /// Downloads the corpus into the temp dir unless a usable cache is already there. + private static async Task EnsureCorpusAsync(string tempDir, string fileName, string url, bool refresh) + { + string path = Path.Combine(tempDir, fileName); + if (refresh || !File.Exists(path)) + { + Console.WriteLine($"Downloading corpus from {url} ..."); + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(2) }; + string text = await http.GetStringAsync(url); + await File.WriteAllTextAsync(path, text); + Console.WriteLine($"Cached at {path}"); + } + else + { + Console.WriteLine($"Using cached corpus at {path} (use --refresh to re-download)"); + } + return path; + } + + private static async Task RunParseAsync(string corpusPath, string tempDir) + { + var run = new CorpusRun(); + foreach (string rawLine in File.ReadLines(corpusPath)) + { + string line = rawLine.Trim(); + if (line.Length == 0) + continue; + run.Process(line); + } + + string report = run.BuildReport(corpusPath); + Console.WriteLine(); + Console.WriteLine(report); + + string reportPath = Path.Combine(tempDir, "CorpusCheck-report.md"); + await File.WriteAllTextAsync(reportPath, report); + Console.WriteLine($"Report written to {reportPath}"); + return 0; + } + + private static async Task RunLiveAsync( + string corpusPath, string tempDir, int count, int seed, TimeSpan timeout) + { + var sample = LiveSampler.Build(corpusPath, count, seed); + if (sample.Nodes.Count == 0) + { + Console.Error.WriteLine("No connectable nodes found in the corpus — nothing to probe."); + return 1; + } + + Console.WriteLine( + $"Probing {sample.Nodes.Count} node(s) sequentially, {timeout.TotalSeconds:0.#}s each, seed {seed}."); + Console.WriteLine( + $"Target through each tunnel: http://{LiveProber.TargetHost}{LiveProber.TargetPath}"); + Console.WriteLine(); + + // Ctrl+C stops the run and still prints what was collected. + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + + var prober = new LiveProber(timeout); + var run = new LiveRun(); + + int index = 0; + foreach (var node in sample.Nodes) + { + if (cts.IsCancellationRequested) + { + Console.WriteLine("Cancelled — reporting what was collected so far."); + break; + } + + index++; + var result = await prober.ProbeAsync(node, cts.Token); + run.Record(node, result); + Console.WriteLine( + $"[{index,3}/{sample.Nodes.Count}] {(result.Ok ? "ok " : "FAIL")} " + + $"{node.Protocol,-6} {result.Elapsed.TotalSeconds,5:0.00}s {result.Detail}"); + } + + string report = run.BuildReport(corpusPath, sample, count, seed, timeout); + Console.WriteLine(); + Console.WriteLine(report); + + string reportPath = Path.Combine(tempDir, "CorpusCheck-live-report.md"); + await File.WriteAllTextAsync(reportPath, report); + Console.WriteLine($"Report written to {reportPath}"); + return 0; + } + + /// Returns the name of the CI variable that is set, or null when not on CI. + private static string? DetectCi() + { + foreach (string name in new[] { "CI", "TF_BUILD", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL" }) + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))) + return name; + return null; + } +} + +/// Accumulates per-protocol results for one pass over the corpus. +internal sealed class CorpusRun +{ + private static readonly string ParseNote = + "(Parse success is not connect support — non-tcp transports and REALITY parse fine" + + Environment.NewLine + + "but throw NotSupportedException at connect time.)"; + + private readonly ProtocolStats _vless = new("vless", breakdownNote: ParseNote); + private readonly ProtocolStats _trojan = new("trojan", breakdownNote: ParseNote); + private readonly ProtocolStats _vmess = new("vmess", breakdownNote: ParseNote); + + /// Schemes this library does not implement — reported, but not failures. + private readonly SortedDictionary _otherSchemes = new(StringComparer.OrdinalIgnoreCase); + + private int _noScheme; + private int _total; + + public void Process(string line) + { + _total++; + + if (line.StartsWith("vless://", StringComparison.OrdinalIgnoreCase)) + { + // Fast pass with TryParse; only failures pay for the exception below. + if (VlessShareLink.TryParse(line, out var options)) + { + _vless.RecordOk(VlessMode(options)); + } + else + { + // TryParse discards the reason. Parse throws a FormatException whose + // message IS the reason, so re-parse the failure to capture it. Exceptions + // are slow, but this is a one-shot diagnostic over ~18k lines and only + // failing lines take this path — an acceptable trade for not having to + // change the library's public API. + _vless.RecordFailure(CaptureReason(() => VlessShareLink.Parse(line)), line, Redactor.RedactUriLink); + } + } + else if (line.StartsWith("trojan://", StringComparison.OrdinalIgnoreCase)) + { + if (TrojanShareLink.TryParse(line, out var options)) + _trojan.RecordOk($"transport={options.Transport.ToLowerInvariant()}"); + else + _trojan.RecordFailure(CaptureReason(() => TrojanShareLink.Parse(line)), line, Redactor.RedactUriLink); + } + else if (line.StartsWith("vmess://", StringComparison.OrdinalIgnoreCase)) + { + if (VmessShareLink.TryParse(line, out var options)) + _vmess.RecordOk($"net={options.Transport.ToLowerInvariant()}, tls={(options.UseTls ? "on" : "off")}"); + else + _vmess.RecordFailure(CaptureReason(() => VmessShareLink.Parse(line)), line, Redactor.RedactVmessLink); + } + else + { + int schemeEnd = line.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd > 0 && schemeEnd <= 16) + { + string scheme = line[..schemeEnd].ToLowerInvariant(); + _otherSchemes[scheme] = _otherSchemes.GetValueOrDefault(scheme) + 1; + } + else + { + _noScheme++; + } + } + } + + private static string VlessMode(VlessOptions options) + { + string security = options.Security switch + { + VlessSecurity.Tls => "tls", + VlessSecurity.Reality => "reality", + _ => "none" + }; + return $"transport={options.Transport.ToLowerInvariant()}, security={security}"; + } + + private static string CaptureReason(Action parse) + { + try + { + parse(); + // TryParse failed but Parse succeeded — should be impossible; make it visible. + return "(inconsistent: TryParse failed but Parse succeeded)"; + } + catch (FormatException ex) + { + return Redactor.NormalizeReason(ex.Message); + } + catch (Exception ex) + { + // Anything but FormatException escaping Parse is itself a finding. + return $"(unexpected {ex.GetType().Name}) {Redactor.NormalizeReason(ex.Message)}"; + } + } + + public string BuildReport(string corpusPath) + { + var sb = new StringBuilder(); + sb.AppendLine("# QuickProxyNet share-link corpus check"); + sb.AppendLine(); + sb.AppendLine($"- Corpus: `{corpusPath}`"); + sb.AppendLine($"- Date: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"); + sb.AppendLine($"- Non-empty lines: {_total}"); + sb.AppendLine(); + + sb.AppendLine("## Parse rate"); + sb.AppendLine(); + sb.AppendLine("| Protocol | Total | Parsed OK | Failed | OK % |"); + sb.AppendLine("| --- | ---: | ---: | ---: | ---: |"); + foreach (var stats in new[] { _vless, _trojan, _vmess }) + { + sb.AppendLine( + $"| {stats.Name} | {stats.Total} | {stats.Ok} | {stats.Failed} | " + + $"{(stats.Total == 0 ? 0 : 100.0 * stats.Ok / stats.Total):F1}% |"); + } + sb.AppendLine(); + + if (_otherSchemes.Count > 0 || _noScheme > 0) + { + sb.AppendLine("## Other schemes (not implemented by this library — not failures)"); + sb.AppendLine(); + foreach (var (scheme, count) in _otherSchemes.OrderByDescending(kv => kv.Value)) + sb.AppendLine($"- `{scheme}://` — {count}"); + if (_noScheme > 0) + sb.AppendLine($"- (no recognizable scheme) — {_noScheme}"); + sb.AppendLine(); + } + + foreach (var stats in new[] { _vless, _trojan, _vmess }) + stats.AppendBreakdown(sb); + + foreach (var stats in new[] { _vless, _trojan, _vmess }) + stats.AppendFailures(sb); + + return sb.ToString(); + } +} + +/// +/// Per-protocol counters, an OK-mode breakdown and failure groups. Shared by the parse +/// report and the --live report, which differ only in the breakdown wording. +/// +internal sealed class ProtocolStats( + string name, + string breakdownHeading = "parsed-OK breakdown by mode", + string? breakdownNote = null) +{ + private const int MaxExamplesPerGroup = 3; + + public string Name { get; } = name; + public int Total { get; private set; } + public int Ok { get; private set; } + public int Failed { get; private set; } + + private readonly Dictionary _okModes = new(StringComparer.Ordinal); + private readonly Dictionary _failures = new(StringComparer.Ordinal); + + public void RecordOk(string mode) + { + Total++; + Ok++; + _okModes[mode] = _okModes.GetValueOrDefault(mode) + 1; + } + + public void RecordFailure(string reason, string link, Func redact) + { + Total++; + Failed++; + + if (!_failures.TryGetValue(reason, out var group)) + _failures[reason] = group = new FailureGroup(); + + group.Count++; + if (group.Examples.Count < MaxExamplesPerGroup) + { + string example = redact(link); + // Keep examples within a group distinct — three identical shapes teach nothing. + if (!group.Examples.Contains(example)) + group.Examples.Add(example); + } + } + + public void AppendBreakdown(StringBuilder sb) + { + if (Ok == 0) + return; + + sb.AppendLine($"## {Name}: {breakdownHeading}"); + sb.AppendLine(); + if (breakdownNote is not null) + { + sb.AppendLine(breakdownNote); + sb.AppendLine(); + } + foreach (var (mode, count) in _okModes.OrderByDescending(kv => kv.Value)) + sb.AppendLine($"- {mode} — {count}"); + sb.AppendLine(); + } + + public void AppendFailures(StringBuilder sb) + { + if (Failed == 0) + return; + + sb.AppendLine($"## {Name}: failures grouped by reason ({Failed} total)"); + sb.AppendLine(); + foreach (var (reason, group) in _failures.OrderByDescending(kv => kv.Value.Count)) + { + sb.AppendLine($"### [{group.Count}x] {reason}"); + sb.AppendLine(); + foreach (string example in group.Examples) + sb.AppendLine($"- `{example}`"); + sb.AppendLine(); + } + } + + private sealed class FailureGroup + { + public int Count; + public readonly List Examples = new(); + } +} + +/// +/// Credential redaction. These are REAL servers belonging to other people: no output may +/// contain a usable credential. Userinfo becomes <id>, the host becomes +/// <host>, fragments (remarks) are dropped, and query/JSON values are only +/// shown for a whitelist of mode-describing keys (type/security/fp/...). Everything else — +/// sni, path, pbk, sid, unknown keys — is masked, so an example identifies a SHAPE, never +/// a server. +/// +internal static class Redactor +{ + /// Query keys whose values describe a mode and identify no server. + private static readonly HashSet SafeQueryKeys = new(StringComparer.OrdinalIgnoreCase) + { + "type", "network", "security", "encryption", "flow", "fp", "alpn", + "headerType", "mode", "allowInsecure", "insecure", "packetEncoding" + }; + + /// VMess JSON keys whose values are safe to show (never id/add/ps/host/sni/path). + private static readonly HashSet SafeVmessKeys = new(StringComparer.OrdinalIgnoreCase) + { + "v", "net", "tls", "type", "scy", "security", "aid", "alterId", "alpn" + }; + + /// + /// Collapses variable content out of failure messages so identical problems group + /// together, and strips the one secret a message can embed (the invalid user id). + /// + public static string NormalizeReason(string message) + { + // "... user id 'xxxx' is not a valid UUID." — the quoted value is the credential. + // All other quoted values in parser messages are mode names (security, type, scy) + // and are the interesting, non-secret part of the group key. + if (message.Contains("user id", StringComparison.OrdinalIgnoreCase)) + return ReplaceQuoted(message, ""); + return message; + } + + /// + /// Removes a specific node's own values from free text before it reaches the report. + /// Used by --live, where a BCL exception message may quote the host or SNI it was + /// talking to. Values shorter than three characters are skipped: they are not + /// identifying on their own and blanket-replacing them would shred the message. + /// + public static string Scrub(string text, IReadOnlyList secrets) + { + foreach (string? secret in secrets) + { + if (string.IsNullOrEmpty(secret) || secret.Length < 3) + continue; + text = text.Replace(secret, "", StringComparison.OrdinalIgnoreCase); + } + return text; + } + + private static string ReplaceQuoted(string message, string placeholder) + { + int start = message.IndexOf('\''); + int end = message.LastIndexOf('\''); + if (start < 0 || end <= start) + return message; + return message[..(start + 1)] + placeholder + message[end..]; + } + + /// + /// Redacts a vless:// or trojan:// link. Works on the raw string (not Uri) because + /// many failing links are exactly the ones Uri cannot parse. + /// + public static string RedactUriLink(string link) + { + int schemeEnd = link.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd < 0) + return ""; + + string scheme = link[..schemeEnd].ToLowerInvariant(); + string rest = link[(schemeEnd + 3)..]; + + // Drop the fragment (remark) entirely — often a channel name, never needed. + int hash = rest.IndexOf('#'); + if (hash >= 0) + rest = rest[..hash]; + + string authority = rest; + string? query = null; + int q = rest.IndexOf('?'); + if (q >= 0) + { + authority = rest[..q]; + query = rest[(q + 1)..]; + } + + // Split userinfo@hostport on the LAST '@' — passwords may contain '@'. + string hostPort = authority; + bool hasUserInfo = false; + int at = authority.LastIndexOf('@'); + if (at >= 0) + { + hasUserInfo = at > 0; + hostPort = authority[(at + 1)..]; + } + + // Keep the port (a port alone identifies nothing); mask the host. An IPv6 literal + // is "[...]:port", so look for the port after the closing bracket. + string port = ""; + int portSearchFrom = hostPort.StartsWith('[') ? hostPort.IndexOf(']') : 0; + if (portSearchFrom >= 0) + { + int colon = hostPort.IndexOf(':', portSearchFrom); + if (colon >= 0 && colon + 1 < hostPort.Length && + hostPort[(colon + 1)..].All(char.IsAsciiDigit)) + { + port = ":" + hostPort[(colon + 1)..]; + } + } + bool ipv6 = hostPort.StartsWith('['); + + var sb = new StringBuilder(); + sb.Append(scheme).Append("://"); + if (hasUserInfo) + sb.Append("@"); + else if (at == 0) + sb.Append("@"); + sb.Append(ipv6 ? "" : "").Append(port); + + if (query is not null) + sb.Append('?').Append(RedactQuery(query)); + + return sb.ToString(); + } + + private static string RedactQuery(string query) + { + var sb = new StringBuilder(); + foreach (string pair in query.Split('&')) + { + if (sb.Length > 0) + sb.Append('&'); + + int eq = pair.IndexOf('='); + if (eq < 0) + { + sb.Append(pair); // bare key, no value to leak + continue; + } + + string key = pair[..eq]; + string value = pair[(eq + 1)..]; + sb.Append(key).Append('='); + if (value.Length == 0) + sb.Append(""); // preserve the empty-value shape: "key=" + else if (SafeQueryKeys.Contains(key)) + sb.Append(Uri.UnescapeDataString(value)); + else + sb.Append("<...>"); + } + return sb.ToString(); + } + + /// + /// Redacts a vmess:// link: decodes the base64 payload itself (tolerating the URL-safe + /// alphabet and missing padding) and shows only JSON key names plus whitelisted + /// non-secret values. Never id / add / ps / host / sni / path. + /// + public static string RedactVmessLink(string link) + { + string payload = link["vmess://".Length..].Trim(); + + // v2rayN appends "#remark" AFTER the base64 payload, and the parser accepts that + // form — so strip the fragment before decoding. Without this, a perfectly valid + // payload was reported as "not decodable as base64" and the example described a + // shape that does not exist. The base64 alphabet never contains '#', so cutting at + // the first one cannot truncate real payload. + int hash = payload.IndexOf('#'); + if (hash >= 0) + payload = payload[..hash]; + + // The other grammar in the wild is a plain URI: vmess://uuid@host:port?...#remark. + // '@' is not in the base64 alphabet either, so its presence identifies that form + // unambiguously — describe it as a URI rather than as undecodable base64. + if (payload.Contains('@')) + return RedactUriLink(link); + + byte[] bytes; + try + { + string normalized = payload.Replace('-', '+').Replace('_', '/'); + normalized = string.Concat(normalized.Where(c => !char.IsWhiteSpace(c))); + int pad = normalized.Length % 4; + if (pad == 1) + return DescribeUndecodable(payload); + if (pad != 0) + normalized += new string('=', 4 - pad); + bytes = Convert.FromBase64String(normalized); + } + catch (FormatException) + { + return DescribeUndecodable(payload); + } + + try + { + using var doc = JsonDocument.Parse(bytes); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return $"vmess://"; + + var sb = new StringBuilder("vmess://{ "); + bool first = true; + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (!first) + sb.Append(", "); + first = false; + + sb.Append(property.Name).Append('='); + if (SafeVmessKeys.Contains(property.Name)) + sb.Append(property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.GetRawText()); + else + sb.Append("<...>"); + } + sb.Append(" }"); + return sb.ToString(); + } + catch (JsonException) + { + return $"vmess://"; + } + } + + /// + /// A payload that isn't base64 at all: describe its shape (length, offending character + /// classes) without reproducing any of it. + /// + private static string DescribeUndecodable(string payload) + { + var offending = new SortedSet(); + foreach (char c in payload) + { + if (char.IsAsciiLetterOrDigit(c) || c is '+' or '/' or '-' or '_' or '=') + continue; + offending.Add(c switch + { + '%' => "'%'", + '@' => "'@'", + '?' => "'?'", + '#' => "'#'", + ':' => "':'", + '.' => "'.'", + ',' => "','", + _ when char.IsWhiteSpace(c) => "whitespace", + _ when char.IsAscii(c) => "other ASCII punctuation", + _ => "non-ASCII" + }); + } + + string chars = offending.Count == 0 + ? $"valid alphabet but impossible length {payload.Length} % 4 == 1" + : $"contains {string.Join(", ", offending)}"; + return $"vmess://"; + } +}