From fd3dc48275483b612fb2c29262d61342b56b1866 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 25 Jun 2026 21:56:22 +0500 Subject: [PATCH 01/25] Add VPN protocol documentation --- AGENTS.md | 95 +++++++++++++++++ CLAUDE.md | 189 --------------------------------- docs/README.md | 17 +++ docs/hy2.md | 46 ++++++++ docs/hysteria2.md | 172 ++++++++++++++++++++++++++++++ docs/trojan.md | 160 ++++++++++++++++++++++++++++ docs/tuic.md | 223 +++++++++++++++++++++++++++++++++++++++ docs/vless.md | 209 ++++++++++++++++++++++++++++++++++++ docs/vmess.md | 263 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1185 insertions(+), 189 deletions(-) create mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 docs/README.md create mode 100644 docs/hy2.md create mode 100644 docs/hysteria2.md create mode 100644 docs/trojan.md create mode 100644 docs/tuic.md create mode 100644 docs/vless.md create mode 100644 docs/vmess.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0494983 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# QuickProxyNet Agent Guide + +## Project Overview + +QuickProxyNet is a high-performance C#/.NET library for opening direct `Stream` +connections through proxy protocols. The current core library supports HTTP, +HTTPS, SOCKS4, SOCKS4a, and SOCKS5. + +- NuGet package: `QuickProxyNet` +- Author: Titlehhhh +- License: MIT +- Core targets: `net8.0`, `net9.0`, `net10.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 +``` + +## 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 `Uri` or explicit proxy settings. +- `ProxyProtocolException` carries a structured `ProxyErrorCode`. + +## Current Protocol Implementations + +| Class | Protocol | +| --- | --- | +| `HttpProxyClient` | HTTP CONNECT | +| `HttpsProxyClient` | HTTPS CONNECT over TLS | +| `Socks4Client` | SOCKS4 | +| `Socks4aClient` | SOCKS4a | +| `Socks5Client` | SOCKS5 with optional username/password auth | + +Internal protocol helpers live under `QuickProxyNet/Internal/`: + +- `HttpHelper.cs` +- `HttpResponseParser.cs` +- `SocksHelper.cs` + +## Development Rules + +- Keep hot protocol paths allocation-conscious: prefer `Span`, + `Memory`, `ArrayPool`, `stackalloc`, and `ValueTask`. +- Return rented buffers in `finally`. +- 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`, and `net10.0`. + +## Testing + +Run the test project directly: + +```bash +dotnet test QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +``` + +Integration tests that require real proxies use environment variables such as +`HTTP_PROXY_URI` and `SOCKS5_PROXY_URI`; they no-op when the variables are not +set. + +## 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. + +## VPN-Style Protocol Research + +Detailed notes for VLESS, VMess, Trojan, Hysteria2, hy2, and TUIC live in +`docs/`. Treat those documents as planning notes until implementation and +tests are added. 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/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9969cfa --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# VPN-подобные proxy-протоколы + +Эта папка фиксирует исследование протоколов, которые потенциально можно +добавить в QuickProxyNet с сохранением главной модели библиотеки: +`ConnectAsync(...) -> Stream`. + +Файлы: + +- [VLESS](vless.md) +- [VMess](vmess.md) +- [Trojan](trojan.md) +- [Hysteria2](hysteria2.md) +- [hy2](hy2.md) +- [TUIC](tuic.md) + +Документы написаны как wire-level заметки для будущей реализации. Они не +означают, что поддержка этих протоколов уже есть в библиотеке. 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/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..9de8e9d --- /dev/null +++ b/docs/vless.md @@ -0,0 +1,209 @@ +# 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`: + +```text +TCP connect -> REALITY/uTLS handshake -> VLESS -> Stream +``` + +Нужны public key, short id, SNI/serverName, uTLS fingerprint и проверка +REALITY-specific certificate behavior. Это отдельный transport security слой. +Его нельзя полноценно сделать через стандартный .NET `SslStream`, потому что +`SslStream` не дает точный browser-like ClientHello fingerprint. + +### `flow=xtls-rprx-vision` + +Flow не меняет базовый VLESS request header как таковой, но меняет поведение +последующего копирования/шифрования в XTLS/REALITY режиме. Для чистой +QuickProxyNet-реализации его лучше считать отдельной большой задачей, а не +частью базового VLESS. + +## 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 надо прочитать до возврата stream, иначе пользователь увидит + `00 00` перед байтами target-а. + +## Источники + +- 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.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 From 16a600fe09ea35e4289f14185541b646752b2900 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 23 Jul 2026 19:40:04 +0500 Subject: [PATCH 02/25] feat: add VLESS and Trojan proxy protocols with SHA-224 Implements two VPN-style proxy protocols on the existing ConnectAsync(...) -> Stream model, plus supporting infrastructure. VLESS (security=none/tls): - VlessOptions + VlessShareLink single-pass span vless:// parser - VlessHelper zero-alloc request builder; VlessClient (none + TLS) - UuidCodec: big-endian RFC 4122 encoding (avoids the Guid.ToByteArray mixed-endian trap) Trojan (TLS-mandatory): - Sha224 primitive (absent from the BCL): scalar + guarded Vector128 message-schedule path, NIST-verified with a scalar-vs-vector sweep - TrojanOptions/parser, TrojanHelper, TrojanClient - ProxyAddress: shared address writer (atyp codes passed by protocol) Hardening (from subagent review): - reject unknown vless security= (no silent plaintext downgrade) - bracket IPv6 proxy hosts in ProxyClient - clear credential/password buffers before ArrayPool return - wrap a truncated VLESS response as ProxyProtocolException 107 unit tests; multi-target net8.0/net9.0/net10.0; benchmarks included. Co-Authored-By: Claude Fable 5 --- QuickProxyNet.Benchmarks/Sha224Benchmark.cs | 88 +++++++ QuickProxyNet.Benchmarks/TrojanBenchmark.cs | 94 +++++++ QuickProxyNet.Benchmarks/VlessBenchmark.cs | 140 +++++++++++ QuickProxyNet.Tests/Sha224Test.cs | 147 +++++++++++ QuickProxyNet.Tests/TrojanTest.cs | 203 +++++++++++++++ QuickProxyNet.Tests/VlessTest.cs | 265 ++++++++++++++++++++ QuickProxyNet/Clients/ProxyClient.cs | 10 +- QuickProxyNet/Clients/TrojanClient.cs | 112 +++++++++ QuickProxyNet/Clients/VlessClient.cs | 130 ++++++++++ QuickProxyNet/Configs/TrojanOptions.cs | 46 ++++ QuickProxyNet/Configs/TrojanShareLink.cs | 153 +++++++++++ QuickProxyNet/Configs/VlessOptions.cs | 75 ++++++ QuickProxyNet/Configs/VlessShareLink.cs | 191 ++++++++++++++ QuickProxyNet/Internal/ProxyAddress.cs | 62 +++++ QuickProxyNet/Internal/Sha224.cs | 211 ++++++++++++++++ QuickProxyNet/Internal/TrojanHelper.cs | 100 ++++++++ QuickProxyNet/Internal/UuidCodec.cs | 44 ++++ QuickProxyNet/Internal/VlessHelper.cs | 82 ++++++ QuickProxyNet/ProxyClientFactory.cs | 9 + QuickProxyNet/ProxyProtocolException.cs | 4 +- QuickProxyNet/ProxyType.cs | 11 +- QuickProxyNet/QuickProxyNet.csproj | 1 + docs/implementation-plan.md | 183 ++++++++++++++ 23 files changed, 2355 insertions(+), 6 deletions(-) create mode 100644 QuickProxyNet.Benchmarks/Sha224Benchmark.cs create mode 100644 QuickProxyNet.Benchmarks/TrojanBenchmark.cs create mode 100644 QuickProxyNet.Benchmarks/VlessBenchmark.cs create mode 100644 QuickProxyNet.Tests/Sha224Test.cs create mode 100644 QuickProxyNet.Tests/TrojanTest.cs create mode 100644 QuickProxyNet.Tests/VlessTest.cs create mode 100644 QuickProxyNet/Clients/TrojanClient.cs create mode 100644 QuickProxyNet/Clients/VlessClient.cs create mode 100644 QuickProxyNet/Configs/TrojanOptions.cs create mode 100644 QuickProxyNet/Configs/TrojanShareLink.cs create mode 100644 QuickProxyNet/Configs/VlessOptions.cs create mode 100644 QuickProxyNet/Configs/VlessShareLink.cs create mode 100644 QuickProxyNet/Internal/ProxyAddress.cs create mode 100644 QuickProxyNet/Internal/Sha224.cs create mode 100644 QuickProxyNet/Internal/TrojanHelper.cs create mode 100644 QuickProxyNet/Internal/UuidCodec.cs create mode 100644 QuickProxyNet/Internal/VlessHelper.cs create mode 100644 docs/implementation-plan.md diff --git a/QuickProxyNet.Benchmarks/Sha224Benchmark.cs b/QuickProxyNet.Benchmarks/Sha224Benchmark.cs new file mode 100644 index 0000000..d995825 --- /dev/null +++ b/QuickProxyNet.Benchmarks/Sha224Benchmark.cs @@ -0,0 +1,88 @@ +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 +{ + private class Config : ManualConfig + { + public Config() => AddJob(Job.ShortRun.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/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.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/TrojanTest.cs b/QuickProxyNet.Tests/TrojanTest.cs new file mode 100644 index 0000000..8087ea2 --- /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.True(o.IsRawTcp); + 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=ws")); + + 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/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs new file mode 100644 index 0000000..ea00dff --- /dev/null +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -0,0 +1,265 @@ +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_RejectsGarbage() + { + Span dest = stackalloc byte[16]; + Assert.False(UuidCodec.TryWriteBigEndian("not-a-uuid", dest)); + } + + [Fact] + public void UuidCodec_RejectsSmallDestination() + { + Span dest = stackalloc byte[8]; + Assert.False(UuidCodec.TryWriteBigEndian(Uuid, dest)); + } + + // === 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.True(o.IsRawTcp); + 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://not-a-uuid@example.com:443")] + [InlineData("vless://@example.com:443")] + public void TryParse_RejectsInvalid(string link) + { + Assert.False(VlessShareLink.TryParse(link, out _)); + } + + [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 _)); + } + + [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_InvalidUuid_ThrowsAtConstruction() + { + var bad = new VlessOptions { Id = "not-a-uuid", Host = "example.com", Port = 443 }; + Assert.Throws(() => new VlessClient(bad)); + } + + [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 ex = await Assert.ThrowsAsync( + () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); + } + + // === VlessClient (none path via FakeProxyStream) === + + [Fact] + public async Task Client_None_WritesRequest_And_ReturnsStream() + { + // Server response: ver=00, addonsLen=00. + var stream = new FakeProxyStream([0x00, 0x00]); + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=none")); + + var result = await client.ConnectAsync(stream, "mc.example.com", 25565, CancellationToken.None); + + Assert.Same(stream, result); + 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 + } + + [Fact] + public async Task Client_None_DrainsAddons() + { + // ver=00, addonsLen=03, then 3 addon bytes. + var stream = new FakeProxyStream([0x00, 0x03, 0xAA, 0xBB, 0xCC]); + var client = new VlessClient(VlessShareLink.Parse($"vless://{Uuid}@example.com:443")); + + var result = await client.ConnectAsync(stream, "example.org", 443, CancellationToken.None); + Assert.Same(stream, result); + } + + [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 ex = await Assert.ThrowsAsync( + () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + } + + [Fact] + public async Task Client_Reality_ThrowsNotSupported() + { + var stream = new FakeProxyStream([0x00, 0x00]); + var client = new VlessClient( + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=reality&pbk=x")); + + await Assert.ThrowsAsync( + () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + } + + [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=ws&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/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index d48080f..491e1e5 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,16 @@ 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. + private static string FormatUriHost(string host) => + host.Contains(':') ? $"[{host}]" : host; + public Uri ProxyUri { get; private set; } public abstract ProxyType Type { get; } diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs new file mode 100644 index 0000000..78bff27 --- /dev/null +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -0,0 +1,112 @@ +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. Only tcp/raw +/// transport is supported; alternate transports are rejected with +/// . +/// +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. + EnsureSupported(); + + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + try + { + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + await TrojanHelper.EstablishTrojanTunnelAsync(ssl, Options, host, port, cancellationToken) + .ConfigureAwait(false); + return ssl; + } + catch + { + // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. + await ssl.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private void EnsureSupported() + { + if (!Options.IsRawTcp) + throw new NotSupportedException( + $"Trojan transport '{Options.Transport}' is not supported; only 'tcp'/'raw' is implemented."); + } + + private SslClientAuthenticationOptions BuildSslOptions() => new() + { + TargetHost = Options.Sni ?? 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..310b148 --- /dev/null +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -0,0 +1,130 @@ +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) and security=tls (over ) with tcp/raw +/// transport. REALITY, non-empty flow, and alternate transports are rejected with +/// . +/// +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 UUID fails at construction rather than mid-connect + // (the share-link path already validated it, but a directly-built VlessOptions may not have). + if (!Guid.TryParse(options.Id, out _)) + throw new ArgumentException($"VLESS user id '{options.Id}' is not a valid 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; + + public override async ValueTask ConnectAsync(Stream stream, string host, int port, + CancellationToken cancellationToken = default) + { + EnsureSupported(); + + if (Options.Security == VlessSecurity.Tls) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + try + { + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + await VlessHelper.EstablishVlessTunnelAsync(ssl, Options, host, port, cancellationToken) + .ConfigureAwait(false); + return ssl; + } + catch + { + // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. + await ssl.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + try + { + await VlessHelper.EstablishVlessTunnelAsync(stream, Options, host, port, cancellationToken) + .ConfigureAwait(false); + return stream; + } + catch + { + await stream.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private void EnsureSupported() + { + if (!Options.IsRawTcp) + throw new NotSupportedException( + $"VLESS transport '{Options.Transport}' is not supported; only 'tcp'/'raw' is implemented."); + + if (Options.Security == VlessSecurity.Reality) + throw new NotSupportedException( + "VLESS REALITY is not supported: it requires a uTLS ClientHello fingerprint that SslStream cannot produce."); + + if (!string.IsNullOrEmpty(Options.Flow)) + throw new NotSupportedException( + $"VLESS flow '{Options.Flow}' (XTLS) is not supported in this release."); + } + + private SslClientAuthenticationOptions BuildSslOptions() => new() + { + TargetHost = Options.Sni ?? 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/Configs/TrojanOptions.cs b/QuickProxyNet/Configs/TrojanOptions.cs new file mode 100644 index 0000000..fa21356 --- /dev/null +++ b/QuickProxyNet/Configs/TrojanOptions.cs @@ -0,0 +1,46 @@ +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. Only +/// tcp/raw transport is supported at connect time in this release; other +/// transports (ws, 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 or raw (both raw TCP). Others are unsupported. + public string Transport { get; init; } = "tcp"; + + /// 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; } + + /// True when the transport is plain TCP (tcp or raw). + internal bool IsRawTcp => + Transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || + Transport.Equals("raw", StringComparison.OrdinalIgnoreCase); +} diff --git a/QuickProxyNet/Configs/TrojanShareLink.cs b/QuickProxyNet/Configs/TrojanShareLink.cs new file mode 100644 index 0000000..267131d --- /dev/null +++ b/QuickProxyNet/Configs/TrojanShareLink.cs @@ -0,0 +1,153 @@ +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; + } + + if (!Uri.TryCreate(shareLink.Trim(), UriKind.Absolute, out var uri) || + !uri.Scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) + { + error = "Trojan share link must start with 'trojan://'."; + 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; + 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 = 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); + } + } + + 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, + 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..79d026c --- /dev/null +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -0,0 +1,75 @@ +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). Parsed for completeness but + /// not yet supported at connect time — it requires a browser-like uTLS ClientHello + /// fingerprint that cannot produce. + /// + Reality +} + +/// +/// Strongly-typed configuration for a VLESS outbound, produced by +/// or built directly. +/// +/// +/// Only tcp/raw transport with or +/// is supported at connect time in this release. Other +/// fields (REALITY keys, non-empty , alternate transports) are parsed +/// so callers can inspect them, but connecting with them 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 or raw (both raw TCP). Others are unsupported. + public string Transport { get; init; } = "tcp"; + + /// 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; } + + /// True when the transport is plain TCP (tcp or raw). + internal bool IsRawTcp => + Transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || + Transport.Equals("raw", StringComparison.OrdinalIgnoreCase); +} diff --git a/QuickProxyNet/Configs/VlessShareLink.cs b/QuickProxyNet/Configs/VlessShareLink.cs new file mode 100644 index 0000000..8a401d1 --- /dev/null +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -0,0 +1,191 @@ +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; + } + + if (!Uri.TryCreate(shareLink.Trim(), UriKind.Absolute, out var uri) || + !uri.Scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) + { + error = "VLESS share link must start with 'vless://'."; + 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 '{id}' is not a valid 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; + 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 = 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()}'."; + 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); + } + } + + 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, + 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/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/Sha224.cs b/QuickProxyNet/Internal/Sha224.cs new file mode 100644 index 0000000..78c4ad9 --- /dev/null +++ b/QuickProxyNet/Internal/Sha224.cs @@ -0,0 +1,211 @@ +using System.Buffers.Binary; +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. Allocation-free: state, schedule and padding are stack-allocated. +/// +/// +/// .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. A pure scalar path always exists and is used on any CPU +/// without acceleration. +/// +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; + + private const int BlockSize = 64; + + // 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 + ]; + + /// + /// 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)); + + // SHA-224 initial hash values (second 32 bits of the fractional parts of the + // square roots of the 9th..16th primes). + Span h = + [ + 0xc1059ed8, 0x367cd507, 0x3070dd17, 0xf70e5939, + 0xffc00b31, 0x68581511, 0x64f98fa7, 0xbefa4fa4 + ]; + + Span w = stackalloc uint[64]; + + // Full 64-byte blocks straight from the input. + ReadOnlySpan remaining = data; + while (remaining.Length >= BlockSize) + { + ProcessBlock(remaining, h, w, vectorize); + remaining = remaining.Slice(BlockSize); + } + + // Final block(s): tail + 0x80 + zero pad + 64-bit big-endian bit length. Fits in + // one block when tail <= 55 bytes, otherwise spills into a second block. + 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); + + // SHA-224 keeps only the first seven state words. + 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]); + } + } + + /// + /// 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) + { + 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/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/UuidCodec.cs b/QuickProxyNet/Internal/UuidCodec.cs new file mode 100644 index 0000000..df92aac --- /dev/null +++ b/QuickProxyNet/Internal/UuidCodec.cs @@ -0,0 +1,44 @@ +namespace QuickProxyNet; + +/// +/// Encodes a canonical UUID string into its 16-byte RFC 4122 (network / big-endian) +/// representation, as required by the VLESS and VMess wire formats. +/// +/// +/// 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. +/// +internal static class UuidCodec +{ + public const int Size = 16; + + /// + /// Writes the 16 big-endian bytes of into . + /// + /// is not a valid UUID. + public static void WriteBigEndian(ReadOnlySpan id, Span dest) + { + if (!TryWriteBigEndian(id, dest)) + throw new FormatException( + $"VLESS/VMess user id must be a canonical UUID; got '{id.ToString()}'."); + } + + /// + /// Attempts to write the 16 big-endian bytes of into + /// . Returns without throwing if the + /// id is not a valid UUID or the destination is too small. + /// + public static bool TryWriteBigEndian(ReadOnlySpan id, Span dest) + { + if (dest.Length < Size) + return false; + + // Guid is a struct — TryParse + big-endian TryWriteBytes is fully zero-allocation. + // bigEndian:true (net8+) yields RFC 4122 order == the canonical string byte order. + if (!Guid.TryParse(id, out var guid)) + return false; + + return guid.TryWriteBytes(dest, bigEndian: true, out _); + } +} diff --git a/QuickProxyNet/Internal/VlessHelper.cs b/QuickProxyNet/Internal/VlessHelper.cs new file mode 100644 index 0000000..170ff5a --- /dev/null +++ b/QuickProxyNet/Internal/VlessHelper.cs @@ -0,0 +1,82 @@ +using System.Buffers; +using System.Buffers.Binary; + +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), read in full +/// so the returned stream starts exactly at the target's first byte. +/// +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; + + // ver(1) + uuid(16) + addonsLen(1) + cmd(1) + port(2) + max address. + private const int MaxRequestSize = 1 + UuidCodec.Size + 1 + 1 + 2 + ProxyAddress.MaxLength; + + internal static async ValueTask EstablishVlessTunnelAsync( + Stream stream, VlessOptions options, string host, int port, CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(MaxRequestSize); + try + { + int length = BuildRequest(buffer, options.Id, host, port); + await stream.WriteAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + + try + { + // Response header: ver(1) + addonsLen(1). + await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); + if (buffer[0] != Version) + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + $"Unexpected VLESS response version. Expected 0x00, got 0x{buffer[0]:X2}."); + + // addonsLen is a single byte (<= 255 < buffer length), so the rented buffer + // always holds it. Content is unused for plain TCP; draining it positions the + // stream at the target's first response byte. + int addonsLength = buffer[1]; + if (addonsLength > 0) + await stream.ReadExactlyAsync(buffer.AsMemory(0, addonsLength), cancellationToken) + .ConfigureAwait(false); + } + catch (EndOfStreamException ex) + { + // A short/closed response is the primary VLESS failure signal (e.g. wrong + // UUID: many servers 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); + } + } + 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); + } + } + + internal static int BuildRequest(Span buffer, ReadOnlySpan id, string host, int port) + { + buffer[0] = Version; + UuidCodec.WriteBigEndian(id, buffer.Slice(1, UuidCodec.Size)); + buffer[17] = 0x00; // addons length + buffer[18] = CommandTcp; + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(19), (ushort)port); + int addressLength = + ProxyAddress.WriteTypeAndAddress(host, buffer.Slice(21), AtypIPv4, AtypDomain, AtypIPv6); + return 21 + addressLength; + } +} diff --git a/QuickProxyNet/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs index f423b94..5ed3ebd 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -22,6 +22,15 @@ public sealed class ProxyClientFactory /// Thrown if the URI scheme is not supported. 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)); + NetworkCredential? credential = null; ProxyType type = proxyUri.Scheme switch { diff --git a/QuickProxyNet/ProxyProtocolException.cs b/QuickProxyNet/ProxyProtocolException.cs index 51200e1..011136b 100644 --- a/QuickProxyNet/ProxyProtocolException.cs +++ b/QuickProxyNet/ProxyProtocolException.cs @@ -52,5 +52,7 @@ 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 } diff --git a/QuickProxyNet/ProxyType.cs b/QuickProxyNet/ProxyType.cs index 35646e3..21b1806 100644 --- a/QuickProxyNet/ProxyType.cs +++ b/QuickProxyNet/ProxyType.cs @@ -1,4 +1,4 @@ -namespace QuickProxyNet; +namespace QuickProxyNet; public enum ProxyType { @@ -6,5 +6,10 @@ public enum ProxyType Https, Socks4, Socks4a, - Socks5 -} \ No newline at end of file + Socks5, + Vless, + Vmess, + Trojan, + Hysteria2, + Tuic +} diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index c7f9a58..d156b2d 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -41,6 +41,7 @@ + diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..337d5d6 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,183 @@ +# План реализации 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`. + +Корпус для проверки корректности — реальные 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 | uTLS fingerprint | нет | Очень высокий | отдельно | + +Обоснование порядка: 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`. + +**Фаза 4 — QUIC (Hysteria2/TUIC):** отдельный пакет `QuickProxyNet.Quic` на +`System.Net.Quic`, lifecycle одного QUIC-соединения на несколько стримов. + +**Отдельно:** VLESS REALITY / XTLS-vision (uTLS fingerprint — не покрывается +стандартным `SslStream`). + +## 6. Границы фазы 1 (honest scope) + +Поддерживается: `vless://` с `security=none` и `security=tls`, транспорт +`tcp`/`raw`, команда TCP, адреса IPv4/IPv6/domain, `sni`/`alpn`. +Явно НЕ поддерживается (кидаем `NotSupportedException`): `reality`, непустой +`flow`, транспорты `ws`/`grpc`/`xhttp`/`httpupgrade`, команды UDP/Mux. From 20181d695a1266a9f232f2d94a485596db40ee98 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 23 Jul 2026 22:34:12 +0500 Subject: [PATCH 03/25] feat: add VMess (VMessAEAD, alterId=0) proxy protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full VMessAEAD client on the existing ConnectAsync(...) -> Stream model, alongside VLESS and Trojan. Crypto primitives: - VmessKdf: the nested/recursive HMAC-SHA256 KDF (an HMAC whose hash function is another HMAC — not expressible with HMACSHA256) - VmessCmdKey, VmessAuthId (CRC32-IEEE + AES-128-ECB), Crc32, Fnv1a32, VmessBodyKeys (ChaCha20 MD5 key expansion, response key/IV) Request path: - VmessRequest: command section (port before address, atyp 01/02/03, FNV-1a-32 checksum) + AEAD envelope authid(16) | encLen(18) | connNonce(8) | encHeader(L+16) - All randomness/time injectable via VmessRequestMaterial so the wire bytes are pinned byte-exactly in tests Body path: - VmessStream: chunked AEAD stream, per-chunk nonce (uint16 BE counter | bodyIV[2..12]), independent read/write counters, AES-128-GCM and ChaCha20-Poly1305 (gated on IsSupported) - Clean EOF is ONLY the authenticated empty chunk; truncation and tag failure are hard errors, never EOF - VmessResponse + VmessResponseStream: the response header is read LAZILY on first read. Reading it eagerly in ConnectAsync deadlocks every client-speaks-first protocol (HTTP, TLS, Minecraft), because v2ray/Xray only flush it after the target replies. Config/client: - VmessOptions, VmessShareLink (base64 JSON, URL-safe + unpadded), VmessClient, vmess scheme in the factory - alterId != 0 rejected: legacy MD5 auth is not implemented - Request option byte is 0x01 (ChunkStream only), never 0x1D — the stream implements baseline framing, so announcing M/P/A would make the server mask chunk lengths and desync Ground truth for every vector comes from an independent Python reference that first reproduces the previously committed KDF vectors. 334 tests; multi-target net8.0/net9.0/net10.0; benchmarks included. Co-Authored-By: Claude Fable 5 --- QuickProxyNet.Benchmarks/VmessBenchmark.cs | 304 +++++ .../VmessStreamBenchmark.cs | 124 ++ .../Helpers/DuplexTestStream.cs | 87 ++ .../Helpers/ScriptedDuplexStream.cs | 107 ++ QuickProxyNet.Tests/VmessBodyTest.cs | 919 ++++++++++++++ QuickProxyNet.Tests/VmessClientTest.cs | 1073 +++++++++++++++++ QuickProxyNet.Tests/VmessCryptoTest.cs | 252 ++++ QuickProxyNet.Tests/VmessRequestTest.cs | 733 +++++++++++ QuickProxyNet/Clients/VmessClient.cs | 284 +++++ QuickProxyNet/Configs/VmessOptions.cs | 141 +++ QuickProxyNet/Configs/VmessShareLink.cs | 475 ++++++++ QuickProxyNet/Internal/Crc32.cs | 50 + QuickProxyNet/Internal/Fnv1a32.cs | 38 + QuickProxyNet/Internal/VmessAuthId.cs | 117 ++ QuickProxyNet/Internal/VmessBodyKeys.cs | 53 + QuickProxyNet/Internal/VmessCmdKey.cs | 56 + QuickProxyNet/Internal/VmessKdf.cs | 201 +++ QuickProxyNet/Internal/VmessRequest.cs | 373 ++++++ QuickProxyNet/Internal/VmessResponse.cs | 269 +++++ QuickProxyNet/Internal/VmessResponseStream.cs | 235 ++++ QuickProxyNet/Internal/VmessStream.cs | 568 +++++++++ QuickProxyNet/ProxyClientFactory.cs | 13 + docs/implementation-plan.md | 21 + docs/vmess-aead-body.md | 450 +++++++ docs/vmess-aead-request.md | 471 ++++++++ 25 files changed, 7414 insertions(+) create mode 100644 QuickProxyNet.Benchmarks/VmessBenchmark.cs create mode 100644 QuickProxyNet.Benchmarks/VmessStreamBenchmark.cs create mode 100644 QuickProxyNet.Tests/Helpers/DuplexTestStream.cs create mode 100644 QuickProxyNet.Tests/Helpers/ScriptedDuplexStream.cs create mode 100644 QuickProxyNet.Tests/VmessBodyTest.cs create mode 100644 QuickProxyNet.Tests/VmessClientTest.cs create mode 100644 QuickProxyNet.Tests/VmessCryptoTest.cs create mode 100644 QuickProxyNet.Tests/VmessRequestTest.cs create mode 100644 QuickProxyNet/Clients/VmessClient.cs create mode 100644 QuickProxyNet/Configs/VmessOptions.cs create mode 100644 QuickProxyNet/Configs/VmessShareLink.cs create mode 100644 QuickProxyNet/Internal/Crc32.cs create mode 100644 QuickProxyNet/Internal/Fnv1a32.cs create mode 100644 QuickProxyNet/Internal/VmessAuthId.cs create mode 100644 QuickProxyNet/Internal/VmessBodyKeys.cs create mode 100644 QuickProxyNet/Internal/VmessCmdKey.cs create mode 100644 QuickProxyNet/Internal/VmessKdf.cs create mode 100644 QuickProxyNet/Internal/VmessRequest.cs create mode 100644 QuickProxyNet/Internal/VmessResponse.cs create mode 100644 QuickProxyNet/Internal/VmessResponseStream.cs create mode 100644 QuickProxyNet/Internal/VmessStream.cs create mode 100644 docs/vmess-aead-body.md create mode 100644 docs/vmess-aead-request.md diff --git a/QuickProxyNet.Benchmarks/VmessBenchmark.cs b/QuickProxyNet.Benchmarks/VmessBenchmark.cs new file mode 100644 index 0000000..d38ab8f --- /dev/null +++ b/QuickProxyNet.Benchmarks/VmessBenchmark.cs @@ -0,0 +1,304 @@ +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 +{ + private class Config : ManualConfig + { + public Config() => AddJob(Job.ShortRun.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/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/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/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/VmessBodyTest.cs b/QuickProxyNet.Tests/VmessBodyTest.cs new file mode 100644 index 0000000..ee56f7c --- /dev/null +++ b/QuickProxyNet.Tests/VmessBodyTest.cs @@ -0,0 +1,919 @@ +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 Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + } + + [Fact] + public async Task ReadResponseHeader_EmptyStream_IsAnErrorNotEof() + { + var transport = new DuplexTestStream([]); + + await Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + } + + [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 Assert.ThrowsAsync(async () => + await VmessResponse.ReadAsync( + transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + } + + [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)); + + Assert.Equal(ProxyErrorCode.InvalidResponse, 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.InvalidResponse, 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..f268dd0 --- /dev/null +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -0,0 +1,1073 @@ +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.True(o.IsRawTcp); + 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_NonTcpTransport_ParsesButIsNotRawTcp() + { + // Parsed so callers can inspect it; rejected at connect time, not here. + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"net\":\"ws\""))); + Assert.Equal("ws", o.Transport); + Assert.False(o.IsRawTcp); + } + + [Theory] + [InlineData("tcp")] + [InlineData("raw")] + public void Parse_RawTcpTransports(string net) + { + Assert.True(VmessShareLink.Parse(Link(MinimalJson(extra: $",\"net\":\"{net}\""))).IsRawTcp); + } + + // ================================ 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 _)); + Assert.False(VmessShareLink.TryParse(Link(MinimalJson(id: "not-a-uuid")), out _)); + Assert.False(VmessShareLink.TryParse( + Link(MinimalJson(id: "11223344-5566-7788-99aa-bbccddeeff")), out _)); + } + + [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("not-a-uuid")] + [InlineData("11223344-5566-7788-99aa-bbccddeeff")] + public void Client_InvalidUuid_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("ws")] + [InlineData("grpc")] + [InlineData("h2")] + [InlineData("httpupgrade")] + 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.InvalidResponse, 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..10ffd56 --- /dev/null +++ b/QuickProxyNet.Tests/VmessRequestTest.cs @@ -0,0 +1,733 @@ +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_InvalidUuid_Throws() + { + Assert.Throws(() => + { + Span dst = stackalloc byte[16]; + VmessCmdKey.Derive("not-a-uuid", dst); + }); + } + + // ========================= §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/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs new file mode 100644 index 0000000..6a978ca --- /dev/null +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -0,0 +1,284 @@ +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. +/// +/// +/// Only tcp/raw transport is supported; ws, 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). + if (!Guid.TryParse(options.Id, out _)) + throw new ArgumentException($"VMess user id '{options.Id}' is not a valid 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(); + + Stream transport = stream; + if (Options.UseTls) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + try + { + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); + } + catch + { + // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. + await ssl.DisposeAsync().ConfigureAwait(false); + throw; + } + + transport = ssl; + } + + 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 transport.WriteAsync(request.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + + return CreateBodyStream(transport, session, responseVerifier, security); + } + catch + { + // Owns the TLS session as well when one was established. A half-built + // VmessResponseStream holds no unmanaged state, so disposing the transport is + // enough to release everything. + await transport.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() + { + if (!Options.IsRawTcp) + throw new NotSupportedException( + $"VMess transport '{Options.Transport}' is not supported; only 'tcp'/'raw' is 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() + { + TargetHost = Options.Sni ?? 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/VmessOptions.cs b/QuickProxyNet/Configs/VmessOptions.cs new file mode 100644 index 0000000..dd8f019 --- /dev/null +++ b/QuickProxyNet/Configs/VmessOptions.cs @@ -0,0 +1,141 @@ +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. +/// +/// +/// Only tcp/raw transport is supported at connect time in this release, with +/// or without TLS. Other transports (ws, 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: tcp or raw (both raw TCP). Others are unsupported. + public string Transport { get; init; } = "tcp"; + + /// + /// 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; } + + /// True when the transport is plain TCP (tcp or raw). + internal bool IsRawTcp => + Transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || + Transport.Equals("raw", StringComparison.OrdinalIgnoreCase); + + /// + /// 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..f680341 --- /dev/null +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -0,0 +1,475 @@ +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; + +namespace QuickProxyNet; + +/// +/// Parses vmess:// share links into . +/// +/// +/// +/// Grammar: vmess:// followed by base64-encoded UTF-8 JSON (the "v2rayN" format). +/// Both the standard and the URL-safe base64 alphabets are accepted, with or without +/// padding, and embedded whitespace is ignored — real-world links violate all three rules. +/// +/// +/// 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 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), out options, out error); + } + finally + { + ArrayPool.Shared.Return(json, clearArray: true); + } + } + + /// + /// 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, + [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; + 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("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 '{id}' is not a valid 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 = + $"Unrecognized VMess security '{scy}': only 'auto', 'aes-128-gcm' and " + + "'chacha20-poly1305' are supported."; + return false; + } + + // ---- net / type ---- + string? net = GetString(netField); + string transport = string.IsNullOrEmpty(net) ? "tcp" : net; + + string? headerType = GetString(typeField); + if (!string.IsNullOrEmpty(headerType) && + !headerType.Equals("none", StringComparison.OrdinalIgnoreCase)) + { + // 'type' is header obfuscation (e.g. "http"), not the transport. Anything + // other than "none" wraps the VMess stream in a framing we do not produce. + error = $"VMess header obfuscation type '{headerType}' is not supported; only 'none' is."; + 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), + AllowInsecure = GetBoolean(allowInsecureField) || GetBoolean(skipCertVerifyField), + Remark = GetString(psField) + }; + error = null; + return true; + } + } + + 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/Crc32.cs b/QuickProxyNet/Internal/Crc32.cs new file mode 100644 index 0000000..35ae9f5 --- /dev/null +++ b/QuickProxyNet/Internal/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/Fnv1a32.cs b/QuickProxyNet/Internal/Fnv1a32.cs new file mode 100644 index 0000000..4b0e070 --- /dev/null +++ b/QuickProxyNet/Internal/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/VmessAuthId.cs b/QuickProxyNet/Internal/VmessAuthId.cs new file mode 100644 index 0000000..3cf93e3 --- /dev/null +++ b/QuickProxyNet/Internal/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/VmessBodyKeys.cs b/QuickProxyNet/Internal/VmessBodyKeys.cs new file mode 100644 index 0000000..6fcb4e0 --- /dev/null +++ b/QuickProxyNet/Internal/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/VmessCmdKey.cs b/QuickProxyNet/Internal/VmessCmdKey.cs new file mode 100644 index 0000000..7bec45c --- /dev/null +++ b/QuickProxyNet/Internal/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/VmessKdf.cs b/QuickProxyNet/Internal/VmessKdf.cs new file mode 100644 index 0000000..4901ba7 --- /dev/null +++ b/QuickProxyNet/Internal/VmessKdf.cs @@ -0,0 +1,201 @@ +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: .NET exposes no SHA-256 +/// midstate export, so the ipad/opad prefixes cannot be pre-hashed once and reused. +/// 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. +/// +/// +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/VmessRequest.cs b/QuickProxyNet/Internal/VmessRequest.cs new file mode 100644 index 0000000..70b04fe --- /dev/null +++ b/QuickProxyNet/Internal/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/VmessResponse.cs b/QuickProxyNet/Internal/VmessResponse.cs new file mode 100644 index 0000000..970d5b3 --- /dev/null +++ b/QuickProxyNet/Internal/VmessResponse.cs @@ -0,0 +1,269 @@ +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); + } + 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); + } + 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) + { + throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, + "VMess response header length block failed authentication.", ex); + } + + return BinaryPrimitives.ReadUInt16BigEndian(plaintext); + } + + 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.InvalidResponse, + "VMess response header failed authentication.", 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/VmessResponseStream.cs b/QuickProxyNet/Internal/VmessResponseStream.cs new file mode 100644 index 0000000..14d8b94 --- /dev/null +++ b/QuickProxyNet/Internal/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/VmessStream.cs b/QuickProxyNet/Internal/VmessStream.cs new file mode 100644 index 0000000..dc26d6f --- /dev/null +++ b/QuickProxyNet/Internal/VmessStream.cs @@ -0,0 +1,568 @@ +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 . 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. + 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; + _reader.Open( + _receiveSealed!.AsSpan(0, plaintextLength), + _receiveSealed.AsSpan(plaintextLength, TagSize), + plaintext.Span); + } + + // ================================ 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) + { + // A broken transport must not turn disposal into a failure. + } + } + } + finally + { + _disposed = true; + ReleaseResources(); + if (!_leaveInnerOpen) + await _inner.DisposeAsync(); + } + + 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) + { + // See DisposeAsync. + } + } + } + finally + { + _disposed = true; + ReleaseResources(); + if (!_leaveInnerOpen) + _inner.Dispose(); + } + } + 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/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs index 5ed3ebd..a5f289d 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -20,6 +20,15 @@ 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, @@ -31,6 +40,10 @@ public IProxyClient Create(Uri proxyUri) 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 { diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 337d5d6..4522dc0 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -169,6 +169,27 @@ BenchmarkDotNet 0.15.8, .NET 10, Xeon E5-2697 v4, ShortRun/InProcessNoEmit **Фаза 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 — QUIC (Hysteria2/TUIC):** отдельный пакет `QuickProxyNet.Quic` на `System.Net.Quic`, lifecycle одного QUIC-соединения на несколько стримов. 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. From b5e7b8902d874ed65cc91f66ae165e0a8e11877d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 24 Jul 2026 11:14:48 +0500 Subject: [PATCH 04/25] refactor: group Internal/ by concern (Crypto, Vmess) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal/ had grown to 18 files mixing protocol-agnostic crypto primitives, shared utilities and per-protocol helpers. - Internal/Crypto/: Crc32, Fnv1a32, Sha224, UuidCodec - Internal/Vmess/: VmessAuthId, VmessBodyKeys, VmessCmdKey, VmessKdf, VmessRequest, VmessResponse, VmessResponseStream, VmessStream - Internal/ root keeps the six shared/single-helper files (ProxyAddress, HttpHelper, HttpResponseParser, SocksHelper, VlessHelper, TrojanHelper) — no folder-per-single-file. Pure file moves: all 12 are git renames with byte-identical content and every file still declares the flat `namespace QuickProxyNet;`. That flat namespace is load-bearing — it is what lets files be reorganised without touching the public API or forcing `using` churn on consumers — so .editorconfig now records the decision explicitly (dotnet_style_namespace_match_folder = false) instead of leaving the IDE0130 guidance to be "fixed" by a later renaming that would break the API. No behaviour change: 334/334 tests, clean Release build on net8.0/net9.0/net10.0. Co-Authored-By: Claude Fable 5 --- .editorconfig | 9 +++++++++ QuickProxyNet/Internal/{ => Crypto}/Crc32.cs | 0 QuickProxyNet/Internal/{ => Crypto}/Fnv1a32.cs | 0 QuickProxyNet/Internal/{ => Crypto}/Sha224.cs | 0 QuickProxyNet/Internal/{ => Crypto}/UuidCodec.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessAuthId.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessBodyKeys.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessCmdKey.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessKdf.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessRequest.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessResponse.cs | 0 .../Internal/{ => Vmess}/VmessResponseStream.cs | 0 QuickProxyNet/Internal/{ => Vmess}/VmessStream.cs | 0 13 files changed, 9 insertions(+) create mode 100644 .editorconfig rename QuickProxyNet/Internal/{ => Crypto}/Crc32.cs (100%) rename QuickProxyNet/Internal/{ => Crypto}/Fnv1a32.cs (100%) rename QuickProxyNet/Internal/{ => Crypto}/Sha224.cs (100%) rename QuickProxyNet/Internal/{ => Crypto}/UuidCodec.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessAuthId.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessBodyKeys.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessCmdKey.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessKdf.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessRequest.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessResponse.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessResponseStream.cs (100%) rename QuickProxyNet/Internal/{ => Vmess}/VmessStream.cs (100%) 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/QuickProxyNet/Internal/Crc32.cs b/QuickProxyNet/Internal/Crypto/Crc32.cs similarity index 100% rename from QuickProxyNet/Internal/Crc32.cs rename to QuickProxyNet/Internal/Crypto/Crc32.cs diff --git a/QuickProxyNet/Internal/Fnv1a32.cs b/QuickProxyNet/Internal/Crypto/Fnv1a32.cs similarity index 100% rename from QuickProxyNet/Internal/Fnv1a32.cs rename to QuickProxyNet/Internal/Crypto/Fnv1a32.cs diff --git a/QuickProxyNet/Internal/Sha224.cs b/QuickProxyNet/Internal/Crypto/Sha224.cs similarity index 100% rename from QuickProxyNet/Internal/Sha224.cs rename to QuickProxyNet/Internal/Crypto/Sha224.cs diff --git a/QuickProxyNet/Internal/UuidCodec.cs b/QuickProxyNet/Internal/Crypto/UuidCodec.cs similarity index 100% rename from QuickProxyNet/Internal/UuidCodec.cs rename to QuickProxyNet/Internal/Crypto/UuidCodec.cs diff --git a/QuickProxyNet/Internal/VmessAuthId.cs b/QuickProxyNet/Internal/Vmess/VmessAuthId.cs similarity index 100% rename from QuickProxyNet/Internal/VmessAuthId.cs rename to QuickProxyNet/Internal/Vmess/VmessAuthId.cs diff --git a/QuickProxyNet/Internal/VmessBodyKeys.cs b/QuickProxyNet/Internal/Vmess/VmessBodyKeys.cs similarity index 100% rename from QuickProxyNet/Internal/VmessBodyKeys.cs rename to QuickProxyNet/Internal/Vmess/VmessBodyKeys.cs diff --git a/QuickProxyNet/Internal/VmessCmdKey.cs b/QuickProxyNet/Internal/Vmess/VmessCmdKey.cs similarity index 100% rename from QuickProxyNet/Internal/VmessCmdKey.cs rename to QuickProxyNet/Internal/Vmess/VmessCmdKey.cs diff --git a/QuickProxyNet/Internal/VmessKdf.cs b/QuickProxyNet/Internal/Vmess/VmessKdf.cs similarity index 100% rename from QuickProxyNet/Internal/VmessKdf.cs rename to QuickProxyNet/Internal/Vmess/VmessKdf.cs diff --git a/QuickProxyNet/Internal/VmessRequest.cs b/QuickProxyNet/Internal/Vmess/VmessRequest.cs similarity index 100% rename from QuickProxyNet/Internal/VmessRequest.cs rename to QuickProxyNet/Internal/Vmess/VmessRequest.cs diff --git a/QuickProxyNet/Internal/VmessResponse.cs b/QuickProxyNet/Internal/Vmess/VmessResponse.cs similarity index 100% rename from QuickProxyNet/Internal/VmessResponse.cs rename to QuickProxyNet/Internal/Vmess/VmessResponse.cs diff --git a/QuickProxyNet/Internal/VmessResponseStream.cs b/QuickProxyNet/Internal/Vmess/VmessResponseStream.cs similarity index 100% rename from QuickProxyNet/Internal/VmessResponseStream.cs rename to QuickProxyNet/Internal/Vmess/VmessResponseStream.cs diff --git a/QuickProxyNet/Internal/VmessStream.cs b/QuickProxyNet/Internal/Vmess/VmessStream.cs similarity index 100% rename from QuickProxyNet/Internal/VmessStream.cs rename to QuickProxyNet/Internal/Vmess/VmessStream.cs From d0938b4df4e5999b8f6fa768ff2b14f18b49803d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 14 Aug 2026 15:10:46 +0500 Subject: [PATCH 05/25] docs: add Hysteria2 and TUIC protocol analysis Wire-level notes for the two QUIC-based protocols, and the architectural problem that blocks them: one QUIC connection multiplexes many streams, which does not fit this library's "one ConnectAsync, one socket" model. Neither is implemented; this records what implementing them would require. Co-Authored-By: Claude Opus 5 (1M context) --- docs/quic-protocols-analysis.md | 795 ++++++++++++++++++++++++++++++++ 1 file changed, 795 insertions(+) create mode 100644 docs/quic-protocols-analysis.md 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) From 74560d388dbd90e4501d310cee0276ccac726823 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 14 Aug 2026 15:10:55 +0500 Subject: [PATCH 06/25] tools: add CorpusCheck share-link diagnostic Runs the vless/trojan/vmess share-link parsers over ~21k real-world links and groups failures by reason, with an optional --live mode that connects to sampled nodes. Deliberately kept out of QuickProxyNet.slnx: it is a hand-run diagnostic, and keeping it out of the solution keeps it out of CI. The corpus is downloaded to a temp directory and never committed - it contains real IPs, UUIDs and passwords belonging to other people - and every example in the report is redacted to a shape. Co-Authored-By: Claude Opus 5 (1M context) --- tools/CorpusCheck/CorpusCheck.csproj | 24 + tools/CorpusCheck/LiveProbe.cs | 569 ++++++++++++++++++++ tools/CorpusCheck/Program.cs | 745 +++++++++++++++++++++++++++ 3 files changed, 1338 insertions(+) create mode 100644 tools/CorpusCheck/CorpusCheck.csproj create mode 100644 tools/CorpusCheck/LiveProbe.cs create mode 100644 tools/CorpusCheck/Program.cs 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://"; + } +} From ca2739400b2a0c727f3585fe8b46ced73d31b099 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 14 Aug 2026 15:11:31 +0500 Subject: [PATCH 07/25] feat: add ws/httpupgrade transports and a real-server test harness Widens VPN protocol coverage from 13.3% to 45.4% of a 21403-link real-world corpus, measured as links that can actually connect - not links that parse. Transports ---------- New Internal/Transports/ layer shared by VLESS, VMess and Trojan: ws/websocket and httpupgrade. Layering is socket -> optional SslStream -> transport -> protocol header, so one implementation serves all three. RFC 6455 framing comes from the BCL (WebSocket.CreateFromStream) rather than hand-rolled code: 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 properties the adapter must keep: a zero-length binary frame is not EOF, and message boundaries are deliberately not preserved because a proxy tunnel is a byte stream. httpupgrade must NOT send Sec-WebSocket-Key. Both transports advertise "Upgrade: websocket" - that camouflage is the point of httpupgrade - but sing-box routes any request carrying the 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. Measured directly: the key alone triggers it, Sec-WebSocket-Version on its own still upgrades. grpc, xhttp and h2 remain rejected with NotSupportedException before any byte is written. REALITY stays unsupported: it needs a uTLS ClientHello fingerprint SslStream cannot produce. vmess share links ----------------- "security=" carries two different meanings in the wild. The URI grammar defines it as 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 rather than guessed. "none" keeps its documented meaning; both readings agree there is no TLS, so nothing is downgraded. Safe in a way the VLESS case was not: VMessAEAD seals the header under a key derived from the id, so a wrong guess costs a failed handshake, never a cleartext id. vmess parse rate over the corpus: 72.9% -> 99.9%. Testing ------- Docker harness (tests/docker/) running real Xray and sing-box, gated on QPN_DOCKER_TESTS=1 through discovery-time attributes so an unconfigured test reports as skipped, never as passed. Covers vless-ws, vmess-ws, trojan-ws over TLS and httpupgrade against both servers, plus a wrong-path negative case so the positives cannot pass vacuously. 427 tests pass, 3 skipped (external proxies, unconfigured). Zero warnings across net8.0/net9.0/net10.0. Also records the roadmap correction in docs/implementation-plan.md: QUIC (Hysteria2/TUIC) was phase 4 only because it came next in the document. It is the heaviest architectural work in the plan and buys 2.2% of the corpus, so it is deprioritized behind gRPC. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 4 + .gitignore | 3 + AGENTS.md | 271 ++++++++++++- QuickProxyNet.Benchmarks/Sha224Benchmark.cs | 13 +- .../Sha256CompressionBenchmark.cs | 242 +++++++++++ QuickProxyNet.Benchmarks/VmessBenchmark.cs | 15 +- QuickProxyNet.Benchmarks/VmessKdfBenchmark.cs | 361 +++++++++++++++++ QuickProxyNet.Tests/ConnectTest.cs | 24 +- .../Helpers/FakeWebSocketServer.cs | 307 ++++++++++++++ .../Integration/DockerComposeFixture.cs | 197 +++++++++ .../Integration/DockerEndpoints.cs | 121 ++++++ .../Integration/DockerProtocolTests.cs | 383 ++++++++++++++++++ QuickProxyNet.Tests/Sha256CoreTest.cs | 222 ++++++++++ QuickProxyNet.Tests/SkipGates.cs | 119 ++++++ QuickProxyNet.Tests/TransportTest.cs | 353 ++++++++++++++++ QuickProxyNet.Tests/TrojanTest.cs | 4 +- QuickProxyNet.Tests/VlessTest.cs | 226 +++++++++-- QuickProxyNet.Tests/VmessClientTest.cs | 244 ++++++++++- QuickProxyNet.Tests/VmessRequestTest.cs | 26 +- QuickProxyNet/Clients/TrojanClient.cs | 42 +- QuickProxyNet/Clients/VlessClient.cs | 81 ++-- QuickProxyNet/Clients/VmessClient.cs | 71 ++-- QuickProxyNet/Configs/TrojanOptions.cs | 29 +- QuickProxyNet/Configs/TrojanShareLink.cs | 24 +- QuickProxyNet/Configs/VlessOptions.cs | 31 +- QuickProxyNet/Configs/VlessShareLink.cs | 35 +- QuickProxyNet/Configs/VmessOptions.cs | 27 +- QuickProxyNet/Configs/VmessShareLink.cs | 328 ++++++++++++++- QuickProxyNet/Internal/Crypto/Sha224.cs | 144 +------ QuickProxyNet/Internal/Crypto/Sha256.cs | 46 +++ QuickProxyNet/Internal/Crypto/Sha256Core.cs | 295 ++++++++++++++ QuickProxyNet/Internal/Crypto/UuidCodec.cs | 72 +++- QuickProxyNet/Internal/HttpHelper.cs | 72 +--- QuickProxyNet/Internal/HttpResponseParser.cs | 12 + QuickProxyNet/Internal/PrefixedStream.cs | 83 ++++ QuickProxyNet/Internal/ShareLinkQuery.cs | 32 ++ .../Transports/HttpUpgradeHandshake.cs | 251 ++++++++++++ .../Internal/Transports/ProxyTransport.cs | 120 ++++++ .../Internal/Transports/WebSocketStream.cs | 147 +++++++ QuickProxyNet/Internal/VlessHelper.cs | 43 +- QuickProxyNet/Internal/VlessResponseStream.cs | 209 ++++++++++ QuickProxyNet/Internal/Vmess/VmessKdf.cs | 31 +- QuickProxyNet/ProxyProtocolException.cs | 7 +- docs/implementation-plan.md | 55 ++- tests/docker/README.md | 134 ++++++ tests/docker/certs/server.crt | 20 + tests/docker/certs/server.key | 28 ++ tests/docker/docker-compose.yml | 80 ++++ tests/docker/echo/serve.sh | 17 + tests/docker/singbox/config.json | 159 ++++++++ tests/docker/xray/config.json | 231 +++++++++++ 51 files changed, 5641 insertions(+), 450 deletions(-) create mode 100644 QuickProxyNet.Benchmarks/Sha256CompressionBenchmark.cs create mode 100644 QuickProxyNet.Benchmarks/VmessKdfBenchmark.cs create mode 100644 QuickProxyNet.Tests/Helpers/FakeWebSocketServer.cs create mode 100644 QuickProxyNet.Tests/Integration/DockerComposeFixture.cs create mode 100644 QuickProxyNet.Tests/Integration/DockerEndpoints.cs create mode 100644 QuickProxyNet.Tests/Integration/DockerProtocolTests.cs create mode 100644 QuickProxyNet.Tests/Sha256CoreTest.cs create mode 100644 QuickProxyNet.Tests/SkipGates.cs create mode 100644 QuickProxyNet.Tests/TransportTest.cs create mode 100644 QuickProxyNet/Internal/Crypto/Sha256.cs create mode 100644 QuickProxyNet/Internal/Crypto/Sha256Core.cs create mode 100644 QuickProxyNet/Internal/PrefixedStream.cs create mode 100644 QuickProxyNet/Internal/ShareLinkQuery.cs create mode 100644 QuickProxyNet/Internal/Transports/HttpUpgradeHandshake.cs create mode 100644 QuickProxyNet/Internal/Transports/ProxyTransport.cs create mode 100644 QuickProxyNet/Internal/Transports/WebSocketStream.cs create mode 100644 QuickProxyNet/Internal/VlessResponseStream.cs create mode 100644 tests/docker/README.md create mode 100644 tests/docker/certs/server.crt create mode 100644 tests/docker/certs/server.key create mode 100644 tests/docker/docker-compose.yml create mode 100644 tests/docker/echo/serve.sh create mode 100644 tests/docker/singbox/config.json create mode 100644 tests/docker/xray/config.json 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/.gitignore b/.gitignore index 442a07d..efabca3 100644 --- a/.gitignore +++ b/.gitignore @@ -363,3 +363,6 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd + +# Local agent tooling session state +.omc/ diff --git a/AGENTS.md b/AGENTS.md index 0494983..c44fb6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ ## Project Overview QuickProxyNet is a high-performance C#/.NET library for opening direct `Stream` -connections through proxy protocols. The current core library supports HTTP, -HTTPS, SOCKS4, SOCKS4a, and SOCKS5. +connections through proxy protocols. It covers the classic proxy family (HTTP, +HTTPS, SOCKS4, SOCKS4a, SOCKS5) and the VPN-style family (VLESS, Trojan, VMess). - NuGet package: `QuickProxyNet` - Author: Titlehhhh @@ -20,8 +20,13 @@ 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. @@ -32,8 +37,11 @@ All public library types live in the `QuickProxyNet` namespace. `ValueTask`. - `ProxyClient` owns common socket setup, timeout handling, and argument validation. -- `ProxyClientFactory` creates clients from `Uri` or explicit proxy settings. +- `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. ## Current Protocol Implementations @@ -44,18 +52,167 @@ All public library types live in the `QuickProxyNet` namespace. | `Socks4Client` | SOCKS4 | | `Socks4aClient` | SOCKS4a | | `Socks5Client` | SOCKS5 with optional username/password auth | +| `VlessClient` | VLESS, `security=none` or `tls` | +| `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/`: -- `HttpHelper.cs` -- `HttpResponseParser.cs` -- `SocksHelper.cs` +```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 +``` + +## 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. ## Development Rules - Keep hot protocol paths allocation-conscious: prefer `Span`, `Memory`, `ArrayPool`, `stackalloc`, and `ValueTask`. -- Return rented buffers in `finally`. +- 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. @@ -63,6 +220,11 @@ Internal protocol helpers live under `QuickProxyNet/Internal/`: - 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`, and `net10.0`. +- **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 @@ -72,9 +234,55 @@ Run the test project directly: dotnet test QuickProxyNet.Tests/QuickProxyNet.Tests.csproj ``` -Integration tests that require real proxies use environment variables such as -`HTTP_PROXY_URI` and `SOCKS5_PROXY_URI`; they no-op when the variables are not -set. +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 ~17k 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 @@ -88,8 +296,45 @@ NUKE build scripts are available from the repository root: 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 three 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 -Detailed notes for VLESS, VMess, Trojan, Hysteria2, hy2, and TUIC live in -`docs/`. Treat those documents as planning notes until implementation and -tests are added. +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 with `tools/CorpusCheck` over 21 403 real links (2026-08-14), counting what +can actually **connect**, not what parses. See `docs/implementation-plan.md` §7 for +the full table. + +| Blocker | Links | % of corpus | +| --- | ---: | ---: | +| REALITY (needs uTLS — `SslStream` cannot do it) | 10 653 | 49.8% | +| gRPC | 991 | 4.6% | +| xhttp (Xray-only) | 789 | 3.7% | +| Hysteria2 / TUIC (QUIC) | 472 | 2.2% | + +The point of that table: **QUIC is the worst remaining investment**, not the next +phase. It is the heaviest architectural work in the roadmap — it breaks the "one +`ConnectAsync`, one socket" model — and buys 2.2%. The old roadmap listed it as +phase 4 purely because it was next in the document, which is not a reason. REALITY +is half the corpus and is gated on a uTLS ClientHello, so it is a separate project +rather than a feature. diff --git a/QuickProxyNet.Benchmarks/Sha224Benchmark.cs b/QuickProxyNet.Benchmarks/Sha224Benchmark.cs index d995825..ba2b298 100644 --- a/QuickProxyNet.Benchmarks/Sha224Benchmark.cs +++ b/QuickProxyNet.Benchmarks/Sha224Benchmark.cs @@ -19,9 +19,20 @@ namespace QuickProxyNet.Benchmarks; [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() => AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + 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 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/VmessBenchmark.cs b/QuickProxyNet.Benchmarks/VmessBenchmark.cs index d38ab8f..c3cb200 100644 --- a/QuickProxyNet.Benchmarks/VmessBenchmark.cs +++ b/QuickProxyNet.Benchmarks/VmessBenchmark.cs @@ -23,9 +23,22 @@ namespace QuickProxyNet.Benchmarks; [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() => AddJob(Job.ShortRun.WithToolchain(InProcessNoEmitToolchain.Instance)); + 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"; 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.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/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/Integration/DockerComposeFixture.cs b/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs new file mode 100644 index 0000000..69f517e --- /dev/null +++ b/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs @@ -0,0 +1,197 @@ +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. +/// +/// +public sealed class DockerComposeFixture : IAsyncLifetime +{ + /// The fixed compose project name. Never generate this. + public const string ProjectName = "quickproxynet-test"; + + private bool _composeTouched; + + /// 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(); + + // 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() + { + if (!_composeTouched) + return; + + try + { + await ComposeAsync("down -v --remove-orphans", TimeSpan.FromMinutes(2)); + } + finally + { + _composeTouched = false; + } + } + + /// + /// 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/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..0d7eb98 --- /dev/null +++ b/QuickProxyNet.Tests/SkipGates.cs @@ -0,0 +1,119 @@ +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 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/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 index 8087ea2..c5f3eae 100644 --- a/QuickProxyNet.Tests/TrojanTest.cs +++ b/QuickProxyNet.Tests/TrojanTest.cs @@ -20,7 +20,7 @@ public void Parse_BasicPassword() Assert.Equal("example.com", o.Host); Assert.Equal(443, o.Port); Assert.Equal("tcp", o.Transport); - Assert.True(o.IsRawTcp); + Assert.Equal(TransportKind.RawTcp, o.TransportKind); Assert.Equal("node1", o.Remark); Assert.False(o.AllowInsecure); } @@ -174,7 +174,7 @@ public async Task Client_UnsupportedTransport_ThrowsNotSupported_BeforeTls() // AuthenticateAsClientAsync would fail with a different exception. var stream = new FakeProxyStream([]); var client = new TrojanClient( - TrojanShareLink.Parse("trojan://pw@example.com:443?type=ws")); + TrojanShareLink.Parse("trojan://pw@example.com:443?type=grpc")); await Assert.ThrowsAsync( () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index ea00dff..fc741fa 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -24,17 +24,89 @@ public void UuidCodec_WritesBigEndian_NotMixedEndian() } [Fact] - public void UuidCodec_RejectsGarbage() + 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.False(UuidCodec.TryWriteBigEndian("not-a-uuid", dest)); + 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_RejectsSmallDestination() + public void UuidCodec_Derivation_IsDeterministic() { - Span dest = stackalloc byte[8]; - Assert.False(UuidCodec.TryWriteBigEndian(Uuid, dest)); + 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) === @@ -102,7 +174,7 @@ public void Parse_SecurityNone_Defaults() Assert.Equal("example.com", o.Host); Assert.Equal(443, o.Port); Assert.Equal(VlessSecurity.None, o.Security); - Assert.True(o.IsRawTcp); + Assert.Equal(TransportKind.RawTcp, o.TransportKind); Assert.Equal("node1", o.Remark); } @@ -132,13 +204,33 @@ public void Parse_Reality_KeepsKeys() [Theory] [InlineData("")] [InlineData("http://example.com:443")] - [InlineData("vless://not-a-uuid@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() { @@ -146,6 +238,40 @@ public void Parse_UnknownSecurity_Rejected_NoSilentPlaintextDowngrade() 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); + + // And the client must refuse it loudly rather than connecting in the clear. + Assert.Throws(() => new VlessClient(o).ConnectAsync( + new MemoryStream(), "example.com", 443).AsTask().GetAwaiter().GetResult()); + } + + [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() { @@ -171,12 +297,34 @@ public void Client_IPv6Host_ConstructsWithoutThrowing() } [Fact] - public void Client_InvalidUuid_ThrowsAtConstruction() + public void Client_UnusableUuid_ThrowsAtConstruction() { - var bad = new VlessOptions { Id = "not-a-uuid", Host = "example.com", Port = 443 }; + // 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() { @@ -184,38 +332,51 @@ public async Task Client_ServerClosesEarly_WrapsAsProxyProtocolException() 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( - () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + async () => Assert.Equal(0, await tunnel.ReadAsync(new byte[16]))); Assert.Equal(ProxyErrorCode.ConnectionFailed, ex.ErrorCode); } - // === VlessClient (none path via FakeProxyStream) === - [Fact] - public async Task Client_None_WritesRequest_And_ReturnsStream() + public async Task Client_None_WritesRequest_AndDefersResponseHeader() { - // Server response: ver=00, addonsLen=00. - var stream = new FakeProxyStream([0x00, 0x00]); + // 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 result = await client.ConnectAsync(stream, "mc.example.com", 25565, CancellationToken.None); + var tunnel = await client.ConnectAsync(stream, "mc.example.com", 25565, CancellationToken.None); - Assert.Same(stream, result); + // 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(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. - var stream = new FakeProxyStream([0x00, 0x03, 0xAA, 0xBB, 0xCC]); + // 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 result = await client.ConnectAsync(stream, "example.org", 443, CancellationToken.None); - Assert.Same(stream, result); + 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] @@ -224,11 +385,28 @@ 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( - () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); + 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); + } + [Fact] public async Task Client_Reality_ThrowsNotSupported() { @@ -245,7 +423,7 @@ public async Task Client_UnsupportedTransport_Throws() { var stream = new FakeProxyStream([0x00, 0x00]); var client = new VlessClient( - VlessShareLink.Parse($"vless://{Uuid}@example.com:443?type=ws&security=none")); + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?type=grpc&security=none")); await Assert.ThrowsAsync( () => client.ConnectAsync(stream, "example.org", 443, CancellationToken.None).AsTask()); diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index f268dd0..f2fc012 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -73,7 +73,7 @@ public void Parse_FullLink_PortAsString() Assert.Equal(0, o.AlterId); Assert.Equal(VmessSecurityKind.Aes128Gcm, o.Security); Assert.Equal("tcp", o.Transport); - Assert.True(o.IsRawTcp); + Assert.Equal(TransportKind.RawTcp, o.TransportKind); Assert.True(o.UseTls); Assert.Equal("real.example.com", o.Sni); Assert.Equal("my node", o.Remark); @@ -247,20 +247,26 @@ public void Parse_AlterIdZero_IsAccepted(string extra) } [Fact] - public void Parse_NonTcpTransport_ParsesButIsNotRawTcp() + 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\":\"ws\""))); - Assert.Equal("ws", o.Transport); - Assert.False(o.IsRawTcp); + var o = VmessShareLink.Parse(Link(MinimalJson(extra: ",\"net\":\"grpc\""))); + Assert.Equal("grpc", o.Transport); + Assert.Equal(TransportKind.Unsupported, o.TransportKind); } [Theory] - [InlineData("tcp")] - [InlineData("raw")] - public void Parse_RawTcpTransports(string net) + [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) { - Assert.True(VmessShareLink.Parse(Link(MinimalJson(extra: $",\"net\":\"{net}\""))).IsRawTcp); + // 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 ================================ @@ -298,11 +304,220 @@ public void TryParse_RejectsMissingOrInvalidId() Assert.False(VmessShareLink.TryParse( Link("""{"add":"a.example.com","port":"443"}"""), out _)); Assert.False(VmessShareLink.TryParse(Link(MinimalJson(id: "")), out _)); - Assert.False(VmessShareLink.TryParse(Link(MinimalJson(id: "not-a-uuid")), 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 @@ -426,9 +641,9 @@ public void Client_NullOptions_ThrowsArgumentNull() [Theory] [InlineData("")] - [InlineData("not-a-uuid")] - [InlineData("11223344-5566-7788-99aa-bbccddeeff")] - public void Client_InvalidUuid_ThrowsAtConstruction(string id) + [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))); } @@ -522,10 +737,9 @@ public void Factory_LongVmessLink_CannotBeExpressedAsAUri() } [Theory] - [InlineData("ws")] [InlineData("grpc")] [InlineData("h2")] - [InlineData("httpupgrade")] + [InlineData("xhttp")] public async Task Client_UnsupportedTransport_ThrowsNotSupported_BeforeWritingAnything(string net) { var transport = new ScriptedDuplexStream(); diff --git a/QuickProxyNet.Tests/VmessRequestTest.cs b/QuickProxyNet.Tests/VmessRequestTest.cs index 10ffd56..1d977bf 100644 --- a/QuickProxyNet.Tests/VmessRequestTest.cs +++ b/QuickProxyNet.Tests/VmessRequestTest.cs @@ -178,13 +178,35 @@ public void CmdKey_DestinationTooSmall_Throws() } [Fact] - public void CmdKey_InvalidUuid_Throws() + 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("not-a-uuid", dst); + 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 ========================= diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs index 78bff27..965c787 100644 --- a/QuickProxyNet/Clients/TrojanClient.cs +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -6,8 +6,8 @@ namespace QuickProxyNet; /// /// Connects to a target host through a Trojan proxy. Trojan is TLS-mandatory: the request -/// header is written inside an session. Only tcp/raw -/// transport is supported; alternate transports are rejected with +/// header is written inside an session, over the tcp/raw, +/// ws or httpupgrade transport. The remaining transports are rejected with /// . /// public sealed class TrojanClient : ProxyClient @@ -53,34 +53,50 @@ public override async ValueTask ConnectAsync(Stream stream, string host, CancellationToken cancellationToken = default) { // Reject unsupported transports before writing any bytes or starting the handshake. - EnsureSupported(); + TransportKind transport = EnsureSupported(); - var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + // 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 ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); - await TrojanHelper.EstablishTrojanTunnelAsync(ssl, Options, host, port, cancellationToken) + await ((SslStream)layered).AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken) .ConfigureAwait(false); - return ssl; + + 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 { - // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. - await ssl.DisposeAsync().ConfigureAwait(false); + await layered.DisposeAsync().ConfigureAwait(false); throw; } } - private void EnsureSupported() + private TransportKind EnsureSupported() { - if (!Options.IsRawTcp) + TransportKind transport = Options.TransportKind; + if (transport == TransportKind.Unsupported) throw new NotSupportedException( - $"Trojan transport '{Options.Transport}' is not supported; only 'tcp'/'raw' is implemented."); + $"Trojan transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + + "'httpupgrade' are implemented."); + + return transport; } private SslClientAuthenticationOptions BuildSslOptions() => new() { - TargetHost = Options.Sni ?? Options.Host, + // 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 diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 310b148..7c4c6d1 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -6,8 +6,9 @@ namespace QuickProxyNet; /// /// Connects to a target host through a VLESS proxy. Supports security=none (plain -/// TCP) and security=tls (over ) with tcp/raw -/// transport. REALITY, non-empty flow, and alternate transports are rejected with +/// TCP) and security=tls (over ), each over the +/// tcp/raw, ws or httpupgrade transport. REALITY, non-empty +/// flow, and the remaining transports are rejected with /// . /// public sealed class VlessClient : ProxyClient @@ -20,10 +21,16 @@ public sealed class VlessClient : ProxyClient public VlessClient(VlessOptions options) : base("vless", (options ?? throw new ArgumentNullException(nameof(options))).Host, options.Port) { - // Validate the id up front so a bad UUID fails at construction rather than mid-connect - // (the share-link path already validated it, but a directly-built VlessOptions may not have). - if (!Guid.TryParse(options.Id, out _)) - throw new ArgumentException($"VLESS user id '{options.Id}' is not a valid UUID.", nameof(options)); + // 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 '{options.Id}' is unusable: 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); @@ -47,47 +54,59 @@ public VlessClient(VlessOptions options) /// 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) { - EnsureSupported(); + TransportKind transport = EnsureSupported(); - if (Options.Security == VlessSecurity.Tls) + // 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 { - var ssl = new SslStream(stream, leaveInnerStreamOpen: false); - try - { - await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); - await VlessHelper.EstablishVlessTunnelAsync(ssl, Options, host, port, cancellationToken) - .ConfigureAwait(false); - return ssl; - } - catch + if (Options.Security == VlessSecurity.Tls) { // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. - await ssl.DisposeAsync().ConfigureAwait(false); - throw; + var ssl = new SslStream(layered, leaveInnerStreamOpen: false); + layered = ssl; + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); } - } - try - { - await VlessHelper.EstablishVlessTunnelAsync(stream, Options, host, port, cancellationToken) + 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); - return stream; } catch { - await stream.DisposeAsync().ConfigureAwait(false); + await layered.DisposeAsync().ConfigureAwait(false); throw; } } - private void EnsureSupported() + private TransportKind EnsureSupported() { - if (!Options.IsRawTcp) + TransportKind transport = Options.TransportKind; + if (transport == TransportKind.Unsupported) throw new NotSupportedException( - $"VLESS transport '{Options.Transport}' is not supported; only 'tcp'/'raw' is implemented."); + $"VLESS transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + + "'httpupgrade' are implemented."); if (Options.Security == VlessSecurity.Reality) throw new NotSupportedException( @@ -96,11 +115,15 @@ private void EnsureSupported() if (!string.IsNullOrEmpty(Options.Flow)) throw new NotSupportedException( $"VLESS flow '{Options.Flow}' (XTLS) is not supported in this release."); + + return transport; } private SslClientAuthenticationOptions BuildSslOptions() => new() { - TargetHost = Options.Sni ?? Options.Host, + // 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 diff --git a/QuickProxyNet/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs index 6a978ca..9044e8b 100644 --- a/QuickProxyNet/Clients/VmessClient.cs +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -19,8 +19,9 @@ namespace QuickProxyNet; /// signals end-of-stream in band. /// /// -/// Only tcp/raw transport is supported; ws, grpc and h2 -/// are rejected with before any bytes are written. +/// 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 @@ -65,8 +66,14 @@ public VmessClient(VmessOptions options) // 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). - if (!Guid.TryParse(options.Id, out _)) - throw new ArgumentException($"VMess user id '{options.Id}' is not a valid UUID.", nameof(options)); + // 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 '{options.Id}' is unusable: 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( @@ -117,24 +124,32 @@ public override async ValueTask ConnectAsync(Stream stream, string host, ArgumentNullException.ThrowIfNull(stream); // Reject unsupported transports and ciphers before writing any bytes or starting TLS. - VmessSecurity security = EnsureSupported(); + VmessSecurity security = EnsureSupported(out TransportKind transportKind); - Stream transport = stream; - if (Options.UseTls) + // 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 { - var ssl = new SslStream(stream, leaveInnerStreamOpen: false); - try - { - await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); - } - catch + if (Options.UseTls) { // SslStream(leaveInnerStreamOpen:false) disposes the inner stream too. - await ssl.DisposeAsync().ConfigureAwait(false); - throw; + var ssl = new SslStream(layered, leaveInnerStreamOpen: false); + layered = ssl; + await ssl.AuthenticateAsClientAsync(BuildSslOptions(), cancellationToken).ConfigureAwait(false); } - transport = ssl; + 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); @@ -143,17 +158,17 @@ public override async ValueTask ConnectAsync(Stream stream, string host, { int length = BuildHandshake(request, session, security, host, port, out byte responseVerifier); - await transport.WriteAsync(request.AsMemory(0, length), cancellationToken).ConfigureAwait(false); - await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + await layered.WriteAsync(request.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + await layered.FlushAsync(cancellationToken).ConfigureAwait(false); - return CreateBodyStream(transport, session, responseVerifier, security); + return CreateBodyStream(layered, session, responseVerifier, security); } catch { - // Owns the TLS session as well when one was established. A half-built - // VmessResponseStream holds no unmanaged state, so disposing the transport is + // 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 transport.DisposeAsync().ConfigureAwait(false); + await layered.DisposeAsync().ConfigureAwait(false); throw; } finally @@ -234,11 +249,13 @@ private static Stream CreateBodyStream( /// 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() + private VmessSecurity EnsureSupported(out TransportKind transportKind) { - if (!Options.IsRawTcp) + transportKind = Options.TransportKind; + if (transportKind == TransportKind.Unsupported) throw new NotSupportedException( - $"VMess transport '{Options.Transport}' is not supported; only 'tcp'/'raw' is implemented."); + $"VMess transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + + "'httpupgrade' are implemented."); VmessSecurity security = Options.ResolveSecurity(); @@ -252,7 +269,9 @@ private VmessSecurity EnsureSupported() private SslClientAuthenticationOptions BuildSslOptions() => new() { - TargetHost = Options.Sni ?? Options.Host, + // 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 diff --git a/QuickProxyNet/Configs/TrojanOptions.cs b/QuickProxyNet/Configs/TrojanOptions.cs index fa21356..82e419c 100644 --- a/QuickProxyNet/Configs/TrojanOptions.cs +++ b/QuickProxyNet/Configs/TrojanOptions.cs @@ -5,9 +5,9 @@ namespace QuickProxyNet; /// or built directly. /// /// -/// Trojan is TLS-mandatory: the request header is written inside the TLS session. Only -/// tcp/raw transport is supported at connect time in this release; other -/// transports (ws, grpc, …) are parsed so callers can inspect them, but +/// 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 @@ -21,9 +21,24 @@ public sealed class TrojanOptions /// Proxy server port. public required int Port { get; init; } - /// Transport network: tcp or raw (both raw TCP). Others are unsupported. + /// + /// 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; } @@ -39,8 +54,6 @@ public sealed class TrojanOptions /// Human-readable label from the share-link fragment (#name). public string? Remark { get; init; } - /// True when the transport is plain TCP (tcp or raw). - internal bool IsRawTcp => - Transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || - Transport.Equals("raw", StringComparison.OrdinalIgnoreCase); + /// 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 index 267131d..c991391 100644 --- a/QuickProxyNet/Configs/TrojanShareLink.cs +++ b/QuickProxyNet/Configs/TrojanShareLink.cs @@ -43,13 +43,23 @@ private static bool TryParse( return false; } - if (!Uri.TryCreate(shareLink.Trim(), UriKind.Absolute, out var uri) || - !uri.Scheme.Equals("trojan", StringComparison.OrdinalIgnoreCase)) + 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) { @@ -77,7 +87,7 @@ private static bool TryParse( // Defaults. string transport = "tcp"; - string? sni = null; + string? sni = null, path = null, hostHeader = null; IReadOnlyList? alpn = null; bool allowInsecure = false; @@ -96,7 +106,7 @@ private static bool TryParse( if (eq < 0) continue; - ReadOnlySpan key = pair.Slice(0, eq); + ReadOnlySpan key = ShareLinkQuery.StripHtmlAmpPrefix(pair.Slice(0, eq)); ReadOnlySpan rawVal = pair.Slice(eq + 1); if (rawVal.IsEmpty) continue; @@ -113,6 +123,10 @@ private static bool TryParse( 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); } } @@ -128,6 +142,8 @@ private static bool TryParse( Transport = transport, Sni = sni, Alpn = alpn, + Path = path, + HostHeader = hostHeader, AllowInsecure = allowInsecure, Remark = remark }; diff --git a/QuickProxyNet/Configs/VlessOptions.cs b/QuickProxyNet/Configs/VlessOptions.cs index 79d026c..f8e9de7 100644 --- a/QuickProxyNet/Configs/VlessOptions.cs +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -24,10 +24,10 @@ public enum VlessSecurity /// or built directly. /// /// -/// Only tcp/raw transport with or -/// is supported at connect time in this release. Other -/// fields (REALITY keys, non-empty , alternate transports) are parsed -/// so callers can inspect them, but connecting with them throws +/// Supported at connect time: the tcp/raw, ws and httpupgrade +/// transports with or . +/// Other fields (REALITY keys, non-empty , the grpc/xhttp +/// transports) are parsed so callers can inspect them, but connecting with them throws /// . /// public sealed class VlessOptions @@ -44,9 +44,24 @@ public sealed class VlessOptions /// Transport security layer. Defaults to . public VlessSecurity Security { get; init; } = VlessSecurity.None; - /// Transport network: tcp or raw (both raw TCP). Others are unsupported. + /// + /// 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; } @@ -68,8 +83,6 @@ public sealed class VlessOptions /// Human-readable label from the share-link fragment (#name). public string? Remark { get; init; } - /// True when the transport is plain TCP (tcp or raw). - internal bool IsRawTcp => - Transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || - Transport.Equals("raw", StringComparison.OrdinalIgnoreCase); + /// 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 index 8a401d1..8713746 100644 --- a/QuickProxyNet/Configs/VlessShareLink.cs +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -43,13 +43,26 @@ private static bool TryParse( return false; } - if (!Uri.TryCreate(shareLink.Trim(), UriKind.Absolute, out var uri) || - !uri.Scheme.Equals("vless", StringComparison.OrdinalIgnoreCase)) + 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) { @@ -60,7 +73,9 @@ private static bool TryParse( Span probe = stackalloc byte[UuidCodec.Size]; if (!UuidCodec.TryWriteBigEndian(id, probe)) { - error = $"VLESS user id '{id}' is not a valid UUID."; + error = + $"VLESS user id '{id}' is unusable: it is neither a canonical UUID nor a " + + "string of 1..30 characters (which would be mapped to a UUID)."; return false; } @@ -85,7 +100,7 @@ private static bool TryParse( // Defaults. var security = VlessSecurity.None; string transport = "tcp"; - string? sni = null, flow = null, fp = null, pbk = null, sid = null; + 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 '?'. @@ -103,7 +118,7 @@ private static bool TryParse( if (eq < 0) continue; - ReadOnlySpan key = pair.Slice(0, eq); + ReadOnlySpan key = ShareLinkQuery.StripHtmlAmpPrefix(pair.Slice(0, eq)); ReadOnlySpan rawVal = pair.Slice(eq + 1); if (rawVal.IsEmpty) continue; @@ -117,7 +132,9 @@ private static bool TryParse( { // 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()}'."; + error = + $"Unrecognized VLESS security '{rawVal.ToString()}': expected " + + "'none', 'tls' or 'reality'."; return false; } } @@ -135,6 +152,10 @@ private static bool TryParse( 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); } } @@ -151,6 +172,8 @@ private static bool TryParse( Transport = transport, Sni = sni, Alpn = alpn, + Path = path, + HostHeader = hostHeader, Flow = string.IsNullOrEmpty(flow) ? null : flow, Fingerprint = fp, RealityPublicKey = pbk, diff --git a/QuickProxyNet/Configs/VmessOptions.cs b/QuickProxyNet/Configs/VmessOptions.cs index dd8f019..07322b1 100644 --- a/QuickProxyNet/Configs/VmessOptions.cs +++ b/QuickProxyNet/Configs/VmessOptions.cs @@ -38,8 +38,8 @@ public enum VmessSecurityKind /// deliberately not implemented, so it is rejected rather than silently downgraded. /// /// -/// Only tcp/raw transport is supported at connect time in this release, with -/// or without TLS. Other transports (ws, grpc, h2, …) are parsed so +/// 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 /// . /// @@ -67,9 +67,24 @@ public sealed class VmessOptions /// public int AlterId { get; init; } - /// Transport network: tcp or raw (both raw TCP). Others are unsupported. + /// + /// 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). /// @@ -90,10 +105,8 @@ public sealed class VmessOptions /// Human-readable label from the share-link ps field. public string? Remark { get; init; } - /// True when the transport is plain TCP (tcp or raw). - internal bool IsRawTcp => - Transport.Equals("tcp", StringComparison.OrdinalIgnoreCase) || - Transport.Equals("raw", StringComparison.OrdinalIgnoreCase); + /// The resolved transport layer this configuration selects. + internal TransportKind TransportKind => ProxyTransport.Resolve(Transport); /// /// Maps onto the concrete body cipher written into the request diff --git a/QuickProxyNet/Configs/VmessShareLink.cs b/QuickProxyNet/Configs/VmessShareLink.cs index f680341..6335a3e 100644 --- a/QuickProxyNet/Configs/VmessShareLink.cs +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -10,9 +10,26 @@ namespace QuickProxyNet; /// /// /// -/// Grammar: vmess:// followed by base64-encoded UTF-8 JSON (the "v2rayN" format). -/// Both the standard and the URL-safe base64 alphabets are accepted, with or without -/// padding, and embedded whitespace is ignored — real-world links violate all three rules. +/// 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, @@ -75,6 +92,30 @@ private static bool TryParse( } 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."; @@ -94,7 +135,7 @@ private static bool TryParse( return false; } - return TryParseJson(json.AsMemory(0, jsonLength), out options, out error); + return TryParseJson(json.AsMemory(0, jsonLength), fragmentRemark, out options, out error); } finally { @@ -102,6 +143,245 @@ private static bool TryParse( } } + /// + /// 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 '{id}' is unusable: 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. /// @@ -165,6 +445,7 @@ private static bool TryNormalizeBase64( private static bool TryParseJson( ReadOnlyMemory utf8Json, + string? fragmentRemark, [NotNullWhen(true)] out VmessOptions? options, [NotNullWhen(false)] out string? error) { @@ -199,7 +480,7 @@ private static bool TryParseJson( JsonElement aidField = default, alterIdField = default; JsonElement scyField = default, securityField = default; JsonElement netField = default, typeField = default, tlsField = default; - JsonElement sniField = default, hostField = default; + JsonElement sniField = default, hostField = default, pathField = default; JsonElement alpnField = default, psField = default; JsonElement allowInsecureField = default, skipCertVerifyField = default; @@ -217,6 +498,7 @@ private static bool TryParseJson( 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; @@ -234,7 +516,9 @@ private static bool TryParseJson( Span probe = stackalloc byte[UuidCodec.Size]; if (!UuidCodec.TryWriteBigEndian(id, probe)) { - error = $"VMess user id '{id}' is not a valid UUID."; + error = + $"VMess user id '{id}' is unusable: it is neither a canonical UUID nor a " + + "string of 1..30 characters (which would be mapped to a UUID)."; return false; } @@ -287,25 +571,18 @@ private static bool TryParseJson( string? scy = GetString(scyField) ?? GetString(securityField); if (!TryParseSecurity(scy, out VmessSecurityKind security)) { - error = - $"Unrecognized VMess security '{scy}': only 'auto', 'aes-128-gcm' and " + - "'chacha20-poly1305' are supported."; + 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; - string? headerType = GetString(typeField); - if (!string.IsNullOrEmpty(headerType) && - !headerType.Equals("none", StringComparison.OrdinalIgnoreCase)) - { - // 'type' is header obfuscation (e.g. "http"), not the transport. Anything - // other than "none" wraps the VMess stream in a framing we do not produce. - error = $"VMess header obfuscation type '{headerType}' is not supported; only 'none' is."; + if (!TryValidateHeaderType(GetString(typeField), transport, out error)) return false; - } // ---- tls ---- string? tls = GetString(tlsField); @@ -341,14 +618,29 @@ private static bool TryParseJson( UseTls = useTls, Sni = sni, Alpn = GetAlpn(alpnField), + Path = GetString(pathField), + HostHeader = GetString(hostField), AllowInsecure = GetBoolean(allowInsecureField) || GetBoolean(skipCertVerifyField), - Remark = GetString(psField) + // '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)) diff --git a/QuickProxyNet/Internal/Crypto/Sha224.cs b/QuickProxyNet/Internal/Crypto/Sha224.cs index 78c4ad9..769b79f 100644 --- a/QuickProxyNet/Internal/Crypto/Sha224.cs +++ b/QuickProxyNet/Internal/Crypto/Sha224.cs @@ -1,4 +1,3 @@ -using System.Buffers.Binary; using System.Runtime.Intrinsics; namespace QuickProxyNet; @@ -7,16 +6,9 @@ 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. Allocation-free: state, schedule and padding are stack-allocated. +/// the first 28 bytes, so everything but the IV and the output length comes from +/// . Allocation-free: state, schedule and padding are stack-allocated. /// -/// -/// .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. A pure scalar path always exists and is used on any CPU -/// without acceleration. -/// internal static class Sha224 { /// Digest size in bytes (224 bits). @@ -25,21 +17,6 @@ internal static class Sha224 /// Digest size in lowercase-hex ASCII bytes. public const int HexSize = HashSize * 2; - private const int BlockSize = 64; - - // 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 - ]; - /// /// Computes SHA224() and writes the 28-byte digest into /// . @@ -91,121 +68,6 @@ private static void ComputeHashCore(ReadOnlySpan data, Span destinat throw new ArgumentException( $"Destination must be at least {HashSize} bytes.", nameof(destination)); - // SHA-224 initial hash values (second 32 bits of the fractional parts of the - // square roots of the 9th..16th primes). - Span h = - [ - 0xc1059ed8, 0x367cd507, 0x3070dd17, 0xf70e5939, - 0xffc00b31, 0x68581511, 0x64f98fa7, 0xbefa4fa4 - ]; - - Span w = stackalloc uint[64]; - - // Full 64-byte blocks straight from the input. - ReadOnlySpan remaining = data; - while (remaining.Length >= BlockSize) - { - ProcessBlock(remaining, h, w, vectorize); - remaining = remaining.Slice(BlockSize); - } - - // Final block(s): tail + 0x80 + zero pad + 64-bit big-endian bit length. Fits in - // one block when tail <= 55 bytes, otherwise spills into a second block. - 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); - - // SHA-224 keeps only the first seven state words. - for (int i = 0; i < HashSize / 4; i++) - BinaryPrimitives.WriteUInt32BigEndian(destination.Slice(i * 4), h[i]); + Sha256Core.ComputeHash(Sha256Core.Sha224Iv, data, destination, HashSize, vectorize); } - - 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]); - } - } - - /// - /// 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) - { - 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/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 index df92aac..e437fc9 100644 --- a/QuickProxyNet/Internal/Crypto/UuidCodec.cs +++ b/QuickProxyNet/Internal/Crypto/UuidCodec.cs @@ -1,44 +1,98 @@ +using System.Security.Cryptography; +using System.Text; + namespace QuickProxyNet; /// -/// Encodes a canonical UUID string into its 16-byte RFC 4122 (network / big-endian) -/// representation, as required by the VLESS and VMess wire formats. +/// 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 valid UUID. + /// 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 must be a canonical UUID; got '{id.ToString()}'."); + $"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 not a valid UUID or the destination is too small. + /// id is unusable or the destination is too small. /// public static bool TryWriteBigEndian(ReadOnlySpan id, Span dest) { if (dest.Length < Size) return false; - // Guid is a struct — TryParse + big-endian TryWriteBytes is fully zero-allocation. - // bigEndian:true (net8+) yields RFC 4122 order == the canonical string byte order. - if (!Guid.TryParse(id, out var guid)) + // 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; - return guid.TryWriteBytes(dest, bigEndian: true, out _); + 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/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..2f8233e --- /dev/null +++ b/QuickProxyNet/Internal/Transports/WebSocketStream.cs @@ -0,0 +1,147 @@ +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 +{ + 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. + while (true) + { + ValueWebSocketReceiveResult result; + try + { + result = await _webSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException ex) + { + throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + $"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; + } + } + + 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.TransportUpgradeFailed, + $"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/VlessHelper.cs b/QuickProxyNet/Internal/VlessHelper.cs index 170ff5a..c148424 100644 --- a/QuickProxyNet/Internal/VlessHelper.cs +++ b/QuickProxyNet/Internal/VlessHelper.cs @@ -13,8 +13,9 @@ namespace QuickProxyNet; /// 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), read in full -/// so the returned stream starts exactly at the target's first byte. +/// 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 { @@ -27,7 +28,16 @@ internal static class VlessHelper // ver(1) + uuid(16) + addonsLen(1) + cmd(1) + port(2) + max address. private const int MaxRequestSize = 1 + UuidCodec.Size + 1 + 1 + 2 + ProxyAddress.MaxLength; - internal static async ValueTask EstablishVlessTunnelAsync( + /// + /// 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) { byte[] buffer = ArrayPool.Shared.Rent(MaxRequestSize); @@ -35,30 +45,7 @@ internal static async ValueTask EstablishVlessTunnelAsync( { int length = BuildRequest(buffer, options.Id, host, port); await stream.WriteAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false); - - try - { - // Response header: ver(1) + addonsLen(1). - await stream.ReadExactlyAsync(buffer.AsMemory(0, 2), cancellationToken).ConfigureAwait(false); - if (buffer[0] != Version) - throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, - $"Unexpected VLESS response version. Expected 0x00, got 0x{buffer[0]:X2}."); - - // addonsLen is a single byte (<= 255 < buffer length), so the rented buffer - // always holds it. Content is unused for plain TCP; draining it positions the - // stream at the target's first response byte. - int addonsLength = buffer[1]; - if (addonsLength > 0) - await stream.ReadExactlyAsync(buffer.AsMemory(0, addonsLength), cancellationToken) - .ConfigureAwait(false); - } - catch (EndOfStreamException ex) - { - // A short/closed response is the primary VLESS failure signal (e.g. wrong - // UUID: many servers 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); - } + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } finally { @@ -66,6 +53,8 @@ await stream.ReadExactlyAsync(buffer.AsMemory(0, addonsLength), cancellationToke // returning the array to the shared pool. ArrayPool.Shared.Return(buffer, clearArray: true); } + + return new VlessResponseStream(stream, host, port); } internal static int BuildRequest(Span buffer, ReadOnlySpan id, string host, int port) 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/VmessKdf.cs b/QuickProxyNet/Internal/Vmess/VmessKdf.cs index 4901ba7..8626a74 100644 --- a/QuickProxyNet/Internal/Vmess/VmessKdf.cs +++ b/QuickProxyNet/Internal/Vmess/VmessKdf.cs @@ -27,12 +27,31 @@ namespace QuickProxyNet; /// 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: .NET exposes no SHA-256 -/// midstate export, so the ipad/opad prefixes cannot be pre-hashed once and reused. -/// 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. +/// 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 diff --git a/QuickProxyNet/ProxyProtocolException.cs b/QuickProxyNet/ProxyProtocolException.cs index 011136b..7752f68 100644 --- a/QuickProxyNet/ProxyProtocolException.cs +++ b/QuickProxyNet/ProxyProtocolException.cs @@ -54,5 +54,10 @@ public enum ProxyErrorCode /// The proxy connection timed out. Timeout, /// A protocol string field (e.g. a target host name) exceeded the 255-byte limit. - StringTooLong + 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/docs/implementation-plan.md b/docs/implementation-plan.md index 4522dc0..7b32751 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -190,12 +190,65 @@ time-sync, `security` (`aes-128-gcm`/`chacha20-poly1305`), `alterId=0`. `alterId>0` (legacy MD5 auth), UDP/Mux → `NotSupportedException` с явным сообщением (честный gating до записи байтов). -**Фаза 4 — QUIC (Hysteria2/TUIC):** отдельный пакет `QuickProxyNet.Quic` на +**Фаза 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. **Отдельно:** VLESS REALITY / XTLS-vision (uTLS fingerprint — не покрывается стандартным `SslStream`). +## 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 — половина корпуса, но это отдельный проект, а не +фича: пока нет способа подделать uTLS-отпечаток, честный `NotSupportedException` +остаётся единственным правильным поведением. + +Метод, а не только результат: считать надо то, что может провалиться. «Процент +распарсенного» рос до 99.9% ровно тогда, когда 96% ссылок не могли подключиться — +метрика, которая не умеет падать, ничего не измеряет. + ## 6. Границы фазы 1 (honest scope) Поддерживается: `vless://` с `security=none` и `security=tls`, транспорт diff --git a/tests/docker/README.md b/tests/docker/README.md new file mode 100644 index 0000000..7fd7455 --- /dev/null +++ b/tests/docker/README.md @@ -0,0 +1,134 @@ +# 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. + +## 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) | +| `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..1766538 --- /dev/null +++ b/tests/docker/xray/config.json @@ -0,0 +1,231 @@ +{ + "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": {} + } + ] +} From 6438e8a0167e8fe9b58da4bdec78136fc10971db Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 14 Aug 2026 15:17:35 +0500 Subject: [PATCH 08/25] build: add net11.0 to the target frameworks The package now ships lib/net8.0, net9.0, net10.0 and net11.0. The test project multi-targets net10.0;net11.0 so the new target is actually run against, rather than merely built - a TFM nothing tests is a claim of support, not support. That exposed a real defect in the docker harness rather than a new one: the compose stack is a machine-global singleton (one fixed project name, one fixed set of host ports), and dotnet test runs the TFMs concurrently, so both runs raced to bring it up and every integration test failed with "compose up failed with exit code 1". DockerComposeFixture now takes an exclusive cross-process lock for its whole lifetime, so the runs serialize. A lock file rather than a named Mutex: a mutex has thread affinity and must be released by the thread that took it, which async test lifecycle methods do not guarantee. net11.0 is a preview SDK, so building the repo now requires a preview .NET install - that is the NETSDK1057 message on every build. Verified: 427 tests pass on both net10.0 and net11.0, including the docker integration suite against real Xray and sing-box. Zero warnings across all four library targets. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 9 ++- .../Integration/DockerComposeFixture.cs | 60 +++++++++++++++++-- .../QuickProxyNet.Tests.csproj | 4 +- QuickProxyNet/QuickProxyNet.csproj | 2 +- tests/docker/README.md | 7 +++ 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c44fb6e..2e06516 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ HTTPS, SOCKS4, SOCKS4a, SOCKS5) and the VPN-style family (VLESS, Trojan, VMess). - NuGet package: `QuickProxyNet` - Author: Titlehhhh - License: MIT -- Core targets: `net8.0`, `net9.0`, `net10.0` +- Core targets: `net8.0`, `net9.0`, `net10.0`, `net11.0` ## Repository Layout @@ -219,7 +219,12 @@ not "clean up" any of them without reading the reasoning first. - 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`, and `net10.0`. +- 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 diff --git a/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs b/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs index 69f517e..e931d99 100644 --- a/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs +++ b/QuickProxyNet.Tests/Integration/DockerComposeFixture.cs @@ -24,13 +24,27 @@ namespace QuickProxyNet.Tests.Integration; /// 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; } = ""; @@ -57,6 +71,10 @@ public async Task InitializeAsync() { 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)); @@ -78,16 +96,50 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - if (!_composeTouched) - return; - try { - await ComposeAsync("down -v --remove-orphans", TimeSpan.FromMinutes(2)); + 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); + } } } 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/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index d156b2d..39dcb2d 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -1,6 +1,6 @@  - net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0;net11.0 enable enable latest diff --git a/tests/docker/README.md b/tests/docker/README.md index 7fd7455..21f2d49 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -30,6 +30,13 @@ 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. From 729e29c51e2e96e8476a4559366c986dbf6ad839 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 14 Aug 2026 15:20:39 +0500 Subject: [PATCH 09/25] ci: install the .NET 11 preview SDK The runners ship SDK 10.0.400, which cannot target net11.0, so restore failed with NETSDK1045 as soon as the new TFM landed. Installed in its own step: dotnet-quality applies to every version in a step's list, and there is no preview channel for the GA releases, so asking for one alongside them fails the install. setup-dotnet accumulates SDKs across steps. Applied to publish.yaml as well - it builds and packs the same targets, so it would have failed identically on the next release tag. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yaml | 9 +++++++++ .github/workflows/publish.yaml | 9 +++++++++ 2 files changed, 18 insertions(+) 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 From 14e982979a7ec84f70f7daa87f46f4f106baa6f6 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 15:37:27 +0500 Subject: [PATCH 10/25] feat(reality): add QuickProxyNet.Reality, driving Xray for REALITY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VLESS REALITY needs a browser-identical uTLS ClientHello, which SslStream cannot produce, so the core client refuses it rather than downgrading. This package closes the gap by running the reference implementation: a local Xray-core process with a loopback SOCKS5 inbound, wrapped behind RealityProxy.ConnectAsync. Kept as a separate package so the core keeps its zero-dependency promise — opting into REALITY here is an explicit choice to depend on an external binary, and that binary is supplied by the caller, never shipped. The generated configuration goes to Xray on stdin ('run -c stdin:'), so the VLESS id never touches disk. It is rendered by a pure function of VlessOptions, which is what makes the mapping testable without a process. Two protocol facts the tests pinned down: - REALITY runs only over raw TCP (plus xhttp and gRPC in Xray). Rendering it with a ws transport produces a config Xray rejects at startup, so that combination is now refused with a message naming why. - Xray's own SOCKS inbound stops relaying a request larger than roughly one TLS record: 16 000 bytes round-trips, 16 500 hangs, with no error from either process. LargeRequestDiagnosticTests isolates it — QuickProxyNet's SOCKS5 client carries 100 000 bytes through a plain relay, and the same request stalls against Xray with no VLESS, TLS or REALITY in the path. The integration harness runs entirely on loopback. A REALITY server cannot be tested without a reachable 'dest', since it authenticates by relaying the handshake to a real site; LocalRealityServer gives the same Xray process a second, ordinary TLS inbound and aims dest at it. Co-Authored-By: Claude Opus 5 (1M context) --- .../QuickProxyNet.Reality.csproj | 44 ++ QuickProxyNet.Reality/RealityProxy.cs | 321 ++++++++++++ QuickProxyNet.Reality/RealityProxyOptions.cs | 52 ++ QuickProxyNet.Reality/XrayClientConfig.cs | 196 ++++++++ QuickProxyNet.Reality/XrayExecutable.cs | 76 +++ .../LargeRequestDiagnosticTests.cs | 291 +++++++++++ .../Integration/LocalRealityServer.cs | 460 ++++++++++++++++++ .../Integration/RealityProxyTests.cs | 192 ++++++++ .../QuickProxyNet.Tests.csproj | 1 + QuickProxyNet.Tests/RealityConfigTest.cs | 250 ++++++++++ QuickProxyNet.slnx | 1 + 11 files changed, 1884 insertions(+) create mode 100644 QuickProxyNet.Reality/QuickProxyNet.Reality.csproj create mode 100644 QuickProxyNet.Reality/RealityProxy.cs create mode 100644 QuickProxyNet.Reality/RealityProxyOptions.cs create mode 100644 QuickProxyNet.Reality/XrayClientConfig.cs create mode 100644 QuickProxyNet.Reality/XrayExecutable.cs create mode 100644 QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs create mode 100644 QuickProxyNet.Tests/Integration/LocalRealityServer.cs create mode 100644 QuickProxyNet.Tests/Integration/RealityProxyTests.cs create mode 100644 QuickProxyNet.Tests/RealityConfigTest.cs diff --git a/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj b/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj new file mode 100644 index 0000000..548595a --- /dev/null +++ b/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj @@ -0,0 +1,44 @@ + + + net8.0;net9.0;net10.0;net11.0 + enable + enable + latest + v + 3.0 + + + QuickProxyNet.Reality + Titlehhhh + Titlehhhh + VLESS REALITY and XTLS Vision support for QuickProxyNet by driving a local Xray-core process. The Xray binary is supplied by the caller and is not shipped in this package. + proxy;networking;vless;reality;xray;xtls + Copyright © Titlehhhh 2026 + https://github.com/Titlehhhh/QuickProxyNet + https://github.com/Titlehhhh/QuickProxyNet + + + + True + $(NoWarn);CS1591 + + + + icon.png + LICENSE.txt + + + + + + + + + + + + + + + + diff --git a/QuickProxyNet.Reality/RealityProxy.cs b/QuickProxyNet.Reality/RealityProxy.cs new file mode 100644 index 0000000..994df83 --- /dev/null +++ b/QuickProxyNet.Reality/RealityProxy.cs @@ -0,0 +1,321 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace QuickProxyNet.Reality; + +/// +/// A VLESS REALITY (or TLS + XTLS Vision) tunnel, provided by a local Xray-core process with a +/// loopback SOCKS5 inbound in front of it. +/// +/// +/// +/// What this is and is not. REALITY authenticates by hiding a key exchange inside the TLS +/// session_id of a ClientHello that must be byte-identical to a real browser's. .NET's +/// delegates the handshake to Schannel or OpenSSL and +/// exposes no way to author that ClientHello, so QuickProxyNet's in-process VLESS client cannot +/// speak REALITY and says so rather than downgrading. This package closes that gap the honest +/// way — by running the reference implementation — instead of by approximating a fingerprint, +/// which would mark the user as "not the browser I claim to be" rather than merely failing. +/// +/// +/// The cost is a child process and a binary the caller has to supply. In exchange, everything +/// Xray speaks comes with it: REALITY, xtls-rprx-vision, and the transports underneath. +/// +/// +/// Lifetime. One instance owns exactly one Xray process and one loopback port. +/// kills that process by its own handle — never by image name, since +/// the user's own VPN client is very likely running a binary with the same name. +/// +/// +/// +/// +/// await using var proxy = await RealityProxy.StartAsync( +/// "vless://uuid@example.com:443?security=reality&pbk=...&sid=ab12&sni=www.cloudflare.com&fp=chrome#node"); +/// await using Stream tunnel = await proxy.ConnectAsync("example.org", 80); +/// +/// +public sealed class RealityProxy : IAsyncDisposable +{ + private readonly Process _process; + private readonly Socks5Client _client; + private int _disposed; + + private RealityProxy(Process process, Socks5Client client, string listenAddress, int port) + { + _process = process; + _client = client; + ListenAddress = listenAddress; + ListenPort = port; + } + + /// Loopback address the local SOCKS5 inbound is bound to. + public string ListenAddress { get; } + + /// Port the local SOCKS5 inbound is bound to. + public int ListenPort { get; } + + /// + /// A SOCKS5 client aimed at the local inbound, for handing to code that takes an + /// . + /// + public IProxyClient Client => _client; + + /// Whether the Xray process is still running. + /// + /// False once disposed. throws on a disposed handle, and a + /// property that answers "is it running" by throwing is worse than useless to a caller + /// cleaning up. + /// + public bool IsRunning + { + get + { + if (Volatile.Read(ref _disposed) != 0) + return false; + + try + { + return !_process.HasExited; + } + catch (InvalidOperationException) + { + return false; + } + } + } + + /// Starts a tunnel for a vless:// share link. + /// A vless:// link with security=reality or security=tls. + /// Process settings, or null for the defaults. + /// Cancels startup. + public static ValueTask StartAsync( + string shareLink, + RealityProxyOptions? options = null, + CancellationToken cancellationToken = default) => + StartAsync(VlessShareLink.Parse(shareLink), options, cancellationToken); + + /// Starts a tunnel for an already-parsed VLESS configuration. + /// The outbound to drive. + /// Process settings, or null for the defaults. + /// Cancels startup. + /// Xray-core could not be located. + /// The configuration cannot be rendered. + /// Xray started but never accepted on the inbound. + public static async ValueTask StartAsync( + VlessOptions vless, + RealityProxyOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(vless); + options ??= new RealityProxyOptions(); + + string executable = XrayExecutable.Resolve(options.ExecutablePath); + int port = options.ListenPort ?? ReserveEphemeralPort(options.ListenAddress); + + // Rendered before the process exists so a bad configuration throws without leaving one behind. + byte[] config = XrayClientConfig.Build(vless, options.ListenAddress, port, options.LogLevel); + + var startInfo = new ProcessStartInfo(executable) + { + // 'stdin:' is what keeps the VLESS id off disk. See XrayClientConfig. + ArgumentList = { "run", "-c", "stdin:" }, + WorkingDirectory = Path.GetDirectoryName(executable) ?? Environment.CurrentDirectory, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + Process process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"Could not start '{executable}'."); + + var log = new OutputBuffer(options.LogSink); + var proxy = new RealityProxy(process, new Socks5Client(options.ListenAddress, port), options.ListenAddress, port); + + try + { + log.Attach(process); + + try + { + await process.StandardInput.BaseStream.WriteAsync(config, cancellationToken).ConfigureAwait(false); + await process.StandardInput.BaseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + // The credential is gone from our memory as soon as it has been handed over, + // and stdin must close for Xray to know the document ended. + Array.Clear(config); + process.StandardInput.Close(); + } + + await WaitUntilAcceptingAsync(process, options, port, log, cancellationToken).ConfigureAwait(false); + return proxy; + } + catch + { + await proxy.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// Opens a tunnelled connection to :. + /// Target host; resolved by the remote server, not locally. + /// Target port. + /// Cancels the connection attempt. + public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + return _client.ConnectAsync(host, port, cancellationToken); + } + + /// Stops the Xray process this instance started. + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + try + { + if (!_process.HasExited) + { + // By handle, and only this handle. Killing by image name would take down the + // user's own VPN client, which almost certainly runs a binary called 'xray'. + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync().ConfigureAwait(false); + } + } + catch (InvalidOperationException) + { + // Already gone between the check and the kill. + } + finally + { + _process.Dispose(); + } + } + + /// + /// Polls the inbound until it accepts, failing fast if Xray dies first. + /// + /// + /// Xray binds its inbounds only after the whole configuration has been accepted, so an + /// accepted connection is real evidence the tunnel is configured — unlike a fixed delay, + /// which would be both slower and a guess. + /// + private static async Task WaitUntilAcceptingAsync( + Process process, RealityProxyOptions options, int port, OutputBuffer log, CancellationToken cancellationToken) + { + long deadline = Environment.TickCount64 + (long)options.StartupTimeout.TotalMilliseconds; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (process.HasExited) + throw new InvalidOperationException( + $"Xray exited with code {process.ExitCode} during startup.{log.Format()}"); + + if (await TryConnectAsync(options.ListenAddress, port, cancellationToken).ConfigureAwait(false)) + return; + + if (Environment.TickCount64 > deadline) + throw new InvalidOperationException( + $"Xray did not start accepting on {options.ListenAddress}:{port} within " + + $"{options.StartupTimeout}.{log.Format()}"); + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + private static async Task TryConnectAsync(string address, int port, CancellationToken cancellationToken) + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + + try + { + await socket.ConnectAsync(address, port, cts.Token).ConfigureAwait(false); + return true; + } + catch (Exception ex) when (ex is SocketException || (ex is OperationCanceledException && !cancellationToken.IsCancellationRequested)) + { + return false; + } + } + + /// + /// Binds port 0, reads what the OS handed out, and releases it. + /// + /// + /// There is a window between releasing the port and Xray binding it in which something else + /// could take it; Xray then fails to start and + /// throws with its output. That is preferable to the alternative, which is holding the socket + /// open and having Xray fail to bind every time. Callers who need determinism set + /// . + /// + private static int ReserveEphemeralPort(string address) + { + var listener = new TcpListener(IPAddress.Parse(address), 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + /// + /// Keeps the last few lines of Xray's output so a startup failure can quote the reason. + /// + private sealed class OutputBuffer(Action? sink) + { + private const int MaxLines = 20; + private readonly Queue _lines = new(); + + public void Attach(Process process) + { + process.OutputDataReceived += OnData; + process.ErrorDataReceived += OnData; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + + private void OnData(object? sender, DataReceivedEventArgs e) + { + if (e.Data is null) + return; + + sink?.Invoke(e.Data); + + lock (_lines) + { + _lines.Enqueue(e.Data); + if (_lines.Count > MaxLines) + _lines.Dequeue(); + } + } + + public string Format() + { + lock (_lines) + { + if (_lines.Count == 0) + return " It produced no output."; + + var sb = new StringBuilder(" Its last output was:"); + foreach (string line in _lines) + sb.Append('\n').Append(" ").Append(line); + + return sb.ToString(); + } + } + } +} diff --git a/QuickProxyNet.Reality/RealityProxyOptions.cs b/QuickProxyNet.Reality/RealityProxyOptions.cs new file mode 100644 index 0000000..3d4486c --- /dev/null +++ b/QuickProxyNet.Reality/RealityProxyOptions.cs @@ -0,0 +1,52 @@ +namespace QuickProxyNet.Reality; + +/// +/// Knobs for how locates and runs Xray-core. +/// +public sealed class RealityProxyOptions +{ + /// + /// Environment variable consulted when is null. + /// + public const string ExecutablePathVariable = "QPN_XRAY_PATH"; + + /// + /// Full path to the Xray-core executable. When null the resolver falls back to + /// and then to PATH. + /// + /// + /// This package does not ship a binary. Xray-core is MPL-2.0 and platform-specific; + /// bundling it would make a NuGet package a redistributor of a censorship-circumvention + /// binary, with the download size and antivirus consequences that implies. Pointing at a + /// binary the caller already trusts keeps that decision with the caller. + /// + public string? ExecutablePath { get; init; } + + /// Loopback address for the local SOCKS5 inbound. Defaults to 127.0.0.1. + /// + /// Loopback is not a default to override casually: the inbound has no authentication, so + /// binding it to a routable address exposes an open proxy to the network. + /// + public string ListenAddress { get; init; } = "127.0.0.1"; + + /// + /// Fixed port for the local inbound. When null (the default) a free ephemeral port is taken. + /// + public int? ListenPort { get; init; } + + /// How long to wait for Xray to start accepting on the inbound. + public TimeSpan StartupTimeout { get; init; } = TimeSpan.FromSeconds(15); + + /// Xray loglevel. Defaults to warning. + /// + /// info and below log the destination of every connection made through the tunnel. + /// That is exactly the record the tunnel exists to avoid producing, so verbose logging is + /// opt-in and never the default. + /// + public string LogLevel { get; init; } = "warning"; + + /// + /// Receives Xray's stdout/stderr lines when set. Null discards them. + /// + public Action? LogSink { get; init; } +} diff --git a/QuickProxyNet.Reality/XrayClientConfig.cs b/QuickProxyNet.Reality/XrayClientConfig.cs new file mode 100644 index 0000000..f149330 --- /dev/null +++ b/QuickProxyNet.Reality/XrayClientConfig.cs @@ -0,0 +1,196 @@ +using System.Text.Json; + +namespace QuickProxyNet.Reality; + +/// +/// Builds the Xray-core client configuration that fronts a VLESS outbound with a local +/// SOCKS5 inbound. +/// +/// +/// +/// The generated document is handed to Xray on standard input (run -c stdin:), +/// never written to a file. It contains the VLESS id — a credential — and a config file would +/// leave that credential on disk with the lifetime of the process at best, and past a crash at +/// worst. Xray accepting stdin is what makes that avoidable. +/// +/// +/// This is deliberately the only piece of the package with no process in it: the JSON is a pure +/// function of plus a port, so the mapping can be tested exactly +/// without spawning anything. +/// +/// +internal static class XrayClientConfig +{ + /// Fingerprint used when the share link carries no fp. + /// + /// Xray treats an empty fingerprint as "no uTLS", which produces Go's own ClientHello — + /// the single most identifiable handshake a REALITY client can emit. Defaulting to + /// chrome is therefore a safety default, not a cosmetic one. + /// + public const string DefaultFingerprint = "chrome"; + + /// + /// Renders the client configuration for with a SOCKS5 inbound + /// on :. + /// + /// The VLESS outbound to drive. + /// Loopback address for the local inbound. + /// Port for the local inbound. + /// Xray loglevel. + /// UTF-8 JSON. + /// + /// The options select something this package does not render. Never silently reduced to + /// something weaker — an unrendered field would mean connecting with less protection than + /// the share link asked for. + /// + public static byte[] Build(VlessOptions options, string listenAddress, int socksPort, string logLevel) + { + ArgumentNullException.ThrowIfNull(options); + + string security = options.Security switch + { + VlessSecurity.Reality => "reality", + VlessSecurity.Tls => "tls", + _ => throw new NotSupportedException( + $"QuickProxyNet.Reality drives TLS and REALITY outbounds; this one is " + + $"'{options.Security}'. Plain VLESS needs no external process — use " + + $"QuickProxyNet's VlessClient directly.") + }; + + if (options.Security == VlessSecurity.Reality && string.IsNullOrEmpty(options.RealityPublicKey)) + throw new NotSupportedException( + "A REALITY outbound requires a public key ('pbk' in the share link); this one has none. " + + "Connecting without it would fall back to an ordinary TLS handshake and send the VLESS id " + + "to a server that is not expecting one."); + + string network = ResolveNetwork(options.Transport); + + // Xray refuses this combination outright: "REALITY only supports RAW, XHTTP and gRPC for + // now." Rendering it anyway would produce a document the process rejects at startup, and + // the caller would see a generic launch failure instead of the actual reason. + if (options.Security == VlessSecurity.Reality && network != "tcp") + throw new NotSupportedException( + $"REALITY runs only over the raw/tcp transport (and, in Xray, xhttp and gRPC); this link " + + $"asks for '{options.Transport}'. A REALITY link with a WebSocket transport is malformed — " + + "no server can serve it."); + + var buffer = new MemoryStream(1024); + using (var w = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false })) + { + w.WriteStartObject(); + + w.WriteStartObject("log"); + w.WriteString("loglevel", logLevel); + w.WriteEndObject(); + + w.WriteStartArray("inbounds"); + w.WriteStartObject(); + w.WriteString("listen", listenAddress); + w.WriteNumber("port", socksPort); + w.WriteString("protocol", "socks"); + w.WriteStartObject("settings"); + w.WriteString("auth", "noauth"); + // UDP would need the inbound to hand out a relay address, and nothing in + // QuickProxyNet consumes UDP. Off, rather than advertised and broken. + w.WriteBoolean("udp", false); + w.WriteEndObject(); + w.WriteEndObject(); + w.WriteEndArray(); + + w.WriteStartArray("outbounds"); + w.WriteStartObject(); + w.WriteString("protocol", "vless"); + + w.WriteStartObject("settings"); + w.WriteStartArray("vnext"); + w.WriteStartObject(); + w.WriteString("address", options.Host); + w.WriteNumber("port", options.Port); + w.WriteStartArray("users"); + w.WriteStartObject(); + w.WriteString("id", options.Id); + w.WriteString("encryption", "none"); + if (!string.IsNullOrEmpty(options.Flow)) + w.WriteString("flow", options.Flow); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteEndObject(); + w.WriteEndArray(); + w.WriteEndObject(); + + w.WriteStartObject("streamSettings"); + w.WriteString("network", network); + w.WriteString("security", security); + + if (options.Security == VlessSecurity.Reality) + { + w.WriteStartObject("realitySettings"); + w.WriteString("serverName", options.Sni ?? options.Host); + w.WriteString("fingerprint", string.IsNullOrEmpty(options.Fingerprint) + ? DefaultFingerprint + : options.Fingerprint); + w.WriteString("publicKey", options.RealityPublicKey!); + if (!string.IsNullOrEmpty(options.RealityShortId)) + w.WriteString("shortId", options.RealityShortId); + w.WriteEndObject(); + } + else + { + w.WriteStartObject("tlsSettings"); + w.WriteString("serverName", options.Sni ?? options.HostHeader ?? options.Host); + w.WriteString("fingerprint", string.IsNullOrEmpty(options.Fingerprint) + ? DefaultFingerprint + : options.Fingerprint); + if (options.Alpn is { Count: > 0 }) + { + w.WriteStartArray("alpn"); + foreach (string alpn in options.Alpn) + w.WriteStringValue(alpn); + w.WriteEndArray(); + } + w.WriteEndObject(); + } + + WriteTransportSettings(w, network, options); + + w.WriteEndObject(); // streamSettings + w.WriteEndObject(); // outbound + w.WriteEndArray(); + + w.WriteEndObject(); + } + + return buffer.ToArray(); + } + + private static void WriteTransportSettings(Utf8JsonWriter w, string network, VlessOptions options) + { + if (network == "tcp") + return; + + w.WriteStartObject(network == "ws" ? "wsSettings" : "httpupgradeSettings"); + w.WriteString("path", string.IsNullOrEmpty(options.Path) ? "/" : options.Path); + + // Xray's own key for the Host header. Same fallback chain the core package uses, so a + // link behaves identically whichever client drives it. + string? host = options.HostHeader ?? options.Sni; + if (!string.IsNullOrEmpty(host)) + w.WriteString("host", host); + + w.WriteEndObject(); + } + + /// Maps a share-link type to Xray's network. + private static string ResolveNetwork(string? transport) => + (transport ?? "tcp").ToLowerInvariant() switch + { + "" or "tcp" or "raw" or "none" => "tcp", + "ws" or "websocket" => "ws", + "httpupgrade" => "httpupgrade", + // grpc and xhttp are things Xray can speak, but their share links carry fields + // (serviceName, mode) that VlessOptions does not model yet. Rendering them from + // the fields we do have would produce a config that connects to the wrong path. + var other => throw new NotSupportedException( + $"Transport '{other}' is not rendered yet; supported: tcp/raw, ws, httpupgrade.") + }; +} diff --git a/QuickProxyNet.Reality/XrayExecutable.cs b/QuickProxyNet.Reality/XrayExecutable.cs new file mode 100644 index 0000000..ba08e24 --- /dev/null +++ b/QuickProxyNet.Reality/XrayExecutable.cs @@ -0,0 +1,76 @@ +using System.Runtime.InteropServices; + +namespace QuickProxyNet.Reality; + +/// +/// Finds the Xray-core executable to run. +/// +internal static class XrayExecutable +{ + /// + /// Resolves the binary from the explicit path, then QPN_XRAY_PATH, then PATH. + /// + /// + /// Nothing was found. The message lists every place that was searched — a caller who has to + /// guess where the library looked cannot fix the problem. + /// + public static string Resolve(string? explicitPath) + { + if (!string.IsNullOrEmpty(explicitPath)) + { + if (!File.Exists(explicitPath)) + throw new FileNotFoundException( + $"Xray-core was not found at the configured path '{explicitPath}'.", explicitPath); + + return Path.GetFullPath(explicitPath); + } + + string? fromEnvironment = Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable); + if (!string.IsNullOrEmpty(fromEnvironment)) + { + if (!File.Exists(fromEnvironment)) + throw new FileNotFoundException( + $"{RealityProxyOptions.ExecutablePathVariable} points at '{fromEnvironment}', " + + "which does not exist.", fromEnvironment); + + return Path.GetFullPath(fromEnvironment); + } + + string fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "xray.exe" : "xray"; + string? onPath = SearchPath(fileName); + if (onPath is not null) + return onPath; + + throw new FileNotFoundException( + $"Xray-core was not found. QuickProxyNet.Reality does not ship a binary; supply one via " + + $"{nameof(RealityProxyOptions)}.{nameof(RealityProxyOptions.ExecutablePath)}, the " + + $"{RealityProxyOptions.ExecutablePathVariable} environment variable, or by putting " + + $"'{fileName}' on PATH."); + } + + private static string? SearchPath(string fileName) + { + string? path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrEmpty(path)) + return null; + + foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string candidate; + try + { + candidate = Path.Combine(directory.Trim('"'), fileName); + } + catch (ArgumentException) + { + // A malformed PATH entry is not a reason to fail the whole search. + continue; + } + + if (File.Exists(candidate)) + return candidate; + } + + return null; + } +} diff --git a/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs new file mode 100644 index 0000000..c191240 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs @@ -0,0 +1,291 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using QuickProxyNet.Reality; + +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. + /// + /// + [Theory] + [InlineData(1_000)] + [InlineData(16_000)] + public async Task Socks5_HandlesLargeRequests(int padding) + { + string executable = Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + if (string.IsNullOrEmpty(executable)) + return; + + 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..4887b41 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs @@ -0,0 +1,460 @@ +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 +{ + /// 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); + if (log.Count > 40) + 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"); + 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/RealityProxyTests.cs b/QuickProxyNet.Tests/Integration/RealityProxyTests.cs new file mode 100644 index 0000000..66f584a --- /dev/null +++ b/QuickProxyNet.Tests/Integration/RealityProxyTests.cs @@ -0,0 +1,192 @@ +using System.Text; +using QuickProxyNet.Reality; + +namespace QuickProxyNet.Tests.Integration; + +/// +/// End-to-end tests for against a real Xray-core REALITY server. +/// +/// +/// +/// Everything here runs on loopback: the REALITY server, the decoy TLS endpoint its handshake is +/// relayed to, the tunnel client, and the HTTP target. Nothing leaves the machine, so a failure +/// means the code is wrong rather than that the network was. +/// +/// +/// Gated on QPN_XRAY_PATH because the package deliberately does not ship a binary. Without +/// it these report as skipped, never as passed. +/// +/// +public class RealityProxyTests +{ + private static string Executable => Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + + private static RealityProxyOptions Options(Action? logSink = null) => new() + { + ExecutablePath = Executable, + StartupTimeout = TimeSpan.FromSeconds(20), + LogLevel = logSink is null ? "warning" : "info", + LogSink = logSink + }; + + /// + /// How long any single tunnel exchange may take before the test gives up. + /// + /// + /// Not optional. A tunnel that fails to authenticate does not necessarily close — Xray may + /// hold the connection open while it relays the handshake elsewhere — so an unbounded read + /// turns a failing test into a hung test run, which is strictly worse than a red one. + /// + private static readonly TimeSpan ExchangeTimeout = TimeSpan.FromSeconds(20); + + /// Issues a GET through the tunnel and returns the whole response. + private static async Task GetThroughAsync(RealityProxy proxy, int targetPort) + { + using var timeout = new CancellationTokenSource(ExchangeTimeout); + + await using Stream tunnel = await proxy.ConnectAsync("127.0.0.1", targetPort, timeout.Token); + + byte[] request = Encoding.ASCII.GetBytes( + $"GET / HTTP/1.1\r\nHost: 127.0.0.1:{targetPort}\r\nConnection: close\r\n\r\n"); + await tunnel.WriteAsync(request, timeout.Token); + await tunnel.FlushAsync(timeout.Token); + + using var reader = new StreamReader(tunnel, Encoding.ASCII); + return await reader.ReadToEndAsync(timeout.Token); + } + + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task Reality_RoundTrip() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); + + string response = await GetThroughAsync(proxy, echo.Port); + + Assert.Contains(LoopbackEchoServer.Body, response); + } + + /// + /// XTLS Vision over REALITY — the combination the in-process client cannot do at all. + /// + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task Reality_Vision_RoundTrip() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = + await LocalRealityServer.StartAsync(Executable, flow: "xtls-rprx-vision"); + await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); + + string response = await GetThroughAsync(proxy, echo.Port); + + Assert.Contains(LoopbackEchoServer.Body, response); + } + + /// + /// A payload several times larger than a single write, carried through the tunnel. + /// + /// + /// + /// Capped below 16 KiB on purpose. Xray's own SOCKS inbound stops relaying a request larger + /// than roughly one TLS record: 16 000 bytes round-trips, 16 500 hangs. That boundary is not + /// ours — isolates it, showing QuickProxyNet's + /// SOCKS5 client carrying 100 000 bytes through a plain relay and the same request stalling + /// against Xray with no VLESS, TLS or REALITY anywhere in the path. + /// + /// + /// Record-boundary coverage therefore lives with the managed implementation, which has no + /// such intermediary: see ManagedRealityTunnelTests, which carries 40 000 bytes. + /// + /// + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task Reality_CarriesLargePayload() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); + + using var timeout = new CancellationTokenSource(ExchangeTimeout); + await using Stream tunnel = await proxy.ConnectAsync("127.0.0.1", echo.Port, timeout.Token); + + // The echo server replies only after the whole request head has arrived, so a long + // request proves the outbound direction carried all of it. + byte[] request = Encoding.ASCII.GetBytes( + $"GET /{new string('a', 8_000)} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + await tunnel.WriteAsync(request, timeout.Token); + await tunnel.FlushAsync(timeout.Token); + + using var reader = new StreamReader(tunnel, Encoding.ASCII); + Assert.Contains(LoopbackEchoServer.Body, await reader.ReadToEndAsync(timeout.Token)); + } + + /// + /// The test that proves REALITY authentication is actually happening. + /// + /// + /// A well-formed but wrong public key must fail. If it succeeded, the tunnel would be + /// falling through to an ordinary TLS session against the decoy — which is exactly the + /// silent downgrade the rest of this repo is built to prevent, and it would make every + /// other test in this class prove nothing about REALITY. + /// + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task Reality_WrongPublicKey_DoesNotTunnel() + { + using LoopbackEchoServer echo = LoopbackEchoServer.Start(); + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + + // Valid base64url for 32 bytes, and not the server's key: Xray accepts the configuration + // and fails the handshake, which is the case under test. + string wrongKey = "LmsbBDEPXyy3PS0kYTdC55wlSCqteIEaw6trnKcMUeE"; + string link = server.ShareLink().Replace(LocalRealityServer.PublicKey, wrongKey); + + await using RealityProxy proxy = await RealityProxy.StartAsync(link, Options()); + + // The local SOCKS inbound accepts, then Xray fails to reach the server, so the failure + // surfaces either as a SOCKS error or as the tunnel closing without a response. + string response; + try + { + response = await GetThroughAsync(proxy, echo.Port); + } + catch (Exception ex) when (ex is IOException or ProxyProtocolException or OperationCanceledException) + { + // Refused outright, or the tunnel never carried anything before the timeout. Both are + // the outcome this test wants; only a successful round trip would be wrong. + return; + } + + Assert.DoesNotContain(LoopbackEchoServer.Body, response); + } + + /// + /// The local inbound must never be reachable from off the machine: it has no authentication, + /// so a routable bind would be an open proxy. + /// + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task LocalInbound_IsLoopbackOnly() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); + + Assert.Equal("127.0.0.1", proxy.ListenAddress); + Assert.True(proxy.IsRunning); + } + + /// + /// Disposal must stop the process this instance started. + /// + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task Dispose_StopsTheProcess() + { + await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); + RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); + + Assert.True(proxy.IsRunning); + await proxy.DisposeAsync(); + Assert.False(proxy.IsRunning); + + await Assert.ThrowsAsync( + async () => await proxy.ConnectAsync("127.0.0.1", 80)); + } +} diff --git a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj index 506c132..80bb129 100644 --- a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +++ b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/QuickProxyNet.Tests/RealityConfigTest.cs b/QuickProxyNet.Tests/RealityConfigTest.cs new file mode 100644 index 0000000..c0b5ebe --- /dev/null +++ b/QuickProxyNet.Tests/RealityConfigTest.cs @@ -0,0 +1,250 @@ +using System.Diagnostics; +using System.Text.Json; +using QuickProxyNet.Reality; + +namespace QuickProxyNet.Tests; + +/// +/// Tests for the Xray client configuration that generates. +/// +/// +/// These are the tests that can be exact. The configuration is a pure function of +/// , so every field can be asserted without a process, a port or a +/// network. What they cannot prove is that Xray accepts the document — that is what +/// and the integration tests are for. +/// +public class RealityConfigTest +{ + private const string Uuid = "6643f196-ae07-420a-b173-d909d20807c1"; + + // Synthetic REALITY keypair, generated with 'xray x25519' for this test suite and committed + // on purpose — same status as the repdigit UUIDs and the self-signed cert under + // tests/docker/certs. It protects nothing and never guarded a real server. + private const string PublicKey = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; + private const string ShortId = "ab12"; + + private static JsonElement Build(string shareLink, int port = 21080) + { + byte[] json = XrayClientConfig.Build(VlessShareLink.Parse(shareLink), "127.0.0.1", port, "warning"); + return JsonDocument.Parse(json).RootElement.Clone(); + } + + private static string RealityLink(string extra = "") => + $"vless://{Uuid}@example.com:443?security=reality&pbk={PublicKey}&sid={ShortId}" + + $"&sni=www.cloudflare.com&fp=chrome{extra}#node"; + + // REALITY runs only over raw TCP, so the transport tests use a plain TLS link instead. + private static string TlsLink(string extra = "") => + $"vless://{Uuid}@example.com:443?security=tls&sni=www.cloudflare.com&fp=chrome{extra}#node"; + + private static JsonElement Outbound(JsonElement root) => + root.GetProperty("outbounds")[0]; + + private static JsonElement Stream(JsonElement root) => + Outbound(root).GetProperty("streamSettings"); + + [Fact] + public void Reality_RendersRealitySettings() + { + JsonElement stream = Stream(Build(RealityLink())); + + Assert.Equal("reality", stream.GetProperty("security").GetString()); + Assert.Equal("tcp", stream.GetProperty("network").GetString()); + + JsonElement reality = stream.GetProperty("realitySettings"); + Assert.Equal("www.cloudflare.com", reality.GetProperty("serverName").GetString()); + Assert.Equal("chrome", reality.GetProperty("fingerprint").GetString()); + Assert.Equal(PublicKey, reality.GetProperty("publicKey").GetString()); + Assert.Equal(ShortId, reality.GetProperty("shortId").GetString()); + } + + [Fact] + public void Reality_RendersVnextUser() + { + JsonElement vnext = Outbound(Build(RealityLink())).GetProperty("settings").GetProperty("vnext")[0]; + + Assert.Equal("example.com", vnext.GetProperty("address").GetString()); + Assert.Equal(443, vnext.GetProperty("port").GetInt32()); + + JsonElement user = vnext.GetProperty("users")[0]; + Assert.Equal(Uuid, user.GetProperty("id").GetString()); + Assert.Equal("none", user.GetProperty("encryption").GetString()); + } + + // An empty fingerprint makes Xray skip uTLS and emit Go's own ClientHello — the single most + // recognisable handshake a REALITY client can produce. Defaulting it is a safety behaviour, + // so it gets a test rather than being left to the reader of the source. + [Fact] + public void Reality_WithoutFingerprint_DefaultsToChrome() + { + string link = $"vless://{Uuid}@example.com:443?security=reality&pbk={PublicKey}&sid={ShortId}&sni=a.example#n"; + JsonElement reality = Stream(Build(link)).GetProperty("realitySettings"); + + Assert.Equal("chrome", reality.GetProperty("fingerprint").GetString()); + } + + [Fact] + public void Reality_WithoutSni_FallsBackToHost() + { + string link = $"vless://{Uuid}@example.com:443?security=reality&pbk={PublicKey}&sid={ShortId}#n"; + JsonElement reality = Stream(Build(link)).GetProperty("realitySettings"); + + Assert.Equal("example.com", reality.GetProperty("serverName").GetString()); + } + + [Fact] + public void Vision_FlowIsPassedThrough() + { + JsonElement user = Outbound(Build(RealityLink("&flow=xtls-rprx-vision"))) + .GetProperty("settings").GetProperty("vnext")[0].GetProperty("users")[0]; + + Assert.Equal("xtls-rprx-vision", user.GetProperty("flow").GetString()); + } + + [Fact] + public void NoFlow_OmitsFlowEntirely() + { + JsonElement user = Outbound(Build(RealityLink())) + .GetProperty("settings").GetProperty("vnext")[0].GetProperty("users")[0]; + + // Present-but-empty is not the same as absent: Xray rejects an unknown empty flow. + Assert.False(user.TryGetProperty("flow", out _)); + } + + [Fact] + public void WebSocket_RendersWsSettings() + { + JsonElement stream = Stream(Build(TlsLink("&type=ws&path=%2Fqpn-ws&host=cdn.example"))); + + Assert.Equal("ws", stream.GetProperty("network").GetString()); + JsonElement ws = stream.GetProperty("wsSettings"); + Assert.Equal("/qpn-ws", ws.GetProperty("path").GetString()); + Assert.Equal("cdn.example", ws.GetProperty("host").GetString()); + } + + [Fact] + public void HttpUpgrade_RendersHttpUpgradeSettings() + { + JsonElement stream = Stream(Build(TlsLink("&type=httpupgrade&path=%2Fqpn-hu"))); + + Assert.Equal("httpupgrade", stream.GetProperty("network").GetString()); + Assert.Equal("/qpn-hu", stream.GetProperty("httpupgradeSettings").GetProperty("path").GetString()); + } + + [Fact] + public void Inbound_IsLoopbackSocksWithoutAuth() + { + JsonElement inbound = Build(RealityLink(), port: 31234).GetProperty("inbounds")[0]; + + Assert.Equal("socks", inbound.GetProperty("protocol").GetString()); + Assert.Equal("127.0.0.1", inbound.GetProperty("listen").GetString()); + Assert.Equal(31234, inbound.GetProperty("port").GetInt32()); + Assert.Equal("noauth", inbound.GetProperty("settings").GetProperty("auth").GetString()); + Assert.False(inbound.GetProperty("settings").GetProperty("udp").GetBoolean()); + } + + [Fact] + public void Tls_RendersTlsSettingsInsteadOfReality() + { + string link = $"vless://{Uuid}@example.com:443?security=tls&sni=a.example&flow=xtls-rprx-vision#n"; + JsonElement stream = Stream(Build(link)); + + Assert.Equal("tls", stream.GetProperty("security").GetString()); + Assert.Equal("a.example", stream.GetProperty("tlsSettings").GetProperty("serverName").GetString()); + Assert.False(stream.TryGetProperty("realitySettings", out _)); + } + + // The whole point of the package is that it never quietly does something weaker than the + // link asked for. Each of these would otherwise connect with less protection than intended. + [Fact] + public void PlainVless_IsRefusedAndNamesTheAlternative() + { + var ex = Assert.Throws( + () => Build($"vless://{Uuid}@example.com:443?security=none#n")); + + Assert.Contains("VlessClient", ex.Message); + } + + /// + /// REALITY exists only over raw TCP (plus xhttp and gRPC in Xray). A link combining it with a + /// WebSocket transport describes something no server can serve. + /// + /// + /// Learned the hard way: an earlier version of the builder happily rendered + /// security=reality with type=ws, and Xray refused the document at startup with + /// "REALITY only supports RAW, XHTTP and gRPC for now" — which reached the caller as an opaque + /// launch failure. + /// + [Theory] + [InlineData("ws")] + [InlineData("httpupgrade")] + public void RealityOverAWebTransport_IsRefused(string transport) + { + var ex = Assert.Throws(() => Build(RealityLink($"&type={transport}"))); + + Assert.Contains("raw/tcp", ex.Message); + } + + [Fact] + public void Reality_WithoutPublicKey_IsRefused() + { + var options = new VlessOptions + { + Id = Uuid, + Host = "example.com", + Port = 443, + Security = VlessSecurity.Reality + }; + + var ex = Assert.Throws( + () => XrayClientConfig.Build(options, "127.0.0.1", 21080, "warning")); + + Assert.Contains("public key", ex.Message); + } + + [Fact] + public void UnmodelledTransport_IsRefusedAndListsWhatWorks() + { + var ex = Assert.Throws(() => Build(TlsLink("&type=grpc"))); + + Assert.Contains("httpupgrade", ex.Message); + } + + /// + /// Feeds the generated document to the real Xray binary's own validator. + /// + /// + /// Every other test in this class checks the JSON against my belief about what Xray wants. + /// This one checks it against Xray. It needs no network and no server — run -test + /// parses the configuration and exits — so it is the cheapest test here that can falsify + /// the field names. + /// + [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + public async Task Config_IsAcceptedByXray() + { + byte[] config = XrayClientConfig.Build( + VlessShareLink.Parse(RealityLink("&flow=xtls-rprx-vision")), + "127.0.0.1", 21080, "warning"); + + string executable = Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + var psi = new ProcessStartInfo(executable) + { + ArgumentList = { "run", "-test", "-c", "stdin:" }, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi)!; + await process.StandardInput.BaseStream.WriteAsync(config); + process.StandardInput.Close(); + + string output = await process.StandardOutput.ReadToEndAsync() + + await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + Assert.True(process.ExitCode == 0, $"Xray rejected the generated configuration:\n{output}"); + } +} diff --git a/QuickProxyNet.slnx b/QuickProxyNet.slnx index ebba7b1..d1e6e57 100644 --- a/QuickProxyNet.slnx +++ b/QuickProxyNet.slnx @@ -5,5 +5,6 @@ + From 7cdea61be8c32a00a2ad1186510fe0691acbaa3e Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 15:37:47 +0500 Subject: [PATCH 11/25] feat(reality): implement REALITY in managed code, no Xray process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REALITY client written in C#: X25519, the authentication construction, a TLS 1.3 client, and the record layer. It completes a handshake against real Xray-core 26.3.27 and carries VLESS to a target, with no external binary. The protocol was taken from the sources rather than from descriptions, which settled the detail that had been flagged as unverified: the X25519 private key REALITY authenticates with is the *same* ephemeral key the ClientHello offers in key_share. The server recovers the public half from clientHello.keyShares, so nothing extra is smuggled anywhere. authKey = X25519(clientKeySharePrivate, serverPublicKey) authKey = HKDF-SHA256(authKey, salt: clientRandom[0..20], info: "REALITY") session_id = AES-256-GCM(authKey, nonce: clientRandom[20..32], plaintext: version|0|unixtime|shortId, aad: the raw ClientHello with session_id zeroed) and the server proves itself with HMAC-SHA512(authKey, leafPublicKey) placed in the leaf certificate's signature field. Verified against published vectors wherever possible, because a self-consistent implementation that disagrees with Go would pass any test we invented ourselves: - X25519 against RFC 7748 §5.2 and §6.1, plus a keypair generated by 'xray x25519' — the vectors caught a real bug, a ladder step using BB where RFC 7748 requires AA. - The key schedule against RFC 8448's trace, all eight values. - The sealed session_id opened with the server algorithm from tls.go, and an end-to-end check that a hand-built ClientHello makes Xray log acceptance. What is deliberately not implemented: PSK, resumption, HelloRetryRequest, client certificates, key update. Each is rejected by name rather than worked around. The Ed25519 CertificateVerify signature is not checked either, and that one is a judgement rather than an omission — REALITY's HMAC binds a per-connection certificate to the shared secret, which is the stronger of the two checks; outside REALITY the same omission would be a hole. Not yet a browser fingerprint, and the source says so where it matters. The hello has no GREASE, no padding, an arbitrary extension order, and a bare X25519 key_share where current Chrome sends X25519MLKEM768. A client that merely *works* is more identifiable than one that fails, so this is a protocol implementation for now and not yet a censorship-resistance tool. Co-Authored-By: Claude Opus 5 (1M context) --- QuickProxyNet.Reality/Managed/RealityAuth.cs | 223 +++++++ .../Managed/RealityTlsClient.cs | 573 ++++++++++++++++++ .../Managed/RealityTlsStream.cs | 200 ++++++ .../Managed/TlsClientHello.cs | 223 +++++++ .../Managed/TlsKeySchedule.cs | 129 ++++ .../Managed/TlsRecordLayer.cs | 278 +++++++++ QuickProxyNet.Reality/Managed/TlsWriter.cs | 120 ++++ QuickProxyNet.Reality/Managed/X25519.cs | 386 ++++++++++++ .../ManagedRealityHandshakeTests.cs | 149 +++++ .../Integration/ManagedRealityTunnelTests.cs | 124 ++++ .../Integration/ManagedTlsHandshakeTests.cs | 123 ++++ QuickProxyNet.Tests/RealityAuthTest.cs | 241 ++++++++ QuickProxyNet.Tests/TlsKeyScheduleTest.cs | 139 +++++ QuickProxyNet.Tests/X25519Test.cs | 140 +++++ 14 files changed, 3048 insertions(+) create mode 100644 QuickProxyNet.Reality/Managed/RealityAuth.cs create mode 100644 QuickProxyNet.Reality/Managed/RealityTlsClient.cs create mode 100644 QuickProxyNet.Reality/Managed/RealityTlsStream.cs create mode 100644 QuickProxyNet.Reality/Managed/TlsClientHello.cs create mode 100644 QuickProxyNet.Reality/Managed/TlsKeySchedule.cs create mode 100644 QuickProxyNet.Reality/Managed/TlsRecordLayer.cs create mode 100644 QuickProxyNet.Reality/Managed/TlsWriter.cs create mode 100644 QuickProxyNet.Reality/Managed/X25519.cs create mode 100644 QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs create mode 100644 QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs create mode 100644 QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs create mode 100644 QuickProxyNet.Tests/RealityAuthTest.cs create mode 100644 QuickProxyNet.Tests/TlsKeyScheduleTest.cs create mode 100644 QuickProxyNet.Tests/X25519Test.cs diff --git a/QuickProxyNet.Reality/Managed/RealityAuth.cs b/QuickProxyNet.Reality/Managed/RealityAuth.cs new file mode 100644 index 0000000..77c522b --- /dev/null +++ b/QuickProxyNet.Reality/Managed/RealityAuth.cs @@ -0,0 +1,223 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet.Reality.Managed; + +/// +/// 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); + + try + { + using var aes = new AesGcm(authKey, tagSizeInBytes: 16); + aes.Encrypt(nonce, plaintext, sessionId[..16], sessionId[16..], clientHello); + } + 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.Reality/Managed/RealityTlsClient.cs b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs new file mode 100644 index 0000000..6d3eea0 --- /dev/null +++ b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs @@ -0,0 +1,573 @@ +using System.Buffers.Binary; +using System.Formats.Asn1; +using System.Security.Cryptography; + +namespace QuickProxyNet.Reality.Managed; + +/// 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"; + + /// + /// 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]; + + try + { + // ---- ClientHello, with the REALITY blob sealed into its session id ---- + TlsClientHello.Result hello = TlsClientHello.Build(options.ServerName, options.Alpn); + + RealityAuth.DeriveAuthKey(authKey, hello.PrivateKey, options.PublicKey, hello.Handshake.AsSpan(6, 32)); + + byte[] shortId = new 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 ---- + var 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); + + // 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 ---- + byte[] shared = new byte[X25519.KeySize]; + byte[] handshakeSecret = new byte[parsed.Suite.HashLength]; + byte[] clientHandshakeTraffic = new byte[parsed.Suite.HashLength]; + byte[] serverHandshakeTraffic = new byte[parsed.Suite.HashLength]; + byte[] masterSecret = new byte[parsed.Suite.HashLength]; + + try + { + X25519.Agree(shared, hello.PrivateKey, parsed.KeyShare); + + DeriveHandshakeSecrets( + parsed.Suite, shared, transcript.GetCurrentHash(), + handshakeSecret, clientHandshakeTraffic, serverHandshakeTraffic, masterSecret); + + records.Read = new TlsRecordProtection(parsed.Suite, serverHandshakeTraffic); + + // ---- Server flight ---- + byte[]? leafCertificate = null; + byte[]? serverVerifyData = null; + + while (serverVerifyData is null) + { + HandshakeMessage message = await messages.NextAsync(cancellationToken).ConfigureAwait(false); + + 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. + serverVerifyData = VerifyServerFinished( + parsed.Suite, serverHandshakeTraffic, transcript.GetCurrentHash(), message.Body.Span); + 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, new byte[] { 1 }, cancellationToken) + .ConfigureAwait(false); + + records.Write = new TlsRecordProtection(parsed.Suite, clientHandshakeTraffic); + + byte[] finished = BuildFinished(parsed.Suite, clientHandshakeTraffic, transcriptAfterServerFinished); + await records.WriteAsync(TlsContentType.Handshake, finished, cancellationToken).ConfigureAwait(false); + + // ---- Application keys ---- + byte[] clientApplication = new byte[parsed.Suite.HashLength]; + byte[] serverApplication = new byte[parsed.Suite.HashLength]; + try + { + TlsKeySchedule.DeriveSecret( + parsed.Suite.Hash, masterSecret, "c ap traffic"u8, transcriptAfterServerFinished, clientApplication); + TlsKeySchedule.DeriveSecret( + parsed.Suite.Hash, masterSecret, "s ap traffic"u8, transcriptAfterServerFinished, serverApplication); + + records.Write?.Dispose(); + records.Read?.Dispose(); + records.Write = new TlsRecordProtection(parsed.Suite, clientApplication); + records.Read = new TlsRecordProtection(parsed.Suite, serverApplication); + } + finally + { + CryptographicOperations.ZeroMemory(clientApplication); + CryptographicOperations.ZeroMemory(serverApplication); + } + + return new RealityTlsStream(transport, records, messages.Leftover); + } + finally + { + CryptographicOperations.ZeroMemory(shared); + CryptographicOperations.ZeroMemory(handshakeSecret); + CryptographicOperations.ZeroMemory(clientHandshakeTraffic); + CryptographicOperations.ZeroMemory(serverHandshakeTraffic); + CryptographicOperations.ZeroMemory(masterSecret); + } + } + catch + { + records.Dispose(); + throw; + } + finally + { + CryptographicOperations.ZeroMemory(authKey); + } + } + + 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 byte[] 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( + "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."); + + return body.ToArray(); + } + + 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( + $"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( + "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); + + private static ServerHello ParseServerHello(byte[] raw) + { + ReadOnlySpan body = raw.AsSpan(4); + + if (body.Length < 34) + throw new RealityHandshakeException("The ServerHello is truncated."); + + ReadOnlySpan random = body.Slice(2, 32); + 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 offset = 34; + int sessionIdLength = body[offset++]; + offset += sessionIdLength; + + ushort suiteId = BinaryPrimitives.ReadUInt16BigEndian(body[offset..]); + offset += 2; + offset += 1; // legacy_compression_method + + 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(body[offset..]); + offset += 2; + ReadOnlySpan extensions = body.Slice(offset, extensionsLength); + + byte[]? keyShare = null; + bool sawTls13 = false; + + while (extensions.Length >= 4) + { + ushort type = BinaryPrimitives.ReadUInt16BigEndian(extensions); + int length = BinaryPrimitives.ReadUInt16BigEndian(extensions[2..]); + ReadOnlySpan data = extensions.Slice(4, length); + extensions = extensions[(4 + length)..]; + + 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) + 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); + } + + /// Reads the first certificate out of a TLS 1.3 Certificate message body. + private static byte[] ExtractLeafCertificate(ReadOnlyMemory body) + { + ReadOnlySpan span = body.Span; + + if (span.Length < 4) + throw new RealityHandshakeException("The Certificate message is truncated."); + + int contextLength = span[0]; + span = span[(1 + contextLength)..]; + + int listLength = (span[0] << 16) | (span[1] << 8) | span[2]; + span = span.Slice(3, listLength); + + if (span.Length < 3) + throw new RealityHandshakeException("The server sent an empty certificate list."); + + int certificateLength = (span[0] << 16) | (span[1] << 8) | span[2]; + return span.Slice(3, certificateLength).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) + { + private byte[] _buffer = new byte[TlsRecordStream.MaxCiphertext]; + private int _length; + private int _consumed; + + /// Application data that arrived before the handshake finished. + public List Leftover { get; } = []; + + 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: + continue; + + case TlsContentType.Alert: + throw new RealityHandshakeException(DescribeAlert(record.Payload.Span)); + + case TlsContentType.ApplicationData: + Leftover.AddRange(record.Payload.ToArray()); + continue; + + case TlsContentType.Handshake: + 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 > _buffer.Length) + Array.Resize(ref _buffer, Math.Max(_buffer.Length * 2, _length + data.Length)); + + 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. +public sealed class RealityHandshakeException : Exception +{ + /// Creates the exception. + /// What went wrong. + public RealityHandshakeException(string message) : base(message) + { + } + + /// Creates the exception. + /// What went wrong. + /// The underlying failure. + public RealityHandshakeException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/QuickProxyNet.Reality/Managed/RealityTlsStream.cs b/QuickProxyNet.Reality/Managed/RealityTlsStream.cs new file mode 100644 index 0000000..483950d --- /dev/null +++ b/QuickProxyNet.Reality/Managed/RealityTlsStream.cs @@ -0,0 +1,200 @@ +namespace QuickProxyNet.Reality.Managed; + +/// +/// 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; + + private byte[] _pending; + private int _pendingOffset; + private bool _receivedCloseNotify; + private bool _disposed; + + internal RealityTlsStream(Stream transport, TlsRecordStream records, List leftover) + { + _transport = transport; + _records = records; + _pending = leftover.Count > 0 ? leftover.ToArray() : []; + } + + 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(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (buffer.IsEmpty) + return 0; + + while (_pendingOffset >= _pending.Length) + { + if (_receivedCloseNotify) + return 0; + + if (!await FillAsync(cancellationToken).ConfigureAwait(false)) + return 0; + } + + int count = Math.Min(buffer.Length, _pending.Length - _pendingOffset); + _pending.AsSpan(_pendingOffset, count).CopyTo(buffer.Span); + _pendingOffset += count; + + return count; + } + + /// Reads records until one yields application data. Returns false at end of stream. + 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: + _pending = record.Payload.ToArray(); + _pendingOffset = 0; + return true; + + case TlsContentType.ApplicationData: + case TlsContentType.ChangeCipherSpec: + continue; + + case TlsContentType.Handshake: + SkipPostHandshakeMessage(record.Payload.Span); + 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."); + } + } + } + + 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}."); + } + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + while (!buffer.IsEmpty) + { + int chunk = Math.Min(buffer.Length, TlsRecordStream.MaxPlaintext); + await _records.WriteAsync(TlsContentType.ApplicationData, buffer[..chunk], cancellationToken) + .ConfigureAwait(false); + + buffer = buffer[chunk..]; + } + } + + 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; + + if (disposing) + { + _records.Dispose(); + _transport.Dispose(); + } + + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + _records.Dispose(); + await _transport.DisposeAsync().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } +} diff --git a/QuickProxyNet.Reality/Managed/TlsClientHello.cs b/QuickProxyNet.Reality/Managed/TlsClientHello.cs new file mode 100644 index 0000000..f498ca5 --- /dev/null +++ b/QuickProxyNet.Reality/Managed/TlsClientHello.cs @@ -0,0 +1,223 @@ +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet.Reality.Managed; + +/// +/// 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); + + var writer = new TlsWriter(); + + 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 + 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(serverName)); + writer.EndVector(name, 2); + writer.EndVector(list, 2); + + writer.EndVector(extension, 2); + } + + 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(); + // ed25519 is not optional here: the certificate a REALITY server returns once it has + // authenticated the client is Ed25519, so omitting it would make the server unable to + // answer us at all. + writer.WriteUInt16(0x0807); // ed25519 + writer.WriteUInt16(0x0403); // ecdsa_secp256r1_sha256 + writer.WriteUInt16(0x0804); // rsa_pss_rsae_sha256 + writer.WriteUInt16(0x0805); // rsa_pss_rsae_sha384 + writer.WriteUInt16(0x0806); // rsa_pss_rsae_sha512 + writer.WriteUInt16(0x0401); // rsa_pkcs1_sha256 + writer.WriteUInt16(0x0501); // rsa_pkcs1_sha384 + 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.Reality/Managed/TlsKeySchedule.cs b/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs new file mode 100644 index 0000000..e2fdb4f --- /dev/null +++ b/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Text; + +namespace QuickProxyNet.Reality.Managed; + +/// +/// 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); + + for (int i = 0; i < 8; i++) + nonce[nonce.Length - 1 - i] ^= (byte)(sequenceNumber >> (8 * i)); + } + + /// 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.Reality/Managed/TlsRecordLayer.cs b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs new file mode 100644 index 0000000..392a799 --- /dev/null +++ b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs @@ -0,0 +1,278 @@ +using System.Security.Cryptography; + +namespace QuickProxyNet.Reality.Managed; + +/// 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) + { + byte[] key = new byte[suite.KeyLength]; + _iv = new byte[TlsCipherSuite.NonceLength]; + + 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 in place. + /// + /// 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++); + + if (_chaCha is not null) + _chaCha.Decrypt(nonce, ciphertext, tag, plaintext, header); + else + _aes!.Decrypt(nonce, ciphertext, tag, plaintext, header); + } + + public void Dispose() + { + _aes?.Dispose(); + _chaCha?.Dispose(); + CryptographicOperations.ZeroMemory(_iv); + } +} + +/// +/// Reads and writes TLS records over a transport stream, applying protection once keys exist. +/// +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 readonly byte[] _header = new byte[5]; + private readonly byte[] _body = new byte[MaxCiphertext]; + private readonly byte[] _plaintext = new byte[MaxCiphertext]; + + /// 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. + public async ValueTask ReadAsync(CancellationToken cancellationToken) + { + await transport.ReadExactlyAsync(_header, cancellationToken).ConfigureAwait(false); + + var type = (TlsContentType)_header[0]; + int length = (_header[3] << 8) | _header[4]; + + if (length > MaxCiphertext) + throw new InvalidOperationException($"The peer sent a {length}-byte record, over the {MaxCiphertext} limit."); + + await transport.ReadExactlyAsync(_body.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + + // 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) + return new Record(type, _body.AsMemory(0, length)); + + if (length < TlsCipherSuite.TagLength) + throw new InvalidOperationException("The peer sent an encrypted record shorter than its own tag."); + + int contentLength = length - TlsCipherSuite.TagLength; + Read.Unprotect( + _body.AsSpan(0, contentLength), + _body.AsSpan(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. + int end = contentLength; + while (end > 0 && _plaintext[end - 1] == 0) + end--; + + if (end == 0) + throw new InvalidOperationException("The peer sent a record with no content type."); + + return new Record((TlsContentType)_plaintext[end - 1], _plaintext.AsMemory(0, end - 1)); + } + + /// Writes one record, encrypting it when write protection is installed. + /// The content type. + /// The content. + /// Cancels the write. + public async ValueTask WriteAsync( + TlsContentType type, ReadOnlyMemory payload, CancellationToken cancellationToken) + { + if (Write is null) + { + byte[] plain = new byte[5 + payload.Length]; + plain[0] = (byte)type; + plain[1] = 3; + plain[2] = payload.Length > 0 && type == TlsContentType.Handshake ? (byte)1 : (byte)3; + plain[3] = (byte)(payload.Length >> 8); + plain[4] = (byte)payload.Length; + payload.Span.CopyTo(plain.AsSpan(5)); + + await transport.WriteAsync(plain, cancellationToken).ConfigureAwait(false); + await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + return; + } + + // An encrypted record always announces itself as application_data; the real type rides + // inside, after the content. + int inner = payload.Length + 1; + byte[] record = new byte[5 + inner + TlsCipherSuite.TagLength]; + record[0] = (byte)TlsContentType.ApplicationData; + record[1] = 3; + record[2] = 3; + record[3] = (byte)((inner + TlsCipherSuite.TagLength) >> 8); + record[4] = (byte)(inner + TlsCipherSuite.TagLength); + + byte[] scratch = new byte[inner]; + try + { + payload.Span.CopyTo(scratch); + scratch[payload.Length] = (byte)type; + + Write.Protect( + scratch, + record.AsSpan(5, inner), + record.AsSpan(5 + inner, TlsCipherSuite.TagLength), + record.AsSpan(0, 5)); + } + finally + { + CryptographicOperations.ZeroMemory(scratch); + } + + await transport.WriteAsync(record, cancellationToken).ConfigureAwait(false); + await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + Read?.Dispose(); + Write?.Dispose(); + } +} diff --git a/QuickProxyNet.Reality/Managed/TlsWriter.cs b/QuickProxyNet.Reality/Managed/TlsWriter.cs new file mode 100644 index 0000000..15d90a0 --- /dev/null +++ b/QuickProxyNet.Reality/Managed/TlsWriter.cs @@ -0,0 +1,120 @@ +namespace QuickProxyNet.Reality.Managed; + +/// +/// 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.Reality/Managed/X25519.cs b/QuickProxyNet.Reality/Managed/X25519.cs new file mode 100644 index 0000000..46b873d --- /dev/null +++ b/QuickProxyNet.Reality/Managed/X25519.cs @@ -0,0 +1,386 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; + +namespace QuickProxyNet.Reality.Managed; + +/// +/// 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); + Mul(x3, e, e); // x3 = (c + d)^2 + Mul(z3, c, c); // z3 = (c - d)^2 + Mul(z3, z3, x1); // z3 *= u + Mul(e, a, a); // e = BB = (x2 - z2)^2 + Mul(c, b, 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); + } + + 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 = (UInt128)f0 * g0 + (UInt128)f1 * g4_19 + (UInt128)f2 * g3_19 + (UInt128)f3 * g2_19 + (UInt128)f4 * g1_19; + UInt128 h1 = (UInt128)f0 * g1 + (UInt128)f1 * g0 + (UInt128)f2 * g4_19 + (UInt128)f3 * g3_19 + (UInt128)f4 * g2_19; + UInt128 h2 = (UInt128)f0 * g2 + (UInt128)f1 * g1 + (UInt128)f2 * g0 + (UInt128)f3 * g4_19 + (UInt128)f4 * g3_19; + UInt128 h3 = (UInt128)f0 * g3 + (UInt128)f1 * g2 + (UInt128)f2 * g1 + (UInt128)f3 * g0 + (UInt128)f4 * g4_19; + UInt128 h4 = (UInt128)f0 * g4 + (UInt128)f1 * g3 + (UInt128)f2 * g2 + (UInt128)f3 * g1 + (UInt128)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; + } + + private static void MulSmall(Span result, Span value, ulong scalar) + { + UInt128 h0 = (UInt128)value[0] * scalar; + UInt128 h1 = (UInt128)value[1] * scalar; + UInt128 h2 = (UInt128)value[2] * scalar; + UInt128 h3 = (UInt128)value[3] * scalar; + UInt128 h4 = (UInt128)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; + } + + 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]; + + Mul(z2, z, z); // 2 + Mul(t, z2, z2); // 4 + Mul(t, t, t); // 8 + Mul(z9, t, z); // 9 + Mul(z11, z9, z2); // 11 + Mul(t, z11, 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) + { + Mul(result, value, value); + for (int i = 1; i < times; i++) + Mul(result, result, result); + } +} diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs new file mode 100644 index 0000000..83b26e2 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs @@ -0,0 +1,149 @@ +using System.Net.Sockets; +using QuickProxyNet.Reality; +using QuickProxyNet.Reality.Managed; + +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(RealityProxyOptions.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(RealityProxyOptions.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(RealityProxyOptions.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(RealityProxyOptions.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..035d5fa --- /dev/null +++ b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs @@ -0,0 +1,124 @@ +using System.Net.Sockets; +using System.Text; +using QuickProxyNet.Reality; +using QuickProxyNet.Reality.Managed; + +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(RealityProxyOptions.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) + { + 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 + }; + + return await VlessHelper.EstablishVlessTunnelAsync(tls, vless, "127.0.0.1", targetPort, cancellationToken); + } + + private static async Task GetAsync(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); + + using var reader = new StreamReader(tunnel, Encoding.ASCII); + return await reader.ReadToEndAsync(cancellationToken); + } + + /// + /// The end of the road: bytes go out through managed REALITY and the answer comes back. + /// + [EnvFact(RealityProxyOptions.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(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(RealityProxyOptions.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(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(RealityProxyOptions.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(tunnel, "/", timeout.Token)); + } + } +} diff --git a/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs new file mode 100644 index 0000000..37abf84 --- /dev/null +++ b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs @@ -0,0 +1,123 @@ +using System.Net.Sockets; +using QuickProxyNet.Reality; +using QuickProxyNet.Reality.Managed; + +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(RealityProxyOptions.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(RealityProxyOptions.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(RealityProxyOptions.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); + } + + /// + /// A short id the server does not know must fail the same way: relayed to the decoy, refused. + /// + [EnvFact(RealityProxyOptions.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)); + + await Assert.ThrowsAsync( + async () => await RealityTlsClient.HandshakeAsync( + tcp.GetStream(), Options(shortId: "cdef"), timeout.Token)); + } + + /// + /// 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(RealityProxyOptions.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/RealityAuthTest.cs b/QuickProxyNet.Tests/RealityAuthTest.cs new file mode 100644 index 0000000..bcff4f0 --- /dev/null +++ b/QuickProxyNet.Tests/RealityAuthTest.cs @@ -0,0 +1,241 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using QuickProxyNet.Reality.Managed; + +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/TlsKeyScheduleTest.cs b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs new file mode 100644 index 0000000..5caea4e --- /dev/null +++ b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs @@ -0,0 +1,139 @@ +using System.Security.Cryptography; +using QuickProxyNet.Reality.Managed; + +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/X25519Test.cs b/QuickProxyNet.Tests/X25519Test.cs new file mode 100644 index 0000000..746c543 --- /dev/null +++ b/QuickProxyNet.Tests/X25519Test.cs @@ -0,0 +1,140 @@ +using System.Security.Cryptography; +using QuickProxyNet.Reality.Managed; + +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 §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))); + } + + [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]); + } +} From f5355ce371cbcf173868fa664e7523e111890fa3 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 15:38:42 +0500 Subject: [PATCH 12/25] docs: record what the REALITY work pinned down Three findings that cost real time to establish and would cost it again: the auth key is the TLS key_share private key, REALITY does not run over WebSocket transports, and Xray's SOCKS inbound stops relaying above ~16 KiB. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2e06516..868166c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,36 @@ not "clean up" any of them without reading the reasoning first. 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`, From 754918933d1e4a6e452298acb0aa762adcec0728 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 15:58:34 +0500 Subject: [PATCH 13/25] fix(reality): refuse unauthenticated records before the server has keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by an adversarial review of the managed client. Until the ServerHello arrives there is no key to decrypt with, so the record layer returns records verbatim — and the handshake reader was buffering any application_data it saw into the leftover that becomes the first bytes the caller reads out of the tunnel. An on-path attacker, which is exactly the adversary REALITY is built against, could inject one plaintext record ahead of the server's answer and have it delivered as authenticated payload. The handshake still completed: injected records never enter the transcript, so the server's Finished and the certificate HMAC both still verified. A silent authentication break on the read path. Application data before the epoch change is now refused outright, and any plaintext handshake byte still buffered once the ServerHello has been parsed is refused too — those would otherwise be handed out later as though they had been decrypted. Also from the same review: - Verify legacy_session_id_echo (RFC 8446 §4.1.3). For REALITY it is more than a formality: the session id carries the sealed authentication blob, so a mismatch means the ClientHello was altered in flight. - Reject a non-zero compression method. - Bound every field read in ServerHello and Certificate parsing through one Take helper, so malformed input leaves as a RealityHandshakeException naming what was short instead of an IndexOutOfRangeException naming nothing. - Cap early application data, handshake message size, and ChangeCipherSpec records; each was unbounded. - Zero the ephemeral X25519 private key. Every other secret was already cleared on every path, which made this one the whole exposure. - Seal the session id into a separate buffer rather than in place: the destination aliased the additional data, and AesGcm does not document overlap. - Offer ChaCha20-Poly1305 only where the platform supports it, and punycode the SNI instead of letting Encoding.ASCII turn a non-ASCII host into '?'. Tests: a hostile-peer suite that scripts a malformed or actively hostile server in memory. The record-injection case cannot be produced by a cooperating server, so nothing in the existing Xray-backed tests could reach it. Assertions match the specific failure text, not just the exception type, so they cannot pass because the scripted peer fails later for its own reasons. Also compares the field arithmetic against System.Numerics.BigInteger over ~42 000 products including maximal limbs. The review reported a dropped carry mask in MulSmall; that was a misreading — the mask is present and both routines are correct — but the area had no direct coverage, and a dropped carry is wrong by exactly one limb weight for roughly one input in a billion, which every RFC vector and every real handshake would hide. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + QuickProxyNet.Reality/Managed/RealityAuth.cs | 10 +- .../Managed/RealityTlsClient.cs | 159 ++++++-- .../Managed/TlsClientHello.cs | 41 +- QuickProxyNet.Reality/Managed/X25519.cs | 13 +- QuickProxyNet.Tests/HostilePeerTest.cs | 367 ++++++++++++++++++ QuickProxyNet.Tests/X25519Test.cs | 75 ++++ 7 files changed, 632 insertions(+), 36 deletions(-) create mode 100644 QuickProxyNet.Tests/HostilePeerTest.cs diff --git a/.gitignore b/.gitignore index efabca3..b0f0f12 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,6 @@ FodyWeavers.xsd # Local agent tooling session state .omc/ + +# Isolated git worktrees created for background agents. +.claude/worktrees/ diff --git a/QuickProxyNet.Reality/Managed/RealityAuth.cs b/QuickProxyNet.Reality/Managed/RealityAuth.cs index 77c522b..51c4511 100644 --- a/QuickProxyNet.Reality/Managed/RealityAuth.cs +++ b/QuickProxyNet.Reality/Managed/RealityAuth.cs @@ -143,10 +143,18 @@ public static void SealSessionId( 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, sessionId[..16], sessionId[16..], clientHello); + aes.Encrypt(nonce, plaintext, sealedBlob[..16], sealedBlob[16..], clientHello); + sealedBlob.CopyTo(sessionId); } finally { diff --git a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs index 6d3eea0..5850438 100644 --- a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs +++ b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs @@ -83,11 +83,12 @@ public static async ValueTask HandshakeAsync( var records = new TlsRecordStream(transport); byte[] authKey = new byte[RealityAuth.AuthKeySize]; + TlsClientHello.Result hello = default; try { // ---- ClientHello, with the REALITY blob sealed into its session id ---- - TlsClientHello.Result hello = TlsClientHello.Build(options.ServerName, options.Alpn); + hello = TlsClientHello.Build(options.ServerName, options.Alpn); RealityAuth.DeriveAuthKey(authKey, hello.PrivateKey, options.PublicKey, hello.Handshake.AsSpan(6, 32)); @@ -109,7 +110,15 @@ public static async ValueTask HandshakeAsync( if (serverHello.Type != TlsHandshakeType.ServerHello) throw new RealityHandshakeException($"Expected a ServerHello, got {serverHello.Type}."); - ServerHello parsed = ParseServerHello(serverHello.Raw); + 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. @@ -232,6 +241,12 @@ public static async ValueTask HandshakeAsync( 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); } } @@ -364,43 +379,61 @@ private static bool TryReadEd25519Certificate(byte[] der, out byte[] publicKey, private readonly record struct ServerHello(TlsCipherSuite Suite, byte[] KeyShare); - private static ServerHello ParseServerHello(byte[] raw) + /// + /// 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); - if (body.Length < 34) - throw new RealityHandshakeException("The ServerHello is truncated."); - - ReadOnlySpan random = body.Slice(2, 32); + 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 offset = 34; - int sessionIdLength = body[offset++]; - offset += sessionIdLength; + int sessionIdLength = Take(ref body, 1, "the session id length")[0]; + ReadOnlySpan sessionIdEcho = Take(ref body, sessionIdLength, "the session id"); - ushort suiteId = BinaryPrimitives.ReadUInt16BigEndian(body[offset..]); - offset += 2; - offset += 1; // legacy_compression_method + // 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(body[offset..]); - offset += 2; - ReadOnlySpan extensions = body.Slice(offset, extensionsLength); + 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.Length >= 4) + while (!extensions.IsEmpty) { - ushort type = BinaryPrimitives.ReadUInt16BigEndian(extensions); - int length = BinaryPrimitives.ReadUInt16BigEndian(extensions[2..]); - ReadOnlySpan data = extensions.Slice(4, length); - extensions = extensions[(4 + length)..]; + 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) { @@ -411,7 +444,7 @@ private static ServerHello ParseServerHello(byte[] raw) case 51 when data.Length >= 4: ushort group = BinaryPrimitives.ReadUInt16BigEndian(data); int shareLength = BinaryPrimitives.ReadUInt16BigEndian(data[2..]); - if (group == 0x001D && shareLength == X25519.KeySize) + if (group == 0x001D && shareLength == X25519.KeySize && data.Length >= 4 + shareLength) keyShare = data.Slice(4, shareLength).ToArray(); break; } @@ -427,25 +460,44 @@ private static ServerHello ParseServerHello(byte[] raw) 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; - if (span.Length < 4) - throw new RealityHandshakeException("The Certificate message is truncated."); + int contextLength = Take(ref span, 1, "the certificate request context length")[0]; + Take(ref span, contextLength, "the certificate request context"); - int contextLength = span[0]; - span = span[(1 + contextLength)..]; + int listLength = ReadUInt24(Take(ref span, 3, "the certificate list length")); + ReadOnlySpan list = Take(ref span, listLength, "the certificate list"); - int listLength = (span[0] << 16) | (span[1] << 8) | span[2]; - span = span.Slice(3, listLength); - - if (span.Length < 3) + if (list.IsEmpty) throw new RealityHandshakeException("The server sent an empty certificate list."); - int certificateLength = (span[0] << 16) | (span[1] << 8) | span[2]; - return span.Slice(3, certificateLength).ToArray(); + int certificateLength = ReadUInt24(Take(ref list, 3, "the leaf certificate length")); + return Take(ref list, certificateLength, "the leaf certificate").ToArray(); } /// One complete handshake message. @@ -465,13 +517,32 @@ private static byte[] ExtractLeafCertificate(ReadOnlyMemory body) /// 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; + private byte[] _buffer = new byte[TlsRecordStream.MaxCiphertext]; private int _length; private int _consumed; + private int _changeCipherSpecSeen; /// 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; + public async ValueTask NextAsync(CancellationToken cancellationToken) { while (true) @@ -484,12 +555,32 @@ public async ValueTask NextAsync(CancellationToken cancellatio 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 (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; @@ -507,6 +598,10 @@ 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) Array.Resize(ref _buffer, Math.Max(_buffer.Length * 2, _length + data.Length)); diff --git a/QuickProxyNet.Reality/Managed/TlsClientHello.cs b/QuickProxyNet.Reality/Managed/TlsClientHello.cs index f498ca5..27f2efb 100644 --- a/QuickProxyNet.Reality/Managed/TlsClientHello.cs +++ b/QuickProxyNet.Reality/Managed/TlsClientHello.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -76,7 +77,13 @@ public static Result Build(string serverName, IReadOnlyList? alpn = null int cipherSuites = writer.BeginVector16(); writer.WriteUInt16(0x1301); // TLS_AES_128_GCM_SHA256 writer.WriteUInt16(0x1302); // TLS_AES_256_GCM_SHA384 - writer.WriteUInt16(0x1303); // TLS_CHACHA20_POLY1305_SHA256 + + // 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(); @@ -109,13 +116,43 @@ private static void WriteServerName(TlsWriter writer, string serverName) int list = writer.BeginVector16(); writer.WriteByte(0); // host_name int name = writer.BeginVector16(); - writer.Write(Encoding.ASCII.GetBytes(serverName)); + 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); diff --git a/QuickProxyNet.Reality/Managed/X25519.cs b/QuickProxyNet.Reality/Managed/X25519.cs index 46b873d..dc572d6 100644 --- a/QuickProxyNet.Reality/Managed/X25519.cs +++ b/QuickProxyNet.Reality/Managed/X25519.cs @@ -279,7 +279,14 @@ private static void Mul(Span result, Span left, Span right) result[4] = r4; } - private static void MulSmall(Span result, Span value, ulong scalar) + /// 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 = (UInt128)value[0] * scalar; UInt128 h1 = (UInt128)value[1] * scalar; @@ -303,6 +310,10 @@ private static void MulSmall(Span result, Span value, ulong scalar 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; diff --git a/QuickProxyNet.Tests/HostilePeerTest.cs b/QuickProxyNet.Tests/HostilePeerTest.cs new file mode 100644 index 0000000..dff42c1 --- /dev/null +++ b/QuickProxyNet.Tests/HostilePeerTest.cs @@ -0,0 +1,367 @@ +using QuickProxyNet.Reality.Managed; + +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)); + + return await Assert.ThrowsAsync( + async () => await RealityTlsClient.HandshakeAsync(peer, Options(), timeout.Token)); + } + + /// + /// 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 must not be reported as anything else. + [Fact] + public async Task PeerThatSaysNothing_Fails() + { + await using var peer = new ScriptedPeer(_ => []); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + await Assert.ThrowsAnyAsync( + async () => await RealityTlsClient.HandshakeAsync(peer, Options(), timeout.Token)); + } + + /// + /// 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/X25519Test.cs b/QuickProxyNet.Tests/X25519Test.cs index 746c543..d284f5d 100644 --- a/QuickProxyNet.Tests/X25519Test.cs +++ b/QuickProxyNet.Tests/X25519Test.cs @@ -127,6 +127,81 @@ public void LowOrderPoint_IsRejected(string peerPublicKey) 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. + /// + [Fact] + public void Multiply_MatchesArbitraryPrecision() + { + 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) + ]); + } + + 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)); + } + } + [Fact] public void Clamp_MatchesRfc7748() { From 4e4cd0f29c7dddde8954177afb6b6c79a5f04212 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 15:59:09 +0500 Subject: [PATCH 14/25] perf(reality): benchmark the managed REALITY / TLS 1.3 client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three BenchmarkDotNet suites over QuickProxyNet.Reality.Managed and wires the benchmark project up to reach it (InternalsVisibleTo, matching what the core package already does, plus a ProjectReference). - RealityHandshakeBenchmark: the once-per-connection work — X25519 Agree / GetPublicKey, RealityAuth.DeriveAuthKey / SealSessionId, the TLS 1.3 key schedule, and TlsClientHello.Build. On net11.0 it also measures the BCL's new X25519DiffieHellman side by side, which is the number that decides whether the hand-written curve should still be used there. - RealityRecordBenchmark: the hot path — TlsRecordProtection.Protect at 64 B / 1 KiB / 8 KiB / 16 KiB for AES-128-GCM and, where the platform has it, ChaCha20-Poly1305, with a MB/s column. Unprotect is measured by subtraction from a matched-pair RoundTrip, because a record nonce cannot be rewound and a fixed ciphertext therefore decrypts exactly once. - RealityTlsStreamBenchmark: RealityTlsStream over an in-memory transport, 1 MiB per operation so the MemoryDiagnoser column reads directly as bytes allocated per MiB transferred. The benchmark project now multi-targets net10.0;net11.0 so the platform X25519 comparison can actually run. 0 warnings on both. Measured (ShortRun, 3 warmup / 5 iterations, indicative only; Xeon E5-2697 v4, .NET 11.0.0-preview.5): Managed X25519 Agree 340 us vs platform 133 us (2.6x) DeriveAuthKey 350 us (dominated by the curve) SealSessionId 1.31 us ExpandLabel / TrafficKeys 1.74 us / 3.33 us BuildClientHello 354 us (one fixed-base scalar mult), 1064 B Protect 16 KiB AES-128 8.1 us 2022 MB/s, 0 B Protect 16 KiB ChaCha 59.1 us 277 MB/s, 0 B Stream write 1 MiB 803 us 2.00 MB allocated Stream round trip 1 MiB 1668 us 3.01 MB allocated Co-Authored-By: Claude Opus 5 (1M context) --- .../QuickProxyNet.Benchmarks.csproj | 3 +- .../RealityHandshakeBenchmark.cs | 188 ++++++++++++++++++ .../RealityRecordBenchmark.cs | 171 ++++++++++++++++ .../RealityTlsStreamBenchmark.cs | 136 +++++++++++++ .../QuickProxyNet.Reality.csproj | 1 + 5 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs create mode 100644 QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs create mode 100644 QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs diff --git a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj index 56c57e0..2b4dfca 100644 --- a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj +++ b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj @@ -1,6 +1,6 @@ - net10.0 + net10.0;net11.0 Exe @@ -17,5 +17,6 @@ + diff --git a/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs new file mode 100644 index 0000000..e18a88c --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs @@ -0,0 +1,188 @@ +using System; +using System.Security.Cryptography; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using QuickProxyNet.Reality.Managed; + +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..2185c97 --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs @@ -0,0 +1,171 @@ +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; +using QuickProxyNet.Reality.Managed; + +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/RealityTlsStreamBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs new file mode 100644 index 0000000..f00a3fe --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs @@ -0,0 +1,136 @@ +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; +using QuickProxyNet.Reality.Managed; + +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.Reality/QuickProxyNet.Reality.csproj b/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj index 548595a..a013c32 100644 --- a/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj +++ b/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj @@ -40,5 +40,6 @@ + From b300e1e9be61e09d8986a1538eb2e17915dcb662 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 16:03:24 +0500 Subject: [PATCH 15/25] perf(reality): stop allocating two buffers per TLS record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured, not guessed. A 1 MiB transfer through RealityTlsStream allocated 2.00 MB writing and 1.00 MB reading — about three times the payload, all of it copied out and dropped immediately. At 100 Mbit/s that is roughly 37 MB/s of gen0 garbage. The write path built a fresh record buffer and a fresh scratch buffer for every record; both are now pooled per connection, along with the two receive buffers. The read path copied each decrypted record into a new array although TlsRecordStream already promises its payload stays valid until the next read and the stream only reads again once the previous one is drained — the lifetime already lined up, so the copy bought nothing. Benchmarks on this machine put the framing overhead above the raw AEAD at 35% on write and 42% on read, against AES-128-GCM running at ~2 GB/s. The allocations were the bulk of it. Two things the pooling makes load-bearing, both handled here: - TlsRecordStream.Dispose is reached twice on the failure path, once from the handshake's catch and once from the stream wrapping it. Harmless while the buffers were plain arrays; a double Return would hand one connection's buffer to another. Guarded, and the buffers are returned cleared since they held decrypted application data. - RealityTlsStream._pending now aliases the record layer's buffer, so it is dropped in Dispose before those buffers go back to the pool. WriteAsync now refuses a payload over one record's worth rather than truncating its own length field. The only caller already chunks, so it never fires — but a fixed buffer turns "never in practice" into something that needs enforcing. Also: the padding strip is MemoryExtensions.LastIndexOfAnyExcept, which is vectorised in the BCL and no longer O(padding) against a peer that pads; VerifyServerFinished no longer copies verify_data that nothing read; and the ClientHello writer starts at a capacity that fits a hello. Deliberately NOT done, on the evidence: - SIMD in X25519. .NET exposes no AVX-512-IFMA, so radix-2^51 has no widening 64x64 multiply to vectorise, and the fallback needs hand-scheduled radix-2^25.5 assembly to break even. It also runs twice per connection: ~0.7 ms against a network round trip of tens of milliseconds. Zero steady-state effect. - Coalescing small writes into fuller records. Record sizes and their timing are precisely what a passive observer sees of a REALITY connection, so that is a fingerprinting decision, not a performance one. Co-Authored-By: Claude Opus 5 (1M context) --- .../Managed/RealityTlsClient.cs | 11 +- .../Managed/RealityTlsStream.cs | 33 ++++-- .../Managed/TlsClientHello.cs | 4 +- .../Managed/TlsRecordLayer.cs | 103 ++++++++++++------ 4 files changed, 102 insertions(+), 49 deletions(-) diff --git a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs index 5850438..aec8f3e 100644 --- a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs +++ b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs @@ -145,9 +145,9 @@ public static async ValueTask HandshakeAsync( // ---- Server flight ---- byte[]? leafCertificate = null; - byte[]? serverVerifyData = null; + bool serverFinished = false; - while (serverVerifyData is null) + while (!serverFinished) { HandshakeMessage message = await messages.NextAsync(cancellationToken).ConfigureAwait(false); @@ -165,8 +165,9 @@ public static async ValueTask HandshakeAsync( case TlsHandshakeType.Finished: // Verified against the transcript as it stood *before* this message. - serverVerifyData = VerifyServerFinished( + VerifyServerFinished( parsed.Suite, serverHandshakeTraffic, transcript.GetCurrentHash(), message.Body.Span); + serverFinished = true; transcript.AppendData(message.Raw); break; @@ -289,7 +290,7 @@ private static void HashEmpty(TlsCipherSuite suite, Span output) SHA256.HashData(ReadOnlySpan.Empty, output); } - private static byte[] VerifyServerFinished( + private static void VerifyServerFinished( TlsCipherSuite suite, ReadOnlySpan serverTraffic, ReadOnlySpan transcriptHash, ReadOnlySpan body) { Span expected = stackalloc byte[suite.HashLength]; @@ -299,8 +300,6 @@ private static byte[] VerifyServerFinished( throw new RealityHandshakeException( "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."); - - return body.ToArray(); } private static byte[] BuildFinished( diff --git a/QuickProxyNet.Reality/Managed/RealityTlsStream.cs b/QuickProxyNet.Reality/Managed/RealityTlsStream.cs index 483950d..77238e2 100644 --- a/QuickProxyNet.Reality/Managed/RealityTlsStream.cs +++ b/QuickProxyNet.Reality/Managed/RealityTlsStream.cs @@ -20,8 +20,16 @@ internal sealed class RealityTlsStream : Stream private readonly Stream _transport; private readonly TlsRecordStream _records; - private byte[] _pending; - private int _pendingOffset; + /// + /// 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; @@ -29,7 +37,10 @@ internal RealityTlsStream(Stream transport, TlsRecordStream records, List { _transport = transport; _records = records; - _pending = leftover.Count > 0 ? leftover.ToArray() : []; + + // 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; @@ -50,7 +61,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (buffer.IsEmpty) return 0; - while (_pendingOffset >= _pending.Length) + while (_pending.IsEmpty) { if (_receivedCloseNotify) return 0; @@ -59,9 +70,9 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation return 0; } - int count = Math.Min(buffer.Length, _pending.Length - _pendingOffset); - _pending.AsSpan(_pendingOffset, count).CopyTo(buffer.Span); - _pendingOffset += count; + int count = Math.Min(buffer.Length, _pending.Length); + _pending.Span[..count].CopyTo(buffer.Span); + _pending = _pending[count..]; return count; } @@ -87,8 +98,7 @@ record = await _records.ReadAsync(cancellationToken).ConfigureAwait(false); switch (record.Type) { case TlsContentType.ApplicationData when !record.Payload.IsEmpty: - _pending = record.Payload.ToArray(); - _pendingOffset = 0; + _pending = record.Payload; return true; case TlsContentType.ApplicationData: @@ -177,6 +187,10 @@ protected override void Dispose(bool disposing) _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) { _records.Dispose(); @@ -192,6 +206,7 @@ public override async ValueTask DisposeAsync() return; _disposed = true; + _pending = ReadOnlyMemory.Empty; _records.Dispose(); await _transport.DisposeAsync().ConfigureAwait(false); diff --git a/QuickProxyNet.Reality/Managed/TlsClientHello.cs b/QuickProxyNet.Reality/Managed/TlsClientHello.cs index 27f2efb..23e4c37 100644 --- a/QuickProxyNet.Reality/Managed/TlsClientHello.cs +++ b/QuickProxyNet.Reality/Managed/TlsClientHello.cs @@ -58,7 +58,9 @@ public static Result Build(string serverName, IReadOnlyList? alpn = null byte[] publicKey = new byte[X25519.KeySize]; X25519.GenerateKeyPair(privateKey, publicKey); - var writer = new TlsWriter(); + // 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(); diff --git a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs index 392a799..863f82b 100644 --- a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs +++ b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Security.Cryptography; namespace QuickProxyNet.Reality.Managed; @@ -162,8 +163,17 @@ internal sealed class TlsRecordStream(Stream transport) : IDisposable public const int MaxCiphertext = MaxPlaintext + 256; private readonly byte[] _header = new byte[5]; - private readonly byte[] _body = new byte[MaxCiphertext]; - private readonly byte[] _plaintext = new byte[MaxCiphertext]; + private readonly byte[] _body = ArrayPool.Shared.Rent(MaxCiphertext); + private readonly byte[] _plaintext = ArrayPool.Shared.Rent(MaxCiphertext); + + /// The record being written: header, ciphertext and tag, contiguous for one write. + private readonly byte[] _outbound = + ArrayPool.Shared.Rent(5 + MaxPlaintext + 1 + TlsCipherSuite.TagLength); + + /// The inner plaintext being staged: the payload plus its content-type byte. + private readonly byte[] _outboundPlain = ArrayPool.Shared.Rent(MaxPlaintext + 1); + + private bool _disposed; /// Protection for outgoing records, or null while still in the clear. public TlsRecordProtection? Write { get; set; } @@ -207,9 +217,10 @@ public async ValueTask ReadAsync(CancellationToken cancellationToken) _header); // The real content type is the last non-zero byte: TLS 1.3 hides it behind zero padding. - int end = contentLength; - while (end > 0 && _plaintext[end - 1] == 0) - end--; + // 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 InvalidOperationException("The peer sent a record with no content type."); @@ -221,20 +232,33 @@ public async ValueTask ReadAsync(CancellationToken cancellationToken) /// The content type. /// The content. /// Cancels the write. + /// + /// Not reentrant: one record is staged in shared buffers, and the protection's sequence + /// number advances per call. 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 async ValueTask WriteAsync( TlsContentType type, ReadOnlyMemory payload, CancellationToken cancellationToken) { + // The staging buffers hold exactly one record. 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."); + if (Write is null) { - byte[] plain = new byte[5 + payload.Length]; + Span plain = _outbound.AsSpan(0, 5 + payload.Length); plain[0] = (byte)type; plain[1] = 3; plain[2] = payload.Length > 0 && type == TlsContentType.Handshake ? (byte)1 : (byte)3; plain[3] = (byte)(payload.Length >> 8); plain[4] = (byte)payload.Length; - payload.Span.CopyTo(plain.AsSpan(5)); + payload.Span.CopyTo(plain[5..]); - await transport.WriteAsync(plain, cancellationToken).ConfigureAwait(false); + await transport.WriteAsync(_outbound.AsMemory(0, 5 + payload.Length), cancellationToken) + .ConfigureAwait(false); await transport.FlushAsync(cancellationToken).ConfigureAwait(false); return; } @@ -242,37 +266,50 @@ public async ValueTask WriteAsync( // An encrypted record always announces itself as application_data; the real type rides // inside, after the content. int inner = payload.Length + 1; - byte[] record = new byte[5 + inner + TlsCipherSuite.TagLength]; - record[0] = (byte)TlsContentType.ApplicationData; - record[1] = 3; - record[2] = 3; - record[3] = (byte)((inner + TlsCipherSuite.TagLength) >> 8); - record[4] = (byte)(inner + TlsCipherSuite.TagLength); - - byte[] scratch = new byte[inner]; - try - { - payload.Span.CopyTo(scratch); - scratch[payload.Length] = (byte)type; - - Write.Protect( - scratch, - record.AsSpan(5, inner), - record.AsSpan(5 + inner, TlsCipherSuite.TagLength), - record.AsSpan(0, 5)); - } - finally - { - CryptographicOperations.ZeroMemory(scratch); - } - - await transport.WriteAsync(record, cancellationToken).ConfigureAwait(false); + _outbound[0] = (byte)TlsContentType.ApplicationData; + _outbound[1] = 3; + _outbound[2] = 3; + _outbound[3] = (byte)((inner + TlsCipherSuite.TagLength) >> 8); + _outbound[4] = (byte)(inner + TlsCipherSuite.TagLength); + + // Staged in a second buffer rather than encrypted in place: .NET does not document + // whether an AEAD may overlap its input and output, and the record layer is the wrong + // place to discover the answer. + payload.Span.CopyTo(_outboundPlain); + _outboundPlain[payload.Length] = (byte)type; + + Write.Protect( + _outboundPlain.AsSpan(0, inner), + _outbound.AsSpan(5, inner), + _outbound.AsSpan(5 + inner, TlsCipherSuite.TagLength), + _outbound.AsSpan(0, 5)); + + await transport + .WriteAsync(_outbound.AsMemory(0, 5 + inner + TlsCipherSuite.TagLength), cancellationToken) + .ConfigureAwait(false); await transport.FlushAsync(cancellationToken).ConfigureAwait(false); } + /// 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. + ArrayPool.Shared.Return(_plaintext, clearArray: true); + ArrayPool.Shared.Return(_outboundPlain, clearArray: true); + ArrayPool.Shared.Return(_body, clearArray: true); + ArrayPool.Shared.Return(_outbound, clearArray: true); } } From 41bd3f9fe5a397b6b18a3d791ffcfeacd24e2114 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 16:07:58 +0500 Subject: [PATCH 16/25] fix(reality): drop ed25519 from signature_algorithms, and match Chrome's list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment this removes claimed ed25519 was load-bearing, because a REALITY server answers with an Ed25519 certificate. That reasoning was wrong: the server generates that certificate only after it has authenticated the client, and never consults signature_algorithms for it. Removing 0x0807 leaves the real-server handshake and tunnel tests green. Worth having been wrong about, because Chrome does not send ed25519 either — so the claim was also costing fingerprint fidelity. The list is now Chrome's exact eight in Chrome's order, which matters beyond taste: JA4 appends the signature algorithms unsorted, so a reordering changes the hash even though TLS itself does not care. Adds docs/reality-fingerprint-plan.md with the byte-level detail needed to finish the job: Chrome 133's cipher list and 18 extension slots with exact bodies, the GREASE rules (six draws, the two extension slots must differ, and the group value is shared between supported_groups and key_share), the GREASE ECH construction, and X25519MLKEM768's wire format. Three findings in there change how the remaining work should be approached: - JA3 is not a usable acceptance criterion. BoringSSL permutes every extension except the two GREASE slots, once per handshake, so Chrome's JA3 changes almost every connection. JA4 sorts and strips GREASE; that is the metric. - .NET 10's MLKem is OS-gated. A client whose fingerprint depends on whether the host has the PQC CNG updates is worse than one that is consistently wrong, because the fingerprint then leaks the host OS. One managed ML-KEM for every target framework is the honest answer. - REALITY prefers a standalone X25519 key share and only falls back to the X25519 tail of a hybrid entry. Chrome sends both, so DeriveAuthKey keeps using the standalone share and the two keypairs must stay independent — reusing one would be a trivial byte-equality check for an observer. Co-Authored-By: Claude Opus 5 (1M context) --- .../Managed/TlsClientHello.cs | 18 +- docs/reality-fingerprint-plan.md | 204 ++++++++++++++++++ 2 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 docs/reality-fingerprint-plan.md diff --git a/QuickProxyNet.Reality/Managed/TlsClientHello.cs b/QuickProxyNet.Reality/Managed/TlsClientHello.cs index 23e4c37..75835a5 100644 --- a/QuickProxyNet.Reality/Managed/TlsClientHello.cs +++ b/QuickProxyNet.Reality/Managed/TlsClientHello.cs @@ -173,16 +173,22 @@ private static void WriteSignatureAlgorithms(TlsWriter writer) int extension = writer.BeginVector16(); int algorithms = writer.BeginVector16(); - // ed25519 is not optional here: the certificate a REALITY server returns once it has - // authenticated the client is Ed25519, so omitting it would make the server unable to - // answer us at all. - writer.WriteUInt16(0x0807); // ed25519 + // 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(0x0805); // rsa_pss_rsae_sha384 - writer.WriteUInt16(0x0806); // rsa_pss_rsae_sha512 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); diff --git a/docs/reality-fingerprint-plan.md b/docs/reality-fingerprint-plan.md new file mode 100644 index 0000000..b4c783b --- /dev/null +++ b/docs/reality-fingerprint-plan.md @@ -0,0 +1,204 @@ +# Making the managed REALITY ClientHello look like Chrome + +`QuickProxyNet.Reality/Managed/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 From 815031f8f840a46aa4cc8d63508f5dcd05c1d98d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 16:20:24 +0500 Subject: [PATCH 17/25] perf(reality): pool the per-connection buffers, and clear secrets in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing left on the per-record path allocated, so this is the per-connection tier. Two of the four changes are about correctness as much as garbage. The seven handshake secrets — the ECDHE output, four schedule secrets and two application traffic secrets — were seven arrays with seven ZeroMemory calls. Individually 32 to 48 bytes, once per connection, and not worth chasing for their own sake. What they were worth changing for is the shape: adding an eighth secret and forgetting to extend the clearing is a silent failure with no test that can catch it. They now live in one pooled buffer behind named slices, returned with clearArray: true, so one line covers all of them. The AEAD key in TlsRecordProtection moves from the heap to a fixed stack frame. It is a record-protection key and at most 32 bytes; on the stack there is no heap copy for a collection to move and no window before it is cleared. The handshake reassembly buffer (16 KiB, held for the whole handshake) is now rented. It grows by renting a larger buffer and returning the old one rather than by Array.Resize — a resized array does not come from the pool, and returning it would put a foreign buffer into the shared pool, which is the kind of bug that surfaces somewhere else entirely. Also folded the one-byte ChangeCipherSpec payload into a static, and the short id onto the stack. Left alone on purpose: the ClientHello's key pair and the reassembled handshake messages both escape their scope, and the copy in the message reader pays for correctness — Compact moves bytes underneath the buffer, so the message must own its storage. Trading that for one allocation per message would buy a class of transcript bug that presents as "Finished does not verify against some servers". Co-Authored-By: Claude Opus 5 (1M context) --- .../Managed/RealityTlsClient.cs | 132 ++++++++++++------ .../Managed/TlsRecordLayer.cs | 7 +- 2 files changed, 97 insertions(+), 42 deletions(-) diff --git a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs index aec8f3e..bf4cb60 100644 --- a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs +++ b/QuickProxyNet.Reality/Managed/RealityTlsClient.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Buffers.Binary; using System.Formats.Asn1; using System.Security.Cryptography; @@ -62,6 +63,41 @@ internal sealed class RealityTlsClient private const string Ed25519Oid = "1.3.101.112"; + /// 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. /// @@ -84,6 +120,7 @@ public static async ValueTask HandshakeAsync( var records = new TlsRecordStream(transport); byte[] authKey = new byte[RealityAuth.AuthKeySize]; TlsClientHello.Result hello = default; + HandshakeReader? messages = null; try { @@ -92,7 +129,7 @@ public static async ValueTask HandshakeAsync( RealityAuth.DeriveAuthKey(authKey, hello.PrivateKey, options.PublicKey, hello.Handshake.AsSpan(6, 32)); - byte[] shortId = new byte[RealityAuth.ShortIdSize]; + Span shortId = stackalloc byte[RealityAuth.ShortIdSize]; RealityAuth.ParseShortId(shortId, options.ShortId); RealityAuth.SealSessionId( @@ -105,7 +142,7 @@ public static async ValueTask HandshakeAsync( await records.WriteAsync(TlsContentType.Handshake, hello.Handshake, cancellationToken).ConfigureAwait(false); // ---- ServerHello ---- - var messages = new HandshakeReader(records); + 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}."); @@ -127,21 +164,18 @@ public static async ValueTask HandshakeAsync( transcript.AppendData(serverHello.Raw); // ---- Key schedule ---- - byte[] shared = new byte[X25519.KeySize]; - byte[] handshakeSecret = new byte[parsed.Suite.HashLength]; - byte[] clientHandshakeTraffic = new byte[parsed.Suite.HashLength]; - byte[] serverHandshakeTraffic = new byte[parsed.Suite.HashLength]; - byte[] masterSecret = new byte[parsed.Suite.HashLength]; + HandshakeSecrets secrets = HandshakeSecrets.Rent(parsed.Suite.HashLength); try { - X25519.Agree(shared, hello.PrivateKey, parsed.KeyShare); + X25519.Agree(secrets.Shared, hello.PrivateKey, parsed.KeyShare); DeriveHandshakeSecrets( - parsed.Suite, shared, transcript.GetCurrentHash(), - handshakeSecret, clientHandshakeTraffic, serverHandshakeTraffic, masterSecret); + parsed.Suite, secrets.Shared, transcript.GetCurrentHash(), + secrets.HandshakeSecret, secrets.ClientHandshakeTraffic, + secrets.ServerHandshakeTraffic, secrets.MasterSecret); - records.Read = new TlsRecordProtection(parsed.Suite, serverHandshakeTraffic); + records.Read = new TlsRecordProtection(parsed.Suite, secrets.ServerHandshakeTraffic); // ---- Server flight ---- byte[]? leafCertificate = null; @@ -166,7 +200,8 @@ public static async ValueTask HandshakeAsync( case TlsHandshakeType.Finished: // Verified against the transcript as it stood *before* this message. VerifyServerFinished( - parsed.Suite, serverHandshakeTraffic, transcript.GetCurrentHash(), message.Body.Span); + parsed.Suite, secrets.ServerHandshakeTraffic, + transcript.GetCurrentHash(), message.Body.Span); serverFinished = true; transcript.AppendData(message.Raw); break; @@ -194,44 +229,33 @@ public static async ValueTask HandshakeAsync( // 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, new byte[] { 1 }, cancellationToken) + await records.WriteAsync(TlsContentType.ChangeCipherSpec, ChangeCipherSpecPayload, cancellationToken) .ConfigureAwait(false); - records.Write = new TlsRecordProtection(parsed.Suite, clientHandshakeTraffic); + records.Write = new TlsRecordProtection(parsed.Suite, secrets.ClientHandshakeTraffic); - byte[] finished = BuildFinished(parsed.Suite, clientHandshakeTraffic, transcriptAfterServerFinished); + byte[] finished = BuildFinished( + parsed.Suite, secrets.ClientHandshakeTraffic, transcriptAfterServerFinished); await records.WriteAsync(TlsContentType.Handshake, finished, cancellationToken).ConfigureAwait(false); // ---- Application keys ---- - byte[] clientApplication = new byte[parsed.Suite.HashLength]; - byte[] serverApplication = new byte[parsed.Suite.HashLength]; - try - { - TlsKeySchedule.DeriveSecret( - parsed.Suite.Hash, masterSecret, "c ap traffic"u8, transcriptAfterServerFinished, clientApplication); - TlsKeySchedule.DeriveSecret( - parsed.Suite.Hash, masterSecret, "s ap traffic"u8, transcriptAfterServerFinished, serverApplication); - - records.Write?.Dispose(); - records.Read?.Dispose(); - records.Write = new TlsRecordProtection(parsed.Suite, clientApplication); - records.Read = new TlsRecordProtection(parsed.Suite, serverApplication); - } - finally - { - CryptographicOperations.ZeroMemory(clientApplication); - CryptographicOperations.ZeroMemory(serverApplication); - } + 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 { - CryptographicOperations.ZeroMemory(shared); - CryptographicOperations.ZeroMemory(handshakeSecret); - CryptographicOperations.ZeroMemory(clientHandshakeTraffic); - CryptographicOperations.ZeroMemory(serverHandshakeTraffic); - CryptographicOperations.ZeroMemory(masterSecret); + secrets.Return(); } } catch @@ -248,6 +272,9 @@ public static async ValueTask HandshakeAsync( // 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(); } } @@ -531,7 +558,7 @@ private sealed class HandshakeReader(TlsRecordStream records) /// private const int MaxChangeCipherSpec = 8; - private byte[] _buffer = new byte[TlsRecordStream.MaxCiphertext]; + private byte[] _buffer = ArrayPool.Shared.Rent(TlsRecordStream.MaxCiphertext); private int _length; private int _consumed; private int _changeCipherSpecSeen; @@ -542,6 +569,22 @@ private sealed class HandshakeReader(TlsRecordStream records) /// 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) @@ -602,7 +645,14 @@ private void Append(ReadOnlySpan data) $"The peer's handshake message exceeded {MaxHandshakeMessage} bytes."); if (_length + data.Length > _buffer.Length) - Array.Resize(ref _buffer, Math.Max(_buffer.Length * 2, _length + data.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; diff --git a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs index 863f82b..2157066 100644 --- a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs +++ b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs @@ -89,9 +89,14 @@ internal sealed class TlsRecordProtection : IDisposable public TlsRecordProtection(TlsCipherSuite suite, ReadOnlySpan trafficSecret) { - byte[] key = new byte[suite.KeyLength]; _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); From cf0f6e49c171fa253fc9ae17f7220ed8f28c5a9d Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 16:23:22 +0500 Subject: [PATCH 18/25] docs: bring the guides up to date with REALITY and net11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation had drifted in ways that would mislead: both READMEs still said .NET 8/9/10 and listed only the five classic proxies, so a reader would not learn that VLESS, VMess and Trojan exist at all, let alone REALITY. The NuGet description said the same. AGENTS.md gains a section on QuickProxyNet.Reality: why it cannot live in the core, what each file does, and how the two implementations answer different questions. It also records the open question rather than hiding it — the whole managed stack is internal, so the package's headline capability is unreachable by anyone consuming it, and choosing its public shape is unfinished work. docs/implementation-plan.md gains §8. The old §7 concluded that REALITY was "a separate project, not a feature" and that NotSupportedException was the only honest behaviour. The first half was right, literally — it became a separate package. The second half was half right, and that is the half worth recording: the core still cannot speak REALITY, but it does not follow that refusing is the only option. Two stale claims corrected rather than left to rot: §6 described phase-1 limits that no longer hold (ws and httpupgrade have worked since phase 4), and the docs index implied documents exist for protocols that are implemented when several describe protocols that are not. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 70 +++++++++++++++++++++++++++++- QuickProxyNet/QuickProxyNet.csproj | 4 +- QuickProxyNet/README.md | 6 ++- README.md | 7 ++- docs/README.md | 25 +++++++---- docs/implementation-plan.md | 70 +++++++++++++++++++++++++++--- 6 files changed, 162 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 868166c..23ac272 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,11 @@ 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). -- NuGet package: `QuickProxyNet` +A second package, `QuickProxyNet.Reality`, adds VLESS REALITY. It is separate on +purpose: the core keeps its zero-dependency promise, and opting into REALITY is an +explicit choice. See "The REALITY Package" below. + +- NuGet packages: `QuickProxyNet`, `QuickProxyNet.Reality` - Author: Titlehhhh - License: MIT - Core targets: `net8.0`, `net9.0`, `net10.0`, `net11.0` @@ -15,6 +19,7 @@ HTTPS, SOCKS4, SOCKS4a, SOCKS5) and the VPN-style family (VLESS, Trojan, VMess). ```text QuickProxyNet/ Core library and protocol logic +QuickProxyNet.Reality/ VLESS REALITY: a managed client, plus an Xray-driven one QuickProxyNet.Tests/ xUnit tests QuickProxyNet.Benchmarks/ BenchmarkDotNet benchmarks Sample/ Console usage example @@ -43,6 +48,11 @@ All public library types live in the `QuickProxyNet` namespace. - `VlessOptions` / `TrojanOptions` / `VmessOptions` plus the matching `*ShareLink.Parse` / `TryParse` describe a VPN-style endpoint. +In `QuickProxyNet.Reality`: `RealityProxy` (an `IAsyncDisposable` owning one Xray +process and one loopback port), `RealityProxyOptions`, and `RealityHandshakeException`. +The managed stack under `Managed/` is still `internal` — see the open question at the +end of this file. + ## Current Protocol Implementations | Class | Protocol | @@ -55,6 +65,7 @@ All public library types live in the `QuickProxyNet` namespace. | `VlessClient` | VLESS, `security=none` or `tls` | | `TrojanClient` | Trojan over TLS | | `VmessClient` | VMess (VMessAEAD, `alterId=0`), optional TLS | +| `RealityProxy` | VLESS REALITY and XTLS Vision, via a local Xray process (separate package) | 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 @@ -87,6 +98,63 @@ Internal/Vmess/VmessResponseStream.cs lazy response-header reader Internal/Vmess/VmessStream.cs AEAD chunk framing ``` +## The REALITY Package + +`QuickProxyNet.Reality` exists because REALITY cannot be done in the core library: +it 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. There are two +implementations, and they answer different questions. + +```text +RealityProxy.cs Drives a local Xray process with a loopback SOCKS5 inbound +RealityProxyOptions.cs Where to find the binary, what to bind, how much to log +XrayClientConfig.cs VlessOptions -> Xray JSON, handed over on stdin +XrayExecutable.cs Explicit path -> QPN_XRAY_PATH -> PATH + +Managed/X25519.cs RFC 7748, because net8-net10 have no X25519 anywhere +Managed/RealityAuth.cs authKey derivation, session_id sealing, the certificate HMAC +Managed/TlsKeySchedule.cs RFC 8446 §7.1 and §7.3 +Managed/TlsRecordLayer.cs Suites, record protection, record read/write +Managed/TlsWriter.cs TLS's length-prefixed vectors, with backpatching +Managed/TlsClientHello.cs The hello — NOT yet a browser fingerprint, see below +Managed/RealityTlsClient.cs The handshake state machine +Managed/RealityTlsStream.cs Application data over the record layer +``` + +The Xray path ships nothing: the binary is the caller's, supplied through +`RealityProxyOptions.ExecutablePath`, `QPN_XRAY_PATH`, or `PATH`. Its configuration +goes to Xray on **stdin** (`run -c stdin:`) so the VLESS id never reaches disk. + +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, the +managed path 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. + +**Open question, deliberately left open:** everything under `Managed/` is `internal`. +The headline capability — REALITY with no external binary — is therefore unreachable +by anyone consuming the package. Deciding its public shape (a `RealityClient : +IProxyClient`? folded into `VlessClient`? a third package?) is unfinished work, not an +oversight. + ## Hard-Won Protocol Knowledge Every item below cost a separate investigation. Do not re-derive them, and do diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index 39dcb2d..d74854b 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -11,8 +11,8 @@ 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 + 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 is available in the QuickProxyNet.Reality package. + proxy;networking;http;socks;vless;vmess;trojan;high-performance Copyright © Titlehhhh 2024 https://github.com/Titlehhhh/QuickProxyNet https://github.com/Titlehhhh/QuickProxyNet diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index 0fcf12c..edc6e45 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -1,8 +1,10 @@ # 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 lives in the separate `QuickProxyNet.Reality` package, so the core keeps its zero-dependency promise. + +**Targets:** .NET 8 / .NET 9 / .NET 10 / .NET 11 ## Quick Start diff --git a/README.md b/README.md index d105702..27df30f 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,16 @@ **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 +- **5 classic proxy protocols** — HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 +- **3 VPN-style protocols** — VLESS, VMess (VMessAEAD), Trojan, over `tcp`, `ws` or `httpupgrade` +- **Share-link parsing** — `vless://`, `vmess://`, `trojan://`, validated against a 21 403-link real-world corpus +- **VLESS REALITY** — in the separate `QuickProxyNet.Reality` package - **Static one-liner API** — `Proxy.ConnectAsync(uri, host, port)` for mass checkers - **Structured error codes** — `ProxyProtocolException` with `ProxyErrorCode` enum for programmatic error handling - **Timeout support** — per-connection timeouts with `ProxyErrorCode.Timeout` diff --git a/docs/README.md b/docs/README.md index 9969cfa..8ad0c81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,12 +6,21 @@ Файлы: -- [VLESS](vless.md) -- [VMess](vmess.md) -- [Trojan](trojan.md) -- [Hysteria2](hysteria2.md) -- [hy2](hy2.md) -- [TUIC](tuic.md) +Wire-level заметки по протоколам: -Документы написаны как 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.Reality` + +Наличие документа не означает, что протокол поддержан: там, где поддержки нет, +это сказано явно. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 7b32751..9e5b81d 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -241,9 +241,8 @@ grpc, которые падают уже на `ConnectAsync`. **Вывод: QUIC — худшая из оставшихся инвестиций.** Самая тяжёлая архитектурная работа в роадмапе ради 2.2% охвата. Он стоял «фазой 4» только потому, что шёл следующим по документу — это не обоснование. Следующий по отношению -охват/стоимость — gRPC. REALITY — половина корпуса, но это отдельный проект, а не -фича: пока нет способа подделать uTLS-отпечаток, честный `NotSupportedException` -остаётся единственным правильным поведением. +охват/стоимость — gRPC. REALITY — половина корпуса, и на момент этого замера +вывод был: отдельный проект, а не фича. Он и оказался отдельным проектом — см. §8. Метод, а не только результат: считать надо то, что может провалиться. «Процент распарсенного» рос до 99.9% ровно тогда, когда 96% ссылок не могли подключиться — @@ -251,7 +250,68 @@ grpc, которые падают уже на `ConnectAsync`. ## 6. Границы фазы 1 (honest scope) -Поддерживается: `vless://` с `security=none` и `security=tls`, транспорт +Историческая справка: границы, с которых начиналась фаза 1. + +Поддерживалось: `vless://` с `security=none` и `security=tls`, транспорт `tcp`/`raw`, команда TCP, адреса IPv4/IPv6/domain, `sni`/`alpn`. -Явно НЕ поддерживается (кидаем `NotSupportedException`): `reality`, непустой +Явно НЕ поддерживалось (кидали `NotSupportedException`): `reality`, непустой `flow`, транспорты `ws`/`grpc`/`xhttp`/`httpupgrade`, команды UDP/Mux. + +С тех пор закрыты `ws` и `httpupgrade` (фаза 4), а `reality` и `flow` — в +отдельном пакете `QuickProxyNet.Reality` (§8). Остаются `grpc`, `xhttp` и +UDP/Mux. + + +## 8. REALITY: что вышло (2026-08-20) + +Прогноз из §7 — «отдельный проект, а не фича» — подтвердился буквально: получился +отдельный пакет `QuickProxyNet.Reality`. А вот вывод «пока нет способа подделать +uTLS-отпечаток, честный `NotSupportedException` — единственное правильное +поведение» оказался верным лишь наполовину, и разбираться стоило именно с этой +половиной. + +Верно то, что **ядро** библиотеки не может говорить на REALITY: `SslStream` +отдаёт рукопожатие Schannel или OpenSSL и не даёт написать ClientHello. Неверно +то, что из этого следует отказ как единственный выход. Их два, и они отвечают на +разные вопросы: + +1. **Процесс-компаньон.** Локальный Xray с loopback-инбаундом SOCKS5 за фасадом + `RealityProxy`. Даёт всё сразу — REALITY, Vision, транспорты под ними — ценой + бинаря, который поставляет вызывающая сторона. Конфигурация уходит в Xray + через stdin, поэтому UUID не попадает на диск. +2. **Управляемая реализация.** X25519, аутентификация REALITY, клиент TLS 1.3 и + record layer на C#. Проходит настоящее рукопожатие с Xray-core 26.3.27 и + проносит VLESS без внешнего процесса. + +### Что оказалось дешевле, чем выглядело + +Криптография самого 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. + +### Открытый вопрос + +Весь `Managed/` помечен `internal`. То есть заявленная главная возможность — +REALITY без внешнего бинаря — потребителям пакета недоступна. Это незакрытая +работа, а не недосмотр: публичную форму (`RealityClient : IProxyClient`? внутрь +`VlessClient`? третий пакет?) ещё предстоит выбрать. From 4bcb557d502a88d635947389bc3551f17d8d0f96 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 16:56:52 +0500 Subject: [PATCH 19/25] perf(reality): batch the record layer's trips to the transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A TLS record is a 5-byte header and a body, and the record layer asked the transport for those separately: two reads per record, and one write plus one flush per record on the way out. Over a MemoryStream those are virtual calls and cost nothing, which is why the existing benchmark never saw them. Over a socket they are syscalls. Both directions are buffered now. Reads pull as much as the transport has into a 64 KiB buffer and hand out whole records from it, so a segment carrying four records costs one read rather than eight, and a record already buffered is returned without an async state machine at all. Writes stage several records contiguously and send the batch in one write, so a 64 KiB caller write is one write and one flush instead of four of each. Over a loopback socket, 1 MiB per operation: Write 3.058 ms -> 1.529 ms, 16 B -> 7 B allocated RoundTrip 3.087 ms -> 1.741 ms, 8960 B -> 762 B allocated Over memory the same traffic barely moves (600 -> 556 us), which is the point: what changed is the number of trips, not the arithmetic. The AEAD keeps its documented calling convention. Sealing and opening in place does work on .NET today and would save a copy per record, but AesGcm.Encrypt specifies only that plaintext and ciphertext have the same length and says nothing about overlap — and measured against this layer it was worth about one percent, the AEAD being the other ninety-nine. The two directions also keep their own staging buffers: sharing one would let a write overwrite a record the reader is still holding, which is exactly what a relay does. The record layer had no unit tests, and its only coverage was integration tests that skip without an Xray binary. It has twenty now: transports that drip one byte at a time and transports that deliver four records at once, peers that lie about their lengths, and the full-duplex case above. Co-Authored-By: Claude Opus 5 (1M context) --- .../RealityTlsSocketBenchmark.cs | 232 ++++++++ .../Managed/RealityTlsStream.cs | 55 +- .../Managed/TlsRecordLayer.cs | 277 +++++++-- QuickProxyNet.Tests/TlsRecordStreamTest.cs | 537 ++++++++++++++++++ 4 files changed, 1035 insertions(+), 66 deletions(-) create mode 100644 QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs create mode 100644 QuickProxyNet.Tests/TlsRecordStreamTest.cs diff --git a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs new file mode 100644 index 0000000..3c91d74 --- /dev/null +++ b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs @@ -0,0 +1,232 @@ +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; +using QuickProxyNet.Reality.Managed; + +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.Reality/Managed/RealityTlsStream.cs b/QuickProxyNet.Reality/Managed/RealityTlsStream.cs index 77238e2..7d08432 100644 --- a/QuickProxyNet.Reality/Managed/RealityTlsStream.cs +++ b/QuickProxyNet.Reality/Managed/RealityTlsStream.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace QuickProxyNet.Reality.Managed; /// @@ -54,13 +56,34 @@ public override long Position set => throw new NotSupportedException(); } - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + /// 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 0; + 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) @@ -70,6 +93,12 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation 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..]; @@ -78,6 +107,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation } /// Reads records until one yields application data. Returns false at end of stream. + [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))] private async ValueTask FillAsync(CancellationToken cancellationToken) { while (true) @@ -145,19 +175,22 @@ private static void SkipPostHandshakeMessage(ReadOnlySpan payload) throw new RealityHandshakeException($"Unexpected post-handshake message {type}."); } - public override async ValueTask WriteAsync( + /// 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); - while (!buffer.IsEmpty) - { - int chunk = Math.Min(buffer.Length, TlsRecordStream.MaxPlaintext); - await _records.WriteAsync(TlsContentType.ApplicationData, buffer[..chunk], cancellationToken) - .ConfigureAwait(false); - - buffer = buffer[chunk..]; - } + return buffer.IsEmpty + ? ValueTask.CompletedTask + : _records.WriteApplicationDataAsync(buffer, cancellationToken); } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => diff --git a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs index 2157066..34dd89c 100644 --- a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs +++ b/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace QuickProxyNet.Reality.Managed; @@ -113,7 +114,7 @@ public TlsRecordProtection(TlsCipherSuite suite, ReadOnlySpan trafficSecre } /// - /// Encrypts one record's inner plaintext, producing the ciphertext and tag in place. + /// 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. @@ -159,6 +160,18 @@ public void Dispose() /// /// 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). @@ -167,17 +180,63 @@ internal sealed class TlsRecordStream(Stream transport) : IDisposable /// Largest ciphertext a record may carry: plaintext, content type, tag and slack. public const int MaxCiphertext = MaxPlaintext + 256; - private readonly byte[] _header = new byte[5]; - private readonly byte[] _body = ArrayPool.Shared.Rent(MaxCiphertext); - private readonly byte[] _plaintext = ArrayPool.Shared.Rent(MaxCiphertext); + 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); - /// The record being written: header, ciphertext and tag, contiguous for one write. - private readonly byte[] _outbound = - ArrayPool.Shared.Rent(5 + MaxPlaintext + 1 + TlsCipherSuite.TagLength); + /// 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); - /// The inner plaintext being staged: the payload plus its content-type byte. + /// 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. @@ -193,33 +252,95 @@ internal sealed class TlsRecordStream(Stream transport) : IDisposable /// Reads one record, decrypting it when read protection is installed. /// Cancels the read. - public async ValueTask ReadAsync(CancellationToken cancellationToken) + /// + /// 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) { - await transport.ReadExactlyAsync(_header, cancellationToken).ConfigureAwait(false); + 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; - var type = (TlsContentType)_header[0]; - int length = (_header[3] << 8) | _header[4]; + 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 InvalidOperationException($"The peer sent a {length}-byte record, over the {MaxCiphertext} limit."); - await transport.ReadExactlyAsync(_body.AsMemory(0, length), cancellationToken).ConfigureAwait(false); + 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) - return new Record(type, _body.AsMemory(0, length)); + { + record = new Record(type, _inbound.AsMemory(bodyStart, length)); + return true; + } if (length < TlsCipherSuite.TagLength) throw new InvalidOperationException("The peer sent an encrypted record shorter than its own tag."); int contentLength = length - TlsCipherSuite.TagLength; + Read.Unprotect( - _body.AsSpan(0, contentLength), - _body.AsSpan(contentLength, TlsCipherSuite.TagLength), + _inbound.AsSpan(bodyStart, contentLength), + _inbound.AsSpan(bodyStart + contentLength, TlsCipherSuite.TagLength), _plaintext.AsSpan(0, contentLength), - _header); + 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), @@ -230,7 +351,8 @@ public async ValueTask ReadAsync(CancellationToken cancellationToken) if (end == 0) throw new InvalidOperationException("The peer sent a record with no content type."); - return new Record((TlsContentType)_plaintext[end - 1], _plaintext.AsMemory(0, end - 1)); + record = new Record((TlsContentType)_plaintext[end - 1], _plaintext.AsMemory(0, end - 1)); + return true; } /// Writes one record, encrypting it when write protection is installed. @@ -238,61 +360,105 @@ public async ValueTask ReadAsync(CancellationToken cancellationToken) /// The content. /// Cancels the write. /// - /// Not reentrant: one record is staged in shared buffers, and the protection's sequence - /// number advances per call. Two concurrent writers already produced records the peer could + /// 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 async ValueTask WriteAsync( + public ValueTask WriteAsync( TlsContentType type, ReadOnlyMemory payload, CancellationToken cancellationToken) { - // The staging buffers hold exactly one record. A larger payload would silently truncate - // the length field, so it is refused rather than corrected. + // 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) { - Span plain = _outbound.AsSpan(0, 5 + payload.Length); - plain[0] = (byte)type; - plain[1] = 3; - plain[2] = payload.Length > 0 && type == TlsContentType.Handshake ? (byte)1 : (byte)3; - plain[3] = (byte)(payload.Length >> 8); - plain[4] = (byte)payload.Length; - payload.Span.CopyTo(plain[5..]); - - await transport.WriteAsync(_outbound.AsMemory(0, 5 + payload.Length), cancellationToken) - .ConfigureAwait(false); - await transport.FlushAsync(cancellationToken).ConfigureAwait(false); - return; + 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; - _outbound[0] = (byte)TlsContentType.ApplicationData; - _outbound[1] = 3; - _outbound[2] = 3; - _outbound[3] = (byte)((inner + TlsCipherSuite.TagLength) >> 8); - _outbound[4] = (byte)(inner + TlsCipherSuite.TagLength); - - // Staged in a second buffer rather than encrypted in place: .NET does not document - // whether an AEAD may overlap its input and output, and the record layer is the wrong - // place to discover the answer. - payload.Span.CopyTo(_outboundPlain); + 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), - _outbound.AsSpan(5, inner), - _outbound.AsSpan(5 + inner, TlsCipherSuite.TagLength), - _outbound.AsSpan(0, 5)); + destination.Slice(HeaderLength, inner), + destination.Slice(HeaderLength + inner, TlsCipherSuite.TagLength), + destination[..HeaderLength]); - await transport - .WriteAsync(_outbound.AsMemory(0, 5 + inner + TlsCipherSuite.TagLength), cancellationToken) - .ConfigureAwait(false); - await transport.FlushAsync(cancellationToken).ConfigureAwait(false); + return HeaderLength + inner + TlsCipherSuite.TagLength; } /// Releases the pooled buffers and the AEAD instances. @@ -311,10 +477,11 @@ public void Dispose() Read?.Dispose(); Write?.Dispose(); - // Cleared on return: these held decrypted application data. + // 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); - ArrayPool.Shared.Return(_body, clearArray: true); - ArrayPool.Shared.Return(_outbound, clearArray: true); } } diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs new file mode 100644 index 0000000..908283c --- /dev/null +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -0,0 +1,537 @@ +using System.Security.Cryptography; +using QuickProxyNet.Reality.Managed; + +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)); + + await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + } + + /// 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)) + }; + + await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + } + + /// 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) + }; + + await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + } + + /// 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) + }; + + await Assert.ThrowsAsync(async () => + await reader.ReadAsync(CancellationToken.None)); + } + + /// 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()); + } +} From 77b72c4bb1d36aedd4cad904fb68073aad0ef7c7 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Thu, 20 Aug 2026 16:57:09 +0500 Subject: [PATCH 20/25] perf(reality): more than halve the cost of an X25519 key exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A REALITY handshake spends nearly all of its CPU in two scalar multiplications, and each was costing 323 us — about ten times what the same radix-2^51 field arithmetic costs in C. Two things were behind it. The field multiply wrote its products as `(UInt128)a * b`, which reads like a 64x64 multiply and is not: nothing in the expression tells the JIT the high halves are zero, so it emits the full 128x128 routine. Math.BigMul is the intrinsic for what was meant, and the multiply runs twenty-five of these per call, about twenty-eight hundred times per key exchange. Squaring went through the general multiply, so half of those twenty-five products were computed twice — f1*g2 and f2*g1 are the same number. A dedicated squaring folds each pair into one doubled product: ten multiplies instead of twenty-five. Four of the roughly nine field operations in a ladder step are squarings, and the final inversion is two hundred and fifty of them. X25519 scalar multiplication 323 us -> 137 us BuildClientHello 338 us -> 141 us DeriveAuthKey 341 us -> 151 us Squaring is checked against both arbitrary-precision arithmetic and the general multiply, on limb patterns sitting on the carry boundaries — a shortcut of this shape goes wrong by one limb on inputs where the missed term happens to be non-zero, which no RFC vector and no live handshake would reliably catch. The nonce build now xors the sequence number in as one big-endian 64-bit operation rather than a loop over eight bytes. It runs on every record. No SIMD here, deliberately. The AEADs are the BCL's and already run on AES-NI; the padding scan already uses a vectorised BCL search; and the 51-bit limb arithmetic wants AVX-512 IFMA, which the hardware this was measured on does not have — a Vector256 version without it would be slower than the scalar code. Co-Authored-By: Claude Opus 5 (1M context) --- .../Managed/TlsKeySchedule.cs | 9 +- QuickProxyNet.Reality/Managed/X25519.cs | 109 ++++++++++++++---- QuickProxyNet.Tests/X25519Test.cs | 45 +++++++- 3 files changed, 139 insertions(+), 24 deletions(-) diff --git a/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs b/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs index e2fdb4f..32fc1af 100644 --- a/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs +++ b/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using System.Security.Cryptography; using System.Text; @@ -120,8 +121,12 @@ public static void BuildNonce(Span nonce, ReadOnlySpan iv, ulong seq { iv.CopyTo(nonce); - for (int i = 0; i < 8; i++) - nonce[nonce.Length - 1 - i] ^= (byte)(sequenceNumber >> (8 * i)); + // 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. diff --git a/QuickProxyNet.Reality/Managed/X25519.cs b/QuickProxyNet.Reality/Managed/X25519.cs index dc572d6..0f23471 100644 --- a/QuickProxyNet.Reality/Managed/X25519.cs +++ b/QuickProxyNet.Reality/Managed/X25519.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace QuickProxyNet.Reality.Managed; @@ -146,11 +147,11 @@ private static void ScalarMultiply(Span result, ReadOnlySpan scalar, Mul(d, d, a); // d = (x3 + z3)(x2 - z2) Add(e, c, d); Sub(c, c, d); - Mul(x3, e, e); // x3 = (c + d)^2 - Mul(z3, c, c); // z3 = (c - d)^2 + Sqr(x3, e); // x3 = (c + d)^2 + Sqr(z3, c); // z3 = (c - d)^2 Mul(z3, z3, x1); // z3 *= u - Mul(e, a, a); // e = BB = (x2 - z2)^2 - Mul(c, b, b); // c = AA = (x2 + z2)^2 + 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); @@ -245,6 +246,24 @@ private static void Sub(Span result, Span left, Span right) 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]; @@ -256,11 +275,61 @@ private static void Mul(Span result, Span left, Span right) ulong g3_19 = 19 * g3; ulong g4_19 = 19 * g4; - UInt128 h0 = (UInt128)f0 * g0 + (UInt128)f1 * g4_19 + (UInt128)f2 * g3_19 + (UInt128)f3 * g2_19 + (UInt128)f4 * g1_19; - UInt128 h1 = (UInt128)f0 * g1 + (UInt128)f1 * g0 + (UInt128)f2 * g4_19 + (UInt128)f3 * g3_19 + (UInt128)f4 * g2_19; - UInt128 h2 = (UInt128)f0 * g2 + (UInt128)f1 * g1 + (UInt128)f2 * g0 + (UInt128)f3 * g4_19 + (UInt128)f4 * g3_19; - UInt128 h3 = (UInt128)f0 * g3 + (UInt128)f1 * g2 + (UInt128)f2 * g1 + (UInt128)f3 * g0 + (UInt128)f4 * g4_19; - UInt128 h4 = (UInt128)f0 * g4 + (UInt128)f1 * g3 + (UInt128)f2 * g2 + (UInt128)f3 * g1 + (UInt128)f4 * g0; + 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; @@ -288,11 +357,11 @@ private static void Mul(Span result, Span left, Span right) /// internal static void MulSmall(Span result, Span value, ulong scalar) { - UInt128 h0 = (UInt128)value[0] * scalar; - UInt128 h1 = (UInt128)value[1] * scalar; - UInt128 h2 = (UInt128)value[2] * scalar; - UInt128 h3 = (UInt128)value[3] * scalar; - UInt128 h4 = (UInt128)value[4] * 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; @@ -355,12 +424,12 @@ private static void Invert(Span result, Span z) Span z2_100_0 = stackalloc ulong[Limbs]; Span t = stackalloc ulong[Limbs]; - Mul(z2, z, z); // 2 - Mul(t, z2, z2); // 4 - Mul(t, t, t); // 8 + Sqr(z2, z); // 2 + Sqr(t, z2); // 4 + Sqr(t, t); // 8 Mul(z9, t, z); // 9 Mul(z11, z9, z2); // 11 - Mul(t, z11, z11); // 22 + Sqr(t, z11); // 22 Mul(z2_5_0, t, z9); // 2^5 - 2^0 Square(t, z2_5_0, 5); @@ -390,8 +459,8 @@ private static void Invert(Span result, Span z) private static void Square(Span result, Span value, int times) { - Mul(result, value, value); + Sqr(result, value); for (int i = 1; i < times; i++) - Mul(result, result, result); + Sqr(result, result); } } diff --git a/QuickProxyNet.Tests/X25519Test.cs b/QuickProxyNet.Tests/X25519Test.cs index d284f5d..39f4f49 100644 --- a/QuickProxyNet.Tests/X25519Test.cs +++ b/QuickProxyNet.Tests/X25519Test.cs @@ -151,8 +151,15 @@ private static System.Numerics.BigInteger ToInteger(ReadOnlySpan limbs) /// 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. /// - [Fact] - public void Multiply_MatchesArbitraryPrecision() + /// + /// 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; @@ -180,6 +187,13 @@ public void Multiply_MatchesArbitraryPrecision() ]); } + return cases; + } + + [Fact] + public void Multiply_MatchesArbitraryPrecision() + { + List cases = CarryStressCases(); ulong[] result = new ulong[5]; foreach (ulong[] left in cases) @@ -202,6 +216,33 @@ public void Multiply_MatchesArbitraryPrecision() } } + /// + /// 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() { From ef369ac63eaab810525f2a67eb7c42c9b8e4e3d4 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 21 Aug 2026 21:35:48 +0500 Subject: [PATCH 21/25] feat(vless): speak REALITY and Vision in-process, and take a link as a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that together let a caller hand the library a share link and get a stream back, for the 87% of a real-world corpus that is VLESS/Trojan/VMess. xtls-rprx-vision. VlessHelper never sent the account's flow, so a server whose user is configured for Vision — about 95% of REALITY nodes in the wild — closed the connection on sight. The flow now goes out in the addons, and VisionStream implements the padding protocol both ways. Two details came from dialling real servers rather than from the spec: they end the framing with the *direct* command far more often than with *end*, and they close on a frame boundary with no closing command at all. Handling only *end*, or treating that close as an error, breaks every completed download. REALITY in the core package. The managed TLS 1.3 client moves from QuickProxyNet.Reality to QuickProxyNet/Internal/Reality — it never depended on Xray, only on the BCL, so nothing about the zero-dependency promise changes. VlessClient now runs it for security=reality instead of refusing. The companion package keeps its reason to exist: grpc/xhttp, Vision's splice, and a real uTLS fingerprint, which the managed hello is still not. A string entry point. ProxyClientFactory.Create(string) and Proxy.ConnectAsync(string, ...) read the scheme off the text instead of going through Uri. That is not tidiness: vmess links are base64 JSON, and Uri rejects most real ones outright, so callers had to inspect the scheme themselves to choose between two APIs. One test changed meaning rather than expectations: the HTML-escaped-link test asserted NotSupportedException, which was how "don't downgrade REALITY to cleartext" happened to be enforced. It now asserts the guarantee itself — the connection opens with a TLS record and the UUID never appears in the clear. Verified against live third-party servers: HTTP 200 through real REALITY nodes, response bodies matching Content-Length. 521 unit tests pass on net10 and net11. Co-Authored-By: Claude Opus 5 (1M context) --- QuickProxyNet.Reality/RealityProxy.cs | 16 +- .../Integration/ManagedRealityTunnelTests.cs | 47 +- QuickProxyNet.Tests/ProxyClientFactoryTest.cs | 172 ++++++ QuickProxyNet.Tests/VisionTest.cs | 334 ++++++++++++ QuickProxyNet.Tests/VlessTest.cs | 35 +- QuickProxyNet/Clients/VlessClient.cs | 63 ++- QuickProxyNet/Configs/VlessOptions.cs | 7 +- .../Internal/Reality}/RealityAuth.cs | 0 .../Internal/Reality}/RealityTlsClient.cs | 0 .../Internal/Reality}/RealityTlsStream.cs | 0 .../Internal/Reality}/TlsClientHello.cs | 0 .../Internal/Reality}/TlsKeySchedule.cs | 0 .../Internal/Reality}/TlsRecordLayer.cs | 0 .../Internal/Reality}/TlsWriter.cs | 0 .../Internal/Reality}/X25519.cs | 0 QuickProxyNet/Internal/VisionStream.cs | 508 ++++++++++++++++++ QuickProxyNet/Internal/VlessHelper.cs | 75 ++- QuickProxyNet/Proxy.cs | 43 ++ QuickProxyNet/ProxyClientFactory.cs | 65 +++ QuickProxyNet/README.md | 17 +- README.md | 57 +- docs/vless.md | 23 +- 22 files changed, 1408 insertions(+), 54 deletions(-) create mode 100644 QuickProxyNet.Tests/ProxyClientFactoryTest.cs create mode 100644 QuickProxyNet.Tests/VisionTest.cs rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/RealityAuth.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/RealityTlsClient.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/RealityTlsStream.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/TlsClientHello.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/TlsKeySchedule.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/TlsRecordLayer.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/TlsWriter.cs (100%) rename {QuickProxyNet.Reality/Managed => QuickProxyNet/Internal/Reality}/X25519.cs (100%) create mode 100644 QuickProxyNet/Internal/VisionStream.cs diff --git a/QuickProxyNet.Reality/RealityProxy.cs b/QuickProxyNet.Reality/RealityProxy.cs index 994df83..89ca717 100644 --- a/QuickProxyNet.Reality/RealityProxy.cs +++ b/QuickProxyNet.Reality/RealityProxy.cs @@ -11,17 +11,15 @@ namespace QuickProxyNet.Reality; /// /// /// -/// What this is and is not. REALITY authenticates by hiding a key exchange inside the TLS -/// session_id of a ClientHello that must be byte-identical to a real browser's. .NET's -/// delegates the handshake to Schannel or OpenSSL and -/// exposes no way to author that ClientHello, so QuickProxyNet's in-process VLESS client cannot -/// speak REALITY and says so rather than downgrading. This package closes that gap the honest -/// way — by running the reference implementation — instead of by approximating a fingerprint, -/// which would mark the user as "not the browser I claim to be" rather than merely failing. +/// What this is for, now that REALITY works in-process. speaks +/// REALITY and xtls-rprx-vision by itself, with no binary, so this type is no longer the +/// way to reach a REALITY node — it is the way to reach what the managed stack still does not +/// implement: the grpc and xhttp transports, and Vision's TLS-in-TLS splice. /// /// -/// The cost is a child process and a binary the caller has to supply. In exchange, everything -/// Xray speaks comes with it: REALITY, xtls-rprx-vision, and the transports underneath. +/// The other reason to reach for it is the ClientHello. The managed client's hello is not yet a +/// browser fingerprint; Xray's uTLS one is. Where being indistinguishable matters more than +/// avoiding a child process, this is still the honest choice. /// /// /// Lifetime. One instance owns exactly one Xray process and one loopback port. diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs index 035d5fa..a119204 100644 --- a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs @@ -32,7 +32,7 @@ private static byte[] Base64Url(string value) /// Opens a VLESS tunnel to over managed REALITY. private static async Task OpenTunnelAsync( - LocalRealityServer server, int targetPort, CancellationToken cancellationToken) + LocalRealityServer server, int targetPort, CancellationToken cancellationToken, string? flow = null) { var tcp = new TcpClient(); await tcp.ConnectAsync("127.0.0.1", server.Port, cancellationToken); @@ -52,7 +52,8 @@ private static async Task OpenTunnelAsync( Id = LocalRealityServer.Id, Host = "127.0.0.1", Port = server.Port, - Security = VlessSecurity.Reality + Security = VlessSecurity.Reality, + Flow = flow }; return await VlessHelper.EstablishVlessTunnelAsync(tls, vless, "127.0.0.1", targetPort, cancellationToken); @@ -121,4 +122,46 @@ public async Task ManagedReality_SupportsSequentialTunnels() Assert.Contains(LoopbackEchoServer.Body, await GetAsync(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(RealityProxyOptions.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(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(RealityProxyOptions.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(tunnel, "/" + new string('a', 40_000), timeout.Token); + + Assert.Contains(LoopbackEchoServer.Body, response); + } } diff --git a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs new file mode 100644 index 0000000..9c1572c --- /dev/null +++ b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs @@ -0,0 +1,172 @@ +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 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/VisionTest.cs b/QuickProxyNet.Tests/VisionTest.cs new file mode 100644 index 0000000..e41098b --- /dev/null +++ b/QuickProxyNet.Tests/VisionTest.cs @@ -0,0 +1,334 @@ +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); + } + + private static async Task ReadAllAsync(Stream stream, int chunk = 4096) + { + var all = new MemoryStream(); + byte[] buffer = new byte[chunk]; + while (true) + { + int n = 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)); + } + + [Fact] + public async Task Read_StripsSeveralFrames_AndOnlyTheFirstCarriesTheUuid() + { + 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))); + } + + /// + /// 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. + /// + [Fact] + public async Task Read_SurvivesFramesSplitAcrossEveryByteBoundary() + { + 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))); + } + + [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. + /// + [Fact] + public async Task Read_DirectCommandEndsTheFraming() + { + 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))); + } + + /// + /// 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. + /// + [Fact] + public async Task Read_CloseOnAFrameBoundaryIsACleanEnd() + { + 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))); + } + + [Fact] + public async Task Read_TruncatedFrameThrows() + { + // 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)); + } + + // === writing === + + [Fact] + public async Task Write_PadsTheFirstWriteAndThenRunsRaw() + { + await using VisionStream stream = Wrap([], out MemoryStream sent); + + 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("")); + } + + /// 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 index fc741fa..0b52c8e 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -257,10 +257,39 @@ public void Parse_HtmlEscapedSeparators_DoNotSilentlyDowngradeRealityToPlaintext 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); - // And the client must refuse it loudly rather than connecting in the clear. - Assert.Throws(() => new VlessClient(o).ConnectAsync( - new MemoryStream(), "example.com", 443).AsTask().GetAwaiter().GetResult()); + // 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] diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 7c4c6d1..4e4c70d 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -1,16 +1,22 @@ using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; +using QuickProxyNet.Reality.Managed; namespace QuickProxyNet; /// -/// Connects to a target host through a VLESS proxy. Supports security=none (plain -/// TCP) and security=tls (over ), each over the -/// tcp/raw, ws or httpupgrade transport. REALITY, non-empty -/// flow, and the remaining transports are rejected with -/// . +/// 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; @@ -82,6 +88,12 @@ public override async ValueTask ConnectAsync(Stream stream, string host, 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, @@ -108,17 +120,50 @@ private TransportKind EnsureSupported() $"VLESS transport '{Options.Transport}' is not supported; 'tcp'/'raw', 'ws' and " + "'httpupgrade' are implemented."); - if (Options.Security == VlessSecurity.Reality) + if (Options.Security == VlessSecurity.Reality && string.IsNullOrEmpty(Options.RealityPublicKey)) throw new NotSupportedException( - "VLESS REALITY is not supported: it requires a uTLS ClientHello fingerprint that SslStream cannot produce."); + "VLESS REALITY needs the server's public key ('pbk' in the share link); this configuration has none."); - if (!string.IsNullOrEmpty(Options.Flow)) + if (!string.IsNullOrEmpty(Options.Flow) && !VlessHelper.IsVision(Options.Flow)) throw new NotSupportedException( - $"VLESS flow '{Options.Flow}' (XTLS) is not supported in this release."); + $"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 => "=", _ => "" }; + + try + { + return Convert.FromBase64String(padded); + } + catch (FormatException ex) + { + throw new NotSupportedException( + $"The REALITY public key '{value}' is not valid base64url.", ex); + } + } + private SslClientAuthenticationOptions BuildSslOptions() => new() { // Same precedence Xray applies: explicit SNI, else the transport Host header, else the diff --git a/QuickProxyNet/Configs/VlessOptions.cs b/QuickProxyNet/Configs/VlessOptions.cs index f8e9de7..a0394d5 100644 --- a/QuickProxyNet/Configs/VlessOptions.cs +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -25,9 +25,10 @@ public enum VlessSecurity /// /// /// Supported at connect time: the tcp/raw, ws and httpupgrade -/// transports with or . -/// Other fields (REALITY keys, non-empty , the grpc/xhttp -/// transports) are parsed so callers can inspect them, but connecting with them throws +/// 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 diff --git a/QuickProxyNet.Reality/Managed/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/RealityAuth.cs rename to QuickProxyNet/Internal/Reality/RealityAuth.cs diff --git a/QuickProxyNet.Reality/Managed/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/RealityTlsClient.cs rename to QuickProxyNet/Internal/Reality/RealityTlsClient.cs diff --git a/QuickProxyNet.Reality/Managed/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/RealityTlsStream.cs rename to QuickProxyNet/Internal/Reality/RealityTlsStream.cs diff --git a/QuickProxyNet.Reality/Managed/TlsClientHello.cs b/QuickProxyNet/Internal/Reality/TlsClientHello.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/TlsClientHello.cs rename to QuickProxyNet/Internal/Reality/TlsClientHello.cs diff --git a/QuickProxyNet.Reality/Managed/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/TlsKeySchedule.cs rename to QuickProxyNet/Internal/Reality/TlsKeySchedule.cs diff --git a/QuickProxyNet.Reality/Managed/TlsRecordLayer.cs b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/TlsRecordLayer.cs rename to QuickProxyNet/Internal/Reality/TlsRecordLayer.cs diff --git a/QuickProxyNet.Reality/Managed/TlsWriter.cs b/QuickProxyNet/Internal/Reality/TlsWriter.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/TlsWriter.cs rename to QuickProxyNet/Internal/Reality/TlsWriter.cs diff --git a/QuickProxyNet.Reality/Managed/X25519.cs b/QuickProxyNet/Internal/Reality/X25519.cs similarity index 100% rename from QuickProxyNet.Reality/Managed/X25519.cs rename to QuickProxyNet/Internal/Reality/X25519.cs diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs new file mode 100644 index 0000000..442b238 --- /dev/null +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -0,0 +1,508 @@ +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; + + 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 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) + { + // The decision needs a whole first frame header. A stream that ends before then + // was never framed, so whatever arrived is payload. + await FillAsync(UuidSize + HeaderSize, throwOnEof: false, cancellationToken).ConfigureAwait(false); + DecideMode(); + 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 peer closed the connection inside a Vision frame header."); + + ReadFrameHeader(); + continue; + } + + if (Buffered == 0 && await FillSomeAsync(cancellationToken).ConfigureAwait(false) == 0) + throw new EndOfStreamException("The peer closed the connection inside a Vision frame."); + + 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) + { + Fill(UuidSize + HeaderSize, throwOnEof: false); + DecideMode(); + 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 peer closed the connection inside a Vision frame header."); + + ReadFrameHeader(); + continue; + } + + if (Buffered == 0 && FillSome() == 0) + throw new EndOfStreamException("The peer closed the connection inside a Vision frame."); + + 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)); + + /// + /// Decides, from the bytes buffered so far, whether the peer is speaking Vision framing. + /// + private void DecideMode() + { + if (Buffered >= UuidSize + HeaderSize && _buffer.AsSpan(_start, UuidSize).SequenceEqual(_uuid)) + { + _start += UuidSize; + _mode = Mode.Framed; + return; + } + + // Not our UUID — the server answered in plain VLESS despite the flow, which is what a + // non-Vision server does. Everything buffered is payload. + _mode = Mode.Raw; + } + + /// 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; + } + + 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 peer closed the connection inside a Vision frame header."); + 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 peer closed the connection inside a Vision frame header."); + 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); + } + + 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); + } + + 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; + ReturnBuffer(); + + if (!_leaveInnerOpen) + await _inner.DisposeAsync().ConfigureAwait(false); + + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + ReturnBuffer(); + + if (!_leaveInnerOpen) + _inner.Dispose(); + } + + _disposed = true; + base.Dispose(disposing); + } + + private void ReturnBuffer() + { + byte[] buffer = _buffer; + _buffer = []; + + if (buffer.Length > 0) + ArrayPool.Shared.Return(buffer); + } +} diff --git a/QuickProxyNet/Internal/VlessHelper.cs b/QuickProxyNet/Internal/VlessHelper.cs index c148424..ba0ffde 100644 --- a/QuickProxyNet/Internal/VlessHelper.cs +++ b/QuickProxyNet/Internal/VlessHelper.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Buffers.Binary; +using System.Text; namespace QuickProxyNet; @@ -25,8 +26,15 @@ internal static class VlessHelper private const byte AtypDomain = 0x02; private const byte AtypIPv6 = 0x03; - // ver(1) + uuid(16) + addonsLen(1) + cmd(1) + port(2) + max address. - private const int MaxRequestSize = 1 + UuidCodec.Size + 1 + 1 + 2 + ProxyAddress.MaxLength; + /// 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 @@ -40,10 +48,11 @@ internal static class VlessHelper 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); + 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); } @@ -54,18 +63,64 @@ internal static async ValueTask EstablishVlessTunnelAsync( ArrayPool.Shared.Return(buffer, clearArray: true); } - return new VlessResponseStream(stream, host, port); + 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)); - buffer[17] = 0x00; // addons length - buffer[18] = CommandTcp; - BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(19), (ushort)port); - int addressLength = - ProxyAddress.WriteTypeAndAddress(host, buffer.Slice(21), AtypIPv4, AtypDomain, AtypIPv6); - return 21 + addressLength; + + 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/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 a5f289d..63b4658 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -13,6 +13,71 @@ 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( + $"'{Summarize(trimmed)}' is not a proxy link: expected a scheme followed by '://'.", 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($"'{Summarize(trimmed)}' is not a well-formed {scheme} URI."); + + return Create(uri); + } + + throw new NotSupportedException( + $"Proxy scheme '{scheme}' is not supported. This library speaks http, https, socks4, " + + "socks4a, socks5, vless, trojan and vmess."); + } + + /// Shortens a link for an error message, so a credential does not end up in a log. + private static string Summarize(string link) => + link.Length <= 24 ? link : string.Concat(link.AsSpan(0, 24), "…"); + /// /// Creates an IProxyClient instance based on the provided URI, automatically determining the proxy type /// and extracting credentials if they are present in the URI. diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index edc6e45..6a57b21 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -2,16 +2,20 @@ 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. -VLESS REALITY lives in the separate `QuickProxyNet.Reality` package, so the core keeps its zero-dependency promise. +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. + +The separate `QuickProxyNet.Reality` package is now only for what a child Xray process still buys: the `grpc`/`xhttp` transports, Vision's TLS-in-TLS splice, and a genuine uTLS fingerprint. **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)); ``` @@ -27,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) @@ -49,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 27df30f..7a8f154 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ - **Zero-alloc protocol logic** — `ArrayPool`, `stackalloc`, `Utf8Formatter`, `ValueTask` throughout - **5 classic proxy protocols** — HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5 - **3 VPN-style protocols** — VLESS, VMess (VMessAEAD), Trojan, over `tcp`, `ws` or `httpupgrade` -- **Share-link parsing** — `vless://`, `vmess://`, `trojan://`, validated against a 21 403-link real-world corpus -- **VLESS REALITY** — in the separate `QuickProxyNet.Reality` package -- **Static one-liner API** — `Proxy.ConnectAsync(uri, host, port)` for mass checkers +- **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 @@ -30,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 @@ -50,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; @@ -68,6 +79,33 @@ 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 + +Percentages are measured by this library's own parsers over a real-world corpus of 20 228 share links (snapshot of 2026-08-21; the list changes daily, so treat these as proportions, not constants). + +| | Share of corpus | Status | +|---|---|---| +| Plain VLESS / VMess / Trojan (`tcp`, `ws`, `httpupgrade`) | 41% | Works in-process | +| VLESS REALITY (incl. `xtls-rprx-vision`) | 46% | Works in-process | +| `grpc` transport | 6.0% | Not supported in-process — use `QuickProxyNet.Reality` | +| Hysteria2 | 2.9% | No client — QUIC/datagram model doesn't fit the library's `Stream` model | +| `xhttp` transport | 2.9% | Not supported in-process — use `QuickProxyNet.Reality` | +| `xtls-rprx-vision-udp443` flow | 0.1% | Not supported | + +UDP is not supported as a class: the whole library is built around `ConnectAsync(...) -> Stream`. + +### The `QuickProxyNet.Reality` package + +The companion package no longer exists "for REALITY" — that moved into the core. It now covers what the managed stack cannot do yet: the `grpc` and `xhttp` transports, Vision's TLS-in-TLS splice, and a genuine uTLS browser fingerprint, by driving a child Xray process. You bring the Xray binary. + ## Error Handling All proxy protocol errors throw `ProxyProtocolException` with a specific `ProxyErrorCode`: @@ -75,7 +113,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) @@ -128,6 +166,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/docs/vless.md b/docs/vless.md index 9de8e9d..6688d33 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -173,10 +173,25 @@ REALITY-specific certificate behavior. Это отдельный transport secur ### `flow=xtls-rprx-vision` -Flow не меняет базовый VLESS request header как таковой, но меняет поведение -последующего копирования/шифрования в XTLS/REALITY режиме. Для чистой -QuickProxyNet-реализации его лучше считать отдельной большой задачей, а не -частью базового VLESS. +Реализовано. 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-модель From 16c38d9a8c875211fbac358b2fa3323cf7cec29b Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 21 Aug 2026 21:43:40 +0500 Subject: [PATCH 22/25] refactor: delete the QuickProxyNet.Reality package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was never published — `Pack` only ever built the core project — so nothing downstream can break. What it did, the core now does: REALITY and Vision are managed code, and the child Xray process bought only `grpc`, `xhttp`, Vision's splice and a uTLS fingerprint. Those are now listed as unsupported instead of delegated to a second implementation nobody could install. Xray stays in the test suite, as the reference server the managed handshake is proven against; `LocalRealityServer.ExecutablePathVariable` replaces `RealityProxyOptions.ExecutablePathVariable` as the way to find it. The tests that covered the deleted feature — the Xray JSON renderer and the loopback SOCKS5 proxy — go with it. The namespace loses its `.Managed` suffix, which only ever meant "not the Xray one". READMEs gain a real support matrix: protocols, transports, and VLESS security/flow, each row saying plainly whether it works, followed by what share of a 20 228-link corpus that covers. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 64 ++-- .../QuickProxyNet.Benchmarks.csproj | 1 - .../RealityHandshakeBenchmark.cs | 2 +- .../RealityRecordBenchmark.cs | 2 +- .../RealityTlsSocketBenchmark.cs | 2 +- .../RealityTlsStreamBenchmark.cs | 2 +- .../QuickProxyNet.Reality.csproj | 45 --- QuickProxyNet.Reality/RealityProxy.cs | 319 ------------------ QuickProxyNet.Reality/RealityProxyOptions.cs | 52 --- QuickProxyNet.Reality/XrayClientConfig.cs | 196 ----------- QuickProxyNet.Reality/XrayExecutable.cs | 76 ----- QuickProxyNet.Tests/HostilePeerTest.cs | 2 +- .../LargeRequestDiagnosticTests.cs | 3 +- .../Integration/LocalRealityServer.cs | 11 + .../ManagedRealityHandshakeTests.cs | 9 +- .../Integration/ManagedRealityTunnelTests.cs | 13 +- .../Integration/ManagedTlsHandshakeTests.cs | 11 +- .../Integration/RealityProxyTests.cs | 192 ----------- .../QuickProxyNet.Tests.csproj | 1 - QuickProxyNet.Tests/RealityAuthTest.cs | 2 +- QuickProxyNet.Tests/RealityConfigTest.cs | 250 -------------- QuickProxyNet.Tests/TlsKeyScheduleTest.cs | 2 +- QuickProxyNet.Tests/TlsRecordStreamTest.cs | 2 +- QuickProxyNet.Tests/X25519Test.cs | 2 +- QuickProxyNet.slnx | 1 - QuickProxyNet/Clients/VlessClient.cs | 2 +- QuickProxyNet/Internal/Reality/RealityAuth.cs | 2 +- .../Internal/Reality/RealityTlsClient.cs | 2 +- .../Internal/Reality/RealityTlsStream.cs | 2 +- .../Internal/Reality/TlsClientHello.cs | 2 +- .../Internal/Reality/TlsKeySchedule.cs | 2 +- .../Internal/Reality/TlsRecordLayer.cs | 2 +- QuickProxyNet/Internal/Reality/TlsWriter.cs | 2 +- QuickProxyNet/Internal/Reality/X25519.cs | 2 +- QuickProxyNet/QuickProxyNet.csproj | 2 +- QuickProxyNet/README.md | 2 +- README.md | 56 ++- docs/README.md | 2 +- docs/reality-fingerprint-plan.md | 2 +- 39 files changed, 126 insertions(+), 1218 deletions(-) delete mode 100644 QuickProxyNet.Reality/QuickProxyNet.Reality.csproj delete mode 100644 QuickProxyNet.Reality/RealityProxy.cs delete mode 100644 QuickProxyNet.Reality/RealityProxyOptions.cs delete mode 100644 QuickProxyNet.Reality/XrayClientConfig.cs delete mode 100644 QuickProxyNet.Reality/XrayExecutable.cs delete mode 100644 QuickProxyNet.Tests/Integration/RealityProxyTests.cs delete mode 100644 QuickProxyNet.Tests/RealityConfigTest.cs diff --git a/AGENTS.md b/AGENTS.md index 23ac272..29b5992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,10 @@ 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). -A second package, `QuickProxyNet.Reality`, adds VLESS REALITY. It is separate on -purpose: the core keeps its zero-dependency promise, and opting into REALITY is an -explicit choice. See "The REALITY Package" below. +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 packages: `QuickProxyNet`, `QuickProxyNet.Reality` +- NuGet package: `QuickProxyNet` - Author: Titlehhhh - License: MIT - Core targets: `net8.0`, `net9.0`, `net10.0`, `net11.0` @@ -19,7 +18,6 @@ explicit choice. See "The REALITY Package" below. ```text QuickProxyNet/ Core library and protocol logic -QuickProxyNet.Reality/ VLESS REALITY: a managed client, plus an Xray-driven one QuickProxyNet.Tests/ xUnit tests QuickProxyNet.Benchmarks/ BenchmarkDotNet benchmarks Sample/ Console usage example @@ -48,10 +46,9 @@ All public library types live in the `QuickProxyNet` namespace. - `VlessOptions` / `TrojanOptions` / `VmessOptions` plus the matching `*ShareLink.Parse` / `TryParse` describe a VPN-style endpoint. -In `QuickProxyNet.Reality`: `RealityProxy` (an `IAsyncDisposable` owning one Xray -process and one loopback port), `RealityProxyOptions`, and `RealityHandshakeException`. -The managed stack under `Managed/` is still `internal` — see the open question at the -end of this file. +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 @@ -62,10 +59,9 @@ end of this file. | `Socks4Client` | SOCKS4 | | `Socks4aClient` | SOCKS4a | | `Socks5Client` | SOCKS5 with optional username/password auth | -| `VlessClient` | VLESS, `security=none` or `tls` | +| `VlessClient` | VLESS, `security=none`, `tls` or `reality`, with or without `xtls-rprx-vision` | | `TrojanClient` | Trojan over TLS | | `VmessClient` | VMess (VMessAEAD, `alterId=0`), optional TLS | -| `RealityProxy` | VLESS REALITY and XTLS Vision, via a local Xray process (separate package) | 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 @@ -98,41 +94,41 @@ Internal/Vmess/VmessResponseStream.cs lazy response-header reader Internal/Vmess/VmessStream.cs AEAD chunk framing ``` -## The REALITY Package +## REALITY -`QuickProxyNet.Reality` exists because REALITY cannot be done in the core library: -it authenticates by hiding a key exchange inside the TLS `session_id` of a +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. There are two -implementations, and they answer different questions. +Schannel or OpenSSL with no way to author those bytes. So the handshake is written +here, in `QuickProxyNet/Internal/Reality/`: ```text -RealityProxy.cs Drives a local Xray process with a loopback SOCKS5 inbound -RealityProxyOptions.cs Where to find the binary, what to bind, how much to log -XrayClientConfig.cs VlessOptions -> Xray JSON, handed over on stdin -XrayExecutable.cs Explicit path -> QPN_XRAY_PATH -> PATH - -Managed/X25519.cs RFC 7748, because net8-net10 have no X25519 anywhere -Managed/RealityAuth.cs authKey derivation, session_id sealing, the certificate HMAC -Managed/TlsKeySchedule.cs RFC 8446 §7.1 and §7.3 -Managed/TlsRecordLayer.cs Suites, record protection, record read/write -Managed/TlsWriter.cs TLS's length-prefixed vectors, with backpatching -Managed/TlsClientHello.cs The hello — NOT yet a browser fingerprint, see below -Managed/RealityTlsClient.cs The handshake state machine -Managed/RealityTlsStream.cs Application data over the record layer +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 ``` -The Xray path ships nothing: the binary is the caller's, supplied through -`RealityProxyOptions.ExecutablePath`, `QPN_XRAY_PATH`, or `PATH`. Its configuration -goes to Xray on **stdin** (`run -c stdin:`) so the VLESS id never reaches disk. +`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, the -managed path is a protocol implementation, and the type says so in its own docs. +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 diff --git a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj index 2b4dfca..8a4ff39 100644 --- a/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj +++ b/QuickProxyNet.Benchmarks/QuickProxyNet.Benchmarks.csproj @@ -17,6 +17,5 @@ - diff --git a/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs index e18a88c..16e366a 100644 --- a/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs @@ -4,7 +4,7 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs index 2185c97..1cc09dc 100644 --- a/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs @@ -9,7 +9,7 @@ using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs index 3c91d74..947dbdc 100644 --- a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs @@ -9,7 +9,7 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs index f00a3fe..b37629d 100644 --- a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs @@ -8,7 +8,7 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj b/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj deleted file mode 100644 index a013c32..0000000 --- a/QuickProxyNet.Reality/QuickProxyNet.Reality.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - net8.0;net9.0;net10.0;net11.0 - enable - enable - latest - v - 3.0 - - - QuickProxyNet.Reality - Titlehhhh - Titlehhhh - VLESS REALITY and XTLS Vision support for QuickProxyNet by driving a local Xray-core process. The Xray binary is supplied by the caller and is not shipped in this package. - proxy;networking;vless;reality;xray;xtls - Copyright © Titlehhhh 2026 - https://github.com/Titlehhhh/QuickProxyNet - https://github.com/Titlehhhh/QuickProxyNet - - - - True - $(NoWarn);CS1591 - - - - icon.png - LICENSE.txt - - - - - - - - - - - - - - - - - diff --git a/QuickProxyNet.Reality/RealityProxy.cs b/QuickProxyNet.Reality/RealityProxy.cs deleted file mode 100644 index 89ca717..0000000 --- a/QuickProxyNet.Reality/RealityProxy.cs +++ /dev/null @@ -1,319 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using System.Text; - -namespace QuickProxyNet.Reality; - -/// -/// A VLESS REALITY (or TLS + XTLS Vision) tunnel, provided by a local Xray-core process with a -/// loopback SOCKS5 inbound in front of it. -/// -/// -/// -/// What this is for, now that REALITY works in-process. speaks -/// REALITY and xtls-rprx-vision by itself, with no binary, so this type is no longer the -/// way to reach a REALITY node — it is the way to reach what the managed stack still does not -/// implement: the grpc and xhttp transports, and Vision's TLS-in-TLS splice. -/// -/// -/// The other reason to reach for it is the ClientHello. The managed client's hello is not yet a -/// browser fingerprint; Xray's uTLS one is. Where being indistinguishable matters more than -/// avoiding a child process, this is still the honest choice. -/// -/// -/// Lifetime. One instance owns exactly one Xray process and one loopback port. -/// kills that process by its own handle — never by image name, since -/// the user's own VPN client is very likely running a binary with the same name. -/// -/// -/// -/// -/// await using var proxy = await RealityProxy.StartAsync( -/// "vless://uuid@example.com:443?security=reality&pbk=...&sid=ab12&sni=www.cloudflare.com&fp=chrome#node"); -/// await using Stream tunnel = await proxy.ConnectAsync("example.org", 80); -/// -/// -public sealed class RealityProxy : IAsyncDisposable -{ - private readonly Process _process; - private readonly Socks5Client _client; - private int _disposed; - - private RealityProxy(Process process, Socks5Client client, string listenAddress, int port) - { - _process = process; - _client = client; - ListenAddress = listenAddress; - ListenPort = port; - } - - /// Loopback address the local SOCKS5 inbound is bound to. - public string ListenAddress { get; } - - /// Port the local SOCKS5 inbound is bound to. - public int ListenPort { get; } - - /// - /// A SOCKS5 client aimed at the local inbound, for handing to code that takes an - /// . - /// - public IProxyClient Client => _client; - - /// Whether the Xray process is still running. - /// - /// False once disposed. throws on a disposed handle, and a - /// property that answers "is it running" by throwing is worse than useless to a caller - /// cleaning up. - /// - public bool IsRunning - { - get - { - if (Volatile.Read(ref _disposed) != 0) - return false; - - try - { - return !_process.HasExited; - } - catch (InvalidOperationException) - { - return false; - } - } - } - - /// Starts a tunnel for a vless:// share link. - /// A vless:// link with security=reality or security=tls. - /// Process settings, or null for the defaults. - /// Cancels startup. - public static ValueTask StartAsync( - string shareLink, - RealityProxyOptions? options = null, - CancellationToken cancellationToken = default) => - StartAsync(VlessShareLink.Parse(shareLink), options, cancellationToken); - - /// Starts a tunnel for an already-parsed VLESS configuration. - /// The outbound to drive. - /// Process settings, or null for the defaults. - /// Cancels startup. - /// Xray-core could not be located. - /// The configuration cannot be rendered. - /// Xray started but never accepted on the inbound. - public static async ValueTask StartAsync( - VlessOptions vless, - RealityProxyOptions? options = null, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(vless); - options ??= new RealityProxyOptions(); - - string executable = XrayExecutable.Resolve(options.ExecutablePath); - int port = options.ListenPort ?? ReserveEphemeralPort(options.ListenAddress); - - // Rendered before the process exists so a bad configuration throws without leaving one behind. - byte[] config = XrayClientConfig.Build(vless, options.ListenAddress, port, options.LogLevel); - - var startInfo = new ProcessStartInfo(executable) - { - // 'stdin:' is what keeps the VLESS id off disk. See XrayClientConfig. - ArgumentList = { "run", "-c", "stdin:" }, - WorkingDirectory = Path.GetDirectoryName(executable) ?? Environment.CurrentDirectory, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - Process process = Process.Start(startInfo) - ?? throw new InvalidOperationException($"Could not start '{executable}'."); - - var log = new OutputBuffer(options.LogSink); - var proxy = new RealityProxy(process, new Socks5Client(options.ListenAddress, port), options.ListenAddress, port); - - try - { - log.Attach(process); - - try - { - await process.StandardInput.BaseStream.WriteAsync(config, cancellationToken).ConfigureAwait(false); - await process.StandardInput.BaseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - // The credential is gone from our memory as soon as it has been handed over, - // and stdin must close for Xray to know the document ended. - Array.Clear(config); - process.StandardInput.Close(); - } - - await WaitUntilAcceptingAsync(process, options, port, log, cancellationToken).ConfigureAwait(false); - return proxy; - } - catch - { - await proxy.DisposeAsync().ConfigureAwait(false); - throw; - } - } - - /// Opens a tunnelled connection to :. - /// Target host; resolved by the remote server, not locally. - /// Target port. - /// Cancels the connection attempt. - public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); - return _client.ConnectAsync(host, port, cancellationToken); - } - - /// Stops the Xray process this instance started. - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - return; - - try - { - if (!_process.HasExited) - { - // By handle, and only this handle. Killing by image name would take down the - // user's own VPN client, which almost certainly runs a binary called 'xray'. - _process.Kill(entireProcessTree: true); - await _process.WaitForExitAsync().ConfigureAwait(false); - } - } - catch (InvalidOperationException) - { - // Already gone between the check and the kill. - } - finally - { - _process.Dispose(); - } - } - - /// - /// Polls the inbound until it accepts, failing fast if Xray dies first. - /// - /// - /// Xray binds its inbounds only after the whole configuration has been accepted, so an - /// accepted connection is real evidence the tunnel is configured — unlike a fixed delay, - /// which would be both slower and a guess. - /// - private static async Task WaitUntilAcceptingAsync( - Process process, RealityProxyOptions options, int port, OutputBuffer log, CancellationToken cancellationToken) - { - long deadline = Environment.TickCount64 + (long)options.StartupTimeout.TotalMilliseconds; - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (process.HasExited) - throw new InvalidOperationException( - $"Xray exited with code {process.ExitCode} during startup.{log.Format()}"); - - if (await TryConnectAsync(options.ListenAddress, port, cancellationToken).ConfigureAwait(false)) - return; - - if (Environment.TickCount64 > deadline) - throw new InvalidOperationException( - $"Xray did not start accepting on {options.ListenAddress}:{port} within " + - $"{options.StartupTimeout}.{log.Format()}"); - - await Task.Delay(50, cancellationToken).ConfigureAwait(false); - } - } - - private static async Task TryConnectAsync(string address, int port, CancellationToken cancellationToken) - { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(2)); - - try - { - await socket.ConnectAsync(address, port, cts.Token).ConfigureAwait(false); - return true; - } - catch (Exception ex) when (ex is SocketException || (ex is OperationCanceledException && !cancellationToken.IsCancellationRequested)) - { - return false; - } - } - - /// - /// Binds port 0, reads what the OS handed out, and releases it. - /// - /// - /// There is a window between releasing the port and Xray binding it in which something else - /// could take it; Xray then fails to start and - /// throws with its output. That is preferable to the alternative, which is holding the socket - /// open and having Xray fail to bind every time. Callers who need determinism set - /// . - /// - private static int ReserveEphemeralPort(string address) - { - var listener = new TcpListener(IPAddress.Parse(address), 0); - listener.Start(); - try - { - return ((IPEndPoint)listener.LocalEndpoint).Port; - } - finally - { - listener.Stop(); - } - } - - /// - /// Keeps the last few lines of Xray's output so a startup failure can quote the reason. - /// - private sealed class OutputBuffer(Action? sink) - { - private const int MaxLines = 20; - private readonly Queue _lines = new(); - - public void Attach(Process process) - { - process.OutputDataReceived += OnData; - process.ErrorDataReceived += OnData; - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - } - - private void OnData(object? sender, DataReceivedEventArgs e) - { - if (e.Data is null) - return; - - sink?.Invoke(e.Data); - - lock (_lines) - { - _lines.Enqueue(e.Data); - if (_lines.Count > MaxLines) - _lines.Dequeue(); - } - } - - public string Format() - { - lock (_lines) - { - if (_lines.Count == 0) - return " It produced no output."; - - var sb = new StringBuilder(" Its last output was:"); - foreach (string line in _lines) - sb.Append('\n').Append(" ").Append(line); - - return sb.ToString(); - } - } - } -} diff --git a/QuickProxyNet.Reality/RealityProxyOptions.cs b/QuickProxyNet.Reality/RealityProxyOptions.cs deleted file mode 100644 index 3d4486c..0000000 --- a/QuickProxyNet.Reality/RealityProxyOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace QuickProxyNet.Reality; - -/// -/// Knobs for how locates and runs Xray-core. -/// -public sealed class RealityProxyOptions -{ - /// - /// Environment variable consulted when is null. - /// - public const string ExecutablePathVariable = "QPN_XRAY_PATH"; - - /// - /// Full path to the Xray-core executable. When null the resolver falls back to - /// and then to PATH. - /// - /// - /// This package does not ship a binary. Xray-core is MPL-2.0 and platform-specific; - /// bundling it would make a NuGet package a redistributor of a censorship-circumvention - /// binary, with the download size and antivirus consequences that implies. Pointing at a - /// binary the caller already trusts keeps that decision with the caller. - /// - public string? ExecutablePath { get; init; } - - /// Loopback address for the local SOCKS5 inbound. Defaults to 127.0.0.1. - /// - /// Loopback is not a default to override casually: the inbound has no authentication, so - /// binding it to a routable address exposes an open proxy to the network. - /// - public string ListenAddress { get; init; } = "127.0.0.1"; - - /// - /// Fixed port for the local inbound. When null (the default) a free ephemeral port is taken. - /// - public int? ListenPort { get; init; } - - /// How long to wait for Xray to start accepting on the inbound. - public TimeSpan StartupTimeout { get; init; } = TimeSpan.FromSeconds(15); - - /// Xray loglevel. Defaults to warning. - /// - /// info and below log the destination of every connection made through the tunnel. - /// That is exactly the record the tunnel exists to avoid producing, so verbose logging is - /// opt-in and never the default. - /// - public string LogLevel { get; init; } = "warning"; - - /// - /// Receives Xray's stdout/stderr lines when set. Null discards them. - /// - public Action? LogSink { get; init; } -} diff --git a/QuickProxyNet.Reality/XrayClientConfig.cs b/QuickProxyNet.Reality/XrayClientConfig.cs deleted file mode 100644 index f149330..0000000 --- a/QuickProxyNet.Reality/XrayClientConfig.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System.Text.Json; - -namespace QuickProxyNet.Reality; - -/// -/// Builds the Xray-core client configuration that fronts a VLESS outbound with a local -/// SOCKS5 inbound. -/// -/// -/// -/// The generated document is handed to Xray on standard input (run -c stdin:), -/// never written to a file. It contains the VLESS id — a credential — and a config file would -/// leave that credential on disk with the lifetime of the process at best, and past a crash at -/// worst. Xray accepting stdin is what makes that avoidable. -/// -/// -/// This is deliberately the only piece of the package with no process in it: the JSON is a pure -/// function of plus a port, so the mapping can be tested exactly -/// without spawning anything. -/// -/// -internal static class XrayClientConfig -{ - /// Fingerprint used when the share link carries no fp. - /// - /// Xray treats an empty fingerprint as "no uTLS", which produces Go's own ClientHello — - /// the single most identifiable handshake a REALITY client can emit. Defaulting to - /// chrome is therefore a safety default, not a cosmetic one. - /// - public const string DefaultFingerprint = "chrome"; - - /// - /// Renders the client configuration for with a SOCKS5 inbound - /// on :. - /// - /// The VLESS outbound to drive. - /// Loopback address for the local inbound. - /// Port for the local inbound. - /// Xray loglevel. - /// UTF-8 JSON. - /// - /// The options select something this package does not render. Never silently reduced to - /// something weaker — an unrendered field would mean connecting with less protection than - /// the share link asked for. - /// - public static byte[] Build(VlessOptions options, string listenAddress, int socksPort, string logLevel) - { - ArgumentNullException.ThrowIfNull(options); - - string security = options.Security switch - { - VlessSecurity.Reality => "reality", - VlessSecurity.Tls => "tls", - _ => throw new NotSupportedException( - $"QuickProxyNet.Reality drives TLS and REALITY outbounds; this one is " + - $"'{options.Security}'. Plain VLESS needs no external process — use " + - $"QuickProxyNet's VlessClient directly.") - }; - - if (options.Security == VlessSecurity.Reality && string.IsNullOrEmpty(options.RealityPublicKey)) - throw new NotSupportedException( - "A REALITY outbound requires a public key ('pbk' in the share link); this one has none. " + - "Connecting without it would fall back to an ordinary TLS handshake and send the VLESS id " + - "to a server that is not expecting one."); - - string network = ResolveNetwork(options.Transport); - - // Xray refuses this combination outright: "REALITY only supports RAW, XHTTP and gRPC for - // now." Rendering it anyway would produce a document the process rejects at startup, and - // the caller would see a generic launch failure instead of the actual reason. - if (options.Security == VlessSecurity.Reality && network != "tcp") - throw new NotSupportedException( - $"REALITY runs only over the raw/tcp transport (and, in Xray, xhttp and gRPC); this link " + - $"asks for '{options.Transport}'. A REALITY link with a WebSocket transport is malformed — " + - "no server can serve it."); - - var buffer = new MemoryStream(1024); - using (var w = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false })) - { - w.WriteStartObject(); - - w.WriteStartObject("log"); - w.WriteString("loglevel", logLevel); - w.WriteEndObject(); - - w.WriteStartArray("inbounds"); - w.WriteStartObject(); - w.WriteString("listen", listenAddress); - w.WriteNumber("port", socksPort); - w.WriteString("protocol", "socks"); - w.WriteStartObject("settings"); - w.WriteString("auth", "noauth"); - // UDP would need the inbound to hand out a relay address, and nothing in - // QuickProxyNet consumes UDP. Off, rather than advertised and broken. - w.WriteBoolean("udp", false); - w.WriteEndObject(); - w.WriteEndObject(); - w.WriteEndArray(); - - w.WriteStartArray("outbounds"); - w.WriteStartObject(); - w.WriteString("protocol", "vless"); - - w.WriteStartObject("settings"); - w.WriteStartArray("vnext"); - w.WriteStartObject(); - w.WriteString("address", options.Host); - w.WriteNumber("port", options.Port); - w.WriteStartArray("users"); - w.WriteStartObject(); - w.WriteString("id", options.Id); - w.WriteString("encryption", "none"); - if (!string.IsNullOrEmpty(options.Flow)) - w.WriteString("flow", options.Flow); - w.WriteEndObject(); - w.WriteEndArray(); - w.WriteEndObject(); - w.WriteEndArray(); - w.WriteEndObject(); - - w.WriteStartObject("streamSettings"); - w.WriteString("network", network); - w.WriteString("security", security); - - if (options.Security == VlessSecurity.Reality) - { - w.WriteStartObject("realitySettings"); - w.WriteString("serverName", options.Sni ?? options.Host); - w.WriteString("fingerprint", string.IsNullOrEmpty(options.Fingerprint) - ? DefaultFingerprint - : options.Fingerprint); - w.WriteString("publicKey", options.RealityPublicKey!); - if (!string.IsNullOrEmpty(options.RealityShortId)) - w.WriteString("shortId", options.RealityShortId); - w.WriteEndObject(); - } - else - { - w.WriteStartObject("tlsSettings"); - w.WriteString("serverName", options.Sni ?? options.HostHeader ?? options.Host); - w.WriteString("fingerprint", string.IsNullOrEmpty(options.Fingerprint) - ? DefaultFingerprint - : options.Fingerprint); - if (options.Alpn is { Count: > 0 }) - { - w.WriteStartArray("alpn"); - foreach (string alpn in options.Alpn) - w.WriteStringValue(alpn); - w.WriteEndArray(); - } - w.WriteEndObject(); - } - - WriteTransportSettings(w, network, options); - - w.WriteEndObject(); // streamSettings - w.WriteEndObject(); // outbound - w.WriteEndArray(); - - w.WriteEndObject(); - } - - return buffer.ToArray(); - } - - private static void WriteTransportSettings(Utf8JsonWriter w, string network, VlessOptions options) - { - if (network == "tcp") - return; - - w.WriteStartObject(network == "ws" ? "wsSettings" : "httpupgradeSettings"); - w.WriteString("path", string.IsNullOrEmpty(options.Path) ? "/" : options.Path); - - // Xray's own key for the Host header. Same fallback chain the core package uses, so a - // link behaves identically whichever client drives it. - string? host = options.HostHeader ?? options.Sni; - if (!string.IsNullOrEmpty(host)) - w.WriteString("host", host); - - w.WriteEndObject(); - } - - /// Maps a share-link type to Xray's network. - private static string ResolveNetwork(string? transport) => - (transport ?? "tcp").ToLowerInvariant() switch - { - "" or "tcp" or "raw" or "none" => "tcp", - "ws" or "websocket" => "ws", - "httpupgrade" => "httpupgrade", - // grpc and xhttp are things Xray can speak, but their share links carry fields - // (serviceName, mode) that VlessOptions does not model yet. Rendering them from - // the fields we do have would produce a config that connects to the wrong path. - var other => throw new NotSupportedException( - $"Transport '{other}' is not rendered yet; supported: tcp/raw, ws, httpupgrade.") - }; -} diff --git a/QuickProxyNet.Reality/XrayExecutable.cs b/QuickProxyNet.Reality/XrayExecutable.cs deleted file mode 100644 index ba08e24..0000000 --- a/QuickProxyNet.Reality/XrayExecutable.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Runtime.InteropServices; - -namespace QuickProxyNet.Reality; - -/// -/// Finds the Xray-core executable to run. -/// -internal static class XrayExecutable -{ - /// - /// Resolves the binary from the explicit path, then QPN_XRAY_PATH, then PATH. - /// - /// - /// Nothing was found. The message lists every place that was searched — a caller who has to - /// guess where the library looked cannot fix the problem. - /// - public static string Resolve(string? explicitPath) - { - if (!string.IsNullOrEmpty(explicitPath)) - { - if (!File.Exists(explicitPath)) - throw new FileNotFoundException( - $"Xray-core was not found at the configured path '{explicitPath}'.", explicitPath); - - return Path.GetFullPath(explicitPath); - } - - string? fromEnvironment = Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable); - if (!string.IsNullOrEmpty(fromEnvironment)) - { - if (!File.Exists(fromEnvironment)) - throw new FileNotFoundException( - $"{RealityProxyOptions.ExecutablePathVariable} points at '{fromEnvironment}', " + - "which does not exist.", fromEnvironment); - - return Path.GetFullPath(fromEnvironment); - } - - string fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "xray.exe" : "xray"; - string? onPath = SearchPath(fileName); - if (onPath is not null) - return onPath; - - throw new FileNotFoundException( - $"Xray-core was not found. QuickProxyNet.Reality does not ship a binary; supply one via " + - $"{nameof(RealityProxyOptions)}.{nameof(RealityProxyOptions.ExecutablePath)}, the " + - $"{RealityProxyOptions.ExecutablePathVariable} environment variable, or by putting " + - $"'{fileName}' on PATH."); - } - - private static string? SearchPath(string fileName) - { - string? path = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrEmpty(path)) - return null; - - foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - string candidate; - try - { - candidate = Path.Combine(directory.Trim('"'), fileName); - } - catch (ArgumentException) - { - // A malformed PATH entry is not a reason to fail the whole search. - continue; - } - - if (File.Exists(candidate)) - return candidate; - } - - return null; - } -} diff --git a/QuickProxyNet.Tests/HostilePeerTest.cs b/QuickProxyNet.Tests/HostilePeerTest.cs index dff42c1..8de0193 100644 --- a/QuickProxyNet.Tests/HostilePeerTest.cs +++ b/QuickProxyNet.Tests/HostilePeerTest.cs @@ -1,4 +1,4 @@ -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs index c191240..a95a364 100644 --- a/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs +++ b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Sockets; using System.Text; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests.Integration; @@ -66,7 +65,7 @@ public async Task Echo_HandlesLargeRequestsDirectly(int padding) [InlineData(16_000)] public async Task Socks5_HandlesLargeRequests(int padding) { - string executable = Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + string executable = Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; if (string.IsNullOrEmpty(executable)) return; diff --git a/QuickProxyNet.Tests/Integration/LocalRealityServer.cs b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs index 4887b41..6a613b2 100644 --- a/QuickProxyNet.Tests/Integration/LocalRealityServer.cs +++ b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs @@ -32,6 +32,17 @@ namespace QuickProxyNet.Tests.Integration; /// 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"; diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs index 83b26e2..3b4c662 100644 --- a/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs @@ -1,6 +1,5 @@ using System.Net.Sockets; using QuickProxyNet.Reality; -using QuickProxyNet.Reality.Managed; namespace QuickProxyNet.Tests.Integration; @@ -23,7 +22,7 @@ namespace QuickProxyNet.Tests.Integration; /// public class ManagedRealityHandshakeTests { - private static string Executable => Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + 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]; @@ -97,7 +96,7 @@ private static async Task WaitForLogAsync(LocalRealityServer server, strin /// /// The milestone test: a ClientHello built entirely in managed code authenticates to Xray. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task HandBuiltClientHello_AuthenticatesToXray() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); @@ -119,7 +118,7 @@ await WaitForLogAsync(server, "hs.c.conn == conn: true"), /// 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(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task UnknownShortId_IsNotAccepted() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); @@ -135,7 +134,7 @@ await WaitForLogAsync(server, "hs.c.conn == conn: false"), /// /// A hello for an SNI the server does not serve must not even reach the auth path. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task UnknownServerName_IsNotAccepted() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs index a119204..18ff97a 100644 --- a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs @@ -1,7 +1,6 @@ using System.Net.Sockets; using System.Text; using QuickProxyNet.Reality; -using QuickProxyNet.Reality.Managed; namespace QuickProxyNet.Tests.Integration; @@ -21,7 +20,7 @@ namespace QuickProxyNet.Tests.Integration; /// public class ManagedRealityTunnelTests { - private static string Executable => Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + private static string Executable => Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; private static byte[] Base64Url(string value) { @@ -73,7 +72,7 @@ private static async Task GetAsync(Stream tunnel, string path, Cancellat /// /// The end of the road: bytes go out through managed REALITY and the answer comes back. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task ManagedReality_CarriesVlessToATarget() { using LoopbackEchoServer echo = LoopbackEchoServer.Start(); @@ -89,7 +88,7 @@ public async Task ManagedReality_CarriesVlessToATarget() /// A payload larger than one TLS record, to prove records are split and reassembled rather /// than silently truncated at the 16 KiB boundary. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task ManagedReality_CarriesPayloadsAcrossRecordBoundaries() { using LoopbackEchoServer echo = LoopbackEchoServer.Start(); @@ -108,7 +107,7 @@ public async Task ManagedReality_CarriesPayloadsAcrossRecordBoundaries() /// /// Several tunnels over separate connections, to catch state that leaks between handshakes. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task ManagedReality_SupportsSequentialTunnels() { using LoopbackEchoServer echo = LoopbackEchoServer.Start(); @@ -133,7 +132,7 @@ public async Task ManagedReality_SupportsSequentialTunnels() /// asserts against, so the assertion has to be on the exact body, not on "some bytes came /// back". /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task ManagedReality_WithVisionFlow_CarriesVlessToATarget() { using LoopbackEchoServer echo = LoopbackEchoServer.Start(); @@ -150,7 +149,7 @@ public async Task ManagedReality_WithVisionFlow_CarriesVlessToATarget() /// 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(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task ManagedReality_WithVisionFlow_CarriesPayloadsPastTheFramedPrefix() { using LoopbackEchoServer echo = LoopbackEchoServer.Start(); diff --git a/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs index 37abf84..0038e2e 100644 --- a/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs @@ -1,6 +1,5 @@ using System.Net.Sockets; using QuickProxyNet.Reality; -using QuickProxyNet.Reality.Managed; namespace QuickProxyNet.Tests.Integration; @@ -15,7 +14,7 @@ namespace QuickProxyNet.Tests.Integration; /// public class ManagedTlsHandshakeTests { - private static string Executable => Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; + private static string Executable => Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; private static byte[] Base64Url(string value) { @@ -43,7 +42,7 @@ private static async Task ConnectAsync(LocalRealityServer server) /// The milestone: a TLS 1.3 handshake written from scratch, authenticated by REALITY, /// completed against the reference server. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task ManagedHandshake_CompletesAgainstXray() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); @@ -69,7 +68,7 @@ public async Task ManagedHandshake_CompletesAgainstXray() /// that cannot tell the REALITY server from the borrowed site would send the VLESS id to /// whatever answered. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task WrongPublicKey_IsRefusedRatherThanTunnelled() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); @@ -88,7 +87,7 @@ public async Task WrongPublicKey_IsRefusedRatherThanTunnelled() /// /// A short id the server does not know must fail the same way: relayed to the decoy, refused. /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task UnknownShortId_IsRefused() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); @@ -104,7 +103,7 @@ await Assert.ThrowsAsync( /// 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(RealityProxyOptions.ExecutablePathVariable)] + [EnvFact(LocalRealityServer.ExecutablePathVariable)] public async Task RepeatedHandshakes_AllSucceed() { await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable, show: true); diff --git a/QuickProxyNet.Tests/Integration/RealityProxyTests.cs b/QuickProxyNet.Tests/Integration/RealityProxyTests.cs deleted file mode 100644 index 66f584a..0000000 --- a/QuickProxyNet.Tests/Integration/RealityProxyTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System.Text; -using QuickProxyNet.Reality; - -namespace QuickProxyNet.Tests.Integration; - -/// -/// End-to-end tests for against a real Xray-core REALITY server. -/// -/// -/// -/// Everything here runs on loopback: the REALITY server, the decoy TLS endpoint its handshake is -/// relayed to, the tunnel client, and the HTTP target. Nothing leaves the machine, so a failure -/// means the code is wrong rather than that the network was. -/// -/// -/// Gated on QPN_XRAY_PATH because the package deliberately does not ship a binary. Without -/// it these report as skipped, never as passed. -/// -/// -public class RealityProxyTests -{ - private static string Executable => Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; - - private static RealityProxyOptions Options(Action? logSink = null) => new() - { - ExecutablePath = Executable, - StartupTimeout = TimeSpan.FromSeconds(20), - LogLevel = logSink is null ? "warning" : "info", - LogSink = logSink - }; - - /// - /// How long any single tunnel exchange may take before the test gives up. - /// - /// - /// Not optional. A tunnel that fails to authenticate does not necessarily close — Xray may - /// hold the connection open while it relays the handshake elsewhere — so an unbounded read - /// turns a failing test into a hung test run, which is strictly worse than a red one. - /// - private static readonly TimeSpan ExchangeTimeout = TimeSpan.FromSeconds(20); - - /// Issues a GET through the tunnel and returns the whole response. - private static async Task GetThroughAsync(RealityProxy proxy, int targetPort) - { - using var timeout = new CancellationTokenSource(ExchangeTimeout); - - await using Stream tunnel = await proxy.ConnectAsync("127.0.0.1", targetPort, timeout.Token); - - byte[] request = Encoding.ASCII.GetBytes( - $"GET / HTTP/1.1\r\nHost: 127.0.0.1:{targetPort}\r\nConnection: close\r\n\r\n"); - await tunnel.WriteAsync(request, timeout.Token); - await tunnel.FlushAsync(timeout.Token); - - using var reader = new StreamReader(tunnel, Encoding.ASCII); - return await reader.ReadToEndAsync(timeout.Token); - } - - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task Reality_RoundTrip() - { - using LoopbackEchoServer echo = LoopbackEchoServer.Start(); - await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); - await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); - - string response = await GetThroughAsync(proxy, echo.Port); - - Assert.Contains(LoopbackEchoServer.Body, response); - } - - /// - /// XTLS Vision over REALITY — the combination the in-process client cannot do at all. - /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task Reality_Vision_RoundTrip() - { - using LoopbackEchoServer echo = LoopbackEchoServer.Start(); - await using LocalRealityServer server = - await LocalRealityServer.StartAsync(Executable, flow: "xtls-rprx-vision"); - await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); - - string response = await GetThroughAsync(proxy, echo.Port); - - Assert.Contains(LoopbackEchoServer.Body, response); - } - - /// - /// A payload several times larger than a single write, carried through the tunnel. - /// - /// - /// - /// Capped below 16 KiB on purpose. Xray's own SOCKS inbound stops relaying a request larger - /// than roughly one TLS record: 16 000 bytes round-trips, 16 500 hangs. That boundary is not - /// ours — isolates it, showing QuickProxyNet's - /// SOCKS5 client carrying 100 000 bytes through a plain relay and the same request stalling - /// against Xray with no VLESS, TLS or REALITY anywhere in the path. - /// - /// - /// Record-boundary coverage therefore lives with the managed implementation, which has no - /// such intermediary: see ManagedRealityTunnelTests, which carries 40 000 bytes. - /// - /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task Reality_CarriesLargePayload() - { - using LoopbackEchoServer echo = LoopbackEchoServer.Start(); - await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); - await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); - - using var timeout = new CancellationTokenSource(ExchangeTimeout); - await using Stream tunnel = await proxy.ConnectAsync("127.0.0.1", echo.Port, timeout.Token); - - // The echo server replies only after the whole request head has arrived, so a long - // request proves the outbound direction carried all of it. - byte[] request = Encoding.ASCII.GetBytes( - $"GET /{new string('a', 8_000)} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); - await tunnel.WriteAsync(request, timeout.Token); - await tunnel.FlushAsync(timeout.Token); - - using var reader = new StreamReader(tunnel, Encoding.ASCII); - Assert.Contains(LoopbackEchoServer.Body, await reader.ReadToEndAsync(timeout.Token)); - } - - /// - /// The test that proves REALITY authentication is actually happening. - /// - /// - /// A well-formed but wrong public key must fail. If it succeeded, the tunnel would be - /// falling through to an ordinary TLS session against the decoy — which is exactly the - /// silent downgrade the rest of this repo is built to prevent, and it would make every - /// other test in this class prove nothing about REALITY. - /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task Reality_WrongPublicKey_DoesNotTunnel() - { - using LoopbackEchoServer echo = LoopbackEchoServer.Start(); - await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); - - // Valid base64url for 32 bytes, and not the server's key: Xray accepts the configuration - // and fails the handshake, which is the case under test. - string wrongKey = "LmsbBDEPXyy3PS0kYTdC55wlSCqteIEaw6trnKcMUeE"; - string link = server.ShareLink().Replace(LocalRealityServer.PublicKey, wrongKey); - - await using RealityProxy proxy = await RealityProxy.StartAsync(link, Options()); - - // The local SOCKS inbound accepts, then Xray fails to reach the server, so the failure - // surfaces either as a SOCKS error or as the tunnel closing without a response. - string response; - try - { - response = await GetThroughAsync(proxy, echo.Port); - } - catch (Exception ex) when (ex is IOException or ProxyProtocolException or OperationCanceledException) - { - // Refused outright, or the tunnel never carried anything before the timeout. Both are - // the outcome this test wants; only a successful round trip would be wrong. - return; - } - - Assert.DoesNotContain(LoopbackEchoServer.Body, response); - } - - /// - /// The local inbound must never be reachable from off the machine: it has no authentication, - /// so a routable bind would be an open proxy. - /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task LocalInbound_IsLoopbackOnly() - { - await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); - await using RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); - - Assert.Equal("127.0.0.1", proxy.ListenAddress); - Assert.True(proxy.IsRunning); - } - - /// - /// Disposal must stop the process this instance started. - /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task Dispose_StopsTheProcess() - { - await using LocalRealityServer server = await LocalRealityServer.StartAsync(Executable); - RealityProxy proxy = await RealityProxy.StartAsync(server.ShareLink(), Options()); - - Assert.True(proxy.IsRunning); - await proxy.DisposeAsync(); - Assert.False(proxy.IsRunning); - - await Assert.ThrowsAsync( - async () => await proxy.ConnectAsync("127.0.0.1", 80)); - } -} diff --git a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj index 80bb129..506c132 100644 --- a/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj +++ b/QuickProxyNet.Tests/QuickProxyNet.Tests.csproj @@ -24,7 +24,6 @@ - diff --git a/QuickProxyNet.Tests/RealityAuthTest.cs b/QuickProxyNet.Tests/RealityAuthTest.cs index bcff4f0..e9e0e7f 100644 --- a/QuickProxyNet.Tests/RealityAuthTest.cs +++ b/QuickProxyNet.Tests/RealityAuthTest.cs @@ -1,6 +1,6 @@ using System.Buffers.Binary; using System.Security.Cryptography; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/RealityConfigTest.cs b/QuickProxyNet.Tests/RealityConfigTest.cs deleted file mode 100644 index c0b5ebe..0000000 --- a/QuickProxyNet.Tests/RealityConfigTest.cs +++ /dev/null @@ -1,250 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using QuickProxyNet.Reality; - -namespace QuickProxyNet.Tests; - -/// -/// Tests for the Xray client configuration that generates. -/// -/// -/// These are the tests that can be exact. The configuration is a pure function of -/// , so every field can be asserted without a process, a port or a -/// network. What they cannot prove is that Xray accepts the document — that is what -/// and the integration tests are for. -/// -public class RealityConfigTest -{ - private const string Uuid = "6643f196-ae07-420a-b173-d909d20807c1"; - - // Synthetic REALITY keypair, generated with 'xray x25519' for this test suite and committed - // on purpose — same status as the repdigit UUIDs and the self-signed cert under - // tests/docker/certs. It protects nothing and never guarded a real server. - private const string PublicKey = "BhsV4NiigG9rrk98hJnJHPJ7TQ6Iy1WqUykGF0z9I2g"; - private const string ShortId = "ab12"; - - private static JsonElement Build(string shareLink, int port = 21080) - { - byte[] json = XrayClientConfig.Build(VlessShareLink.Parse(shareLink), "127.0.0.1", port, "warning"); - return JsonDocument.Parse(json).RootElement.Clone(); - } - - private static string RealityLink(string extra = "") => - $"vless://{Uuid}@example.com:443?security=reality&pbk={PublicKey}&sid={ShortId}" + - $"&sni=www.cloudflare.com&fp=chrome{extra}#node"; - - // REALITY runs only over raw TCP, so the transport tests use a plain TLS link instead. - private static string TlsLink(string extra = "") => - $"vless://{Uuid}@example.com:443?security=tls&sni=www.cloudflare.com&fp=chrome{extra}#node"; - - private static JsonElement Outbound(JsonElement root) => - root.GetProperty("outbounds")[0]; - - private static JsonElement Stream(JsonElement root) => - Outbound(root).GetProperty("streamSettings"); - - [Fact] - public void Reality_RendersRealitySettings() - { - JsonElement stream = Stream(Build(RealityLink())); - - Assert.Equal("reality", stream.GetProperty("security").GetString()); - Assert.Equal("tcp", stream.GetProperty("network").GetString()); - - JsonElement reality = stream.GetProperty("realitySettings"); - Assert.Equal("www.cloudflare.com", reality.GetProperty("serverName").GetString()); - Assert.Equal("chrome", reality.GetProperty("fingerprint").GetString()); - Assert.Equal(PublicKey, reality.GetProperty("publicKey").GetString()); - Assert.Equal(ShortId, reality.GetProperty("shortId").GetString()); - } - - [Fact] - public void Reality_RendersVnextUser() - { - JsonElement vnext = Outbound(Build(RealityLink())).GetProperty("settings").GetProperty("vnext")[0]; - - Assert.Equal("example.com", vnext.GetProperty("address").GetString()); - Assert.Equal(443, vnext.GetProperty("port").GetInt32()); - - JsonElement user = vnext.GetProperty("users")[0]; - Assert.Equal(Uuid, user.GetProperty("id").GetString()); - Assert.Equal("none", user.GetProperty("encryption").GetString()); - } - - // An empty fingerprint makes Xray skip uTLS and emit Go's own ClientHello — the single most - // recognisable handshake a REALITY client can produce. Defaulting it is a safety behaviour, - // so it gets a test rather than being left to the reader of the source. - [Fact] - public void Reality_WithoutFingerprint_DefaultsToChrome() - { - string link = $"vless://{Uuid}@example.com:443?security=reality&pbk={PublicKey}&sid={ShortId}&sni=a.example#n"; - JsonElement reality = Stream(Build(link)).GetProperty("realitySettings"); - - Assert.Equal("chrome", reality.GetProperty("fingerprint").GetString()); - } - - [Fact] - public void Reality_WithoutSni_FallsBackToHost() - { - string link = $"vless://{Uuid}@example.com:443?security=reality&pbk={PublicKey}&sid={ShortId}#n"; - JsonElement reality = Stream(Build(link)).GetProperty("realitySettings"); - - Assert.Equal("example.com", reality.GetProperty("serverName").GetString()); - } - - [Fact] - public void Vision_FlowIsPassedThrough() - { - JsonElement user = Outbound(Build(RealityLink("&flow=xtls-rprx-vision"))) - .GetProperty("settings").GetProperty("vnext")[0].GetProperty("users")[0]; - - Assert.Equal("xtls-rprx-vision", user.GetProperty("flow").GetString()); - } - - [Fact] - public void NoFlow_OmitsFlowEntirely() - { - JsonElement user = Outbound(Build(RealityLink())) - .GetProperty("settings").GetProperty("vnext")[0].GetProperty("users")[0]; - - // Present-but-empty is not the same as absent: Xray rejects an unknown empty flow. - Assert.False(user.TryGetProperty("flow", out _)); - } - - [Fact] - public void WebSocket_RendersWsSettings() - { - JsonElement stream = Stream(Build(TlsLink("&type=ws&path=%2Fqpn-ws&host=cdn.example"))); - - Assert.Equal("ws", stream.GetProperty("network").GetString()); - JsonElement ws = stream.GetProperty("wsSettings"); - Assert.Equal("/qpn-ws", ws.GetProperty("path").GetString()); - Assert.Equal("cdn.example", ws.GetProperty("host").GetString()); - } - - [Fact] - public void HttpUpgrade_RendersHttpUpgradeSettings() - { - JsonElement stream = Stream(Build(TlsLink("&type=httpupgrade&path=%2Fqpn-hu"))); - - Assert.Equal("httpupgrade", stream.GetProperty("network").GetString()); - Assert.Equal("/qpn-hu", stream.GetProperty("httpupgradeSettings").GetProperty("path").GetString()); - } - - [Fact] - public void Inbound_IsLoopbackSocksWithoutAuth() - { - JsonElement inbound = Build(RealityLink(), port: 31234).GetProperty("inbounds")[0]; - - Assert.Equal("socks", inbound.GetProperty("protocol").GetString()); - Assert.Equal("127.0.0.1", inbound.GetProperty("listen").GetString()); - Assert.Equal(31234, inbound.GetProperty("port").GetInt32()); - Assert.Equal("noauth", inbound.GetProperty("settings").GetProperty("auth").GetString()); - Assert.False(inbound.GetProperty("settings").GetProperty("udp").GetBoolean()); - } - - [Fact] - public void Tls_RendersTlsSettingsInsteadOfReality() - { - string link = $"vless://{Uuid}@example.com:443?security=tls&sni=a.example&flow=xtls-rprx-vision#n"; - JsonElement stream = Stream(Build(link)); - - Assert.Equal("tls", stream.GetProperty("security").GetString()); - Assert.Equal("a.example", stream.GetProperty("tlsSettings").GetProperty("serverName").GetString()); - Assert.False(stream.TryGetProperty("realitySettings", out _)); - } - - // The whole point of the package is that it never quietly does something weaker than the - // link asked for. Each of these would otherwise connect with less protection than intended. - [Fact] - public void PlainVless_IsRefusedAndNamesTheAlternative() - { - var ex = Assert.Throws( - () => Build($"vless://{Uuid}@example.com:443?security=none#n")); - - Assert.Contains("VlessClient", ex.Message); - } - - /// - /// REALITY exists only over raw TCP (plus xhttp and gRPC in Xray). A link combining it with a - /// WebSocket transport describes something no server can serve. - /// - /// - /// Learned the hard way: an earlier version of the builder happily rendered - /// security=reality with type=ws, and Xray refused the document at startup with - /// "REALITY only supports RAW, XHTTP and gRPC for now" — which reached the caller as an opaque - /// launch failure. - /// - [Theory] - [InlineData("ws")] - [InlineData("httpupgrade")] - public void RealityOverAWebTransport_IsRefused(string transport) - { - var ex = Assert.Throws(() => Build(RealityLink($"&type={transport}"))); - - Assert.Contains("raw/tcp", ex.Message); - } - - [Fact] - public void Reality_WithoutPublicKey_IsRefused() - { - var options = new VlessOptions - { - Id = Uuid, - Host = "example.com", - Port = 443, - Security = VlessSecurity.Reality - }; - - var ex = Assert.Throws( - () => XrayClientConfig.Build(options, "127.0.0.1", 21080, "warning")); - - Assert.Contains("public key", ex.Message); - } - - [Fact] - public void UnmodelledTransport_IsRefusedAndListsWhatWorks() - { - var ex = Assert.Throws(() => Build(TlsLink("&type=grpc"))); - - Assert.Contains("httpupgrade", ex.Message); - } - - /// - /// Feeds the generated document to the real Xray binary's own validator. - /// - /// - /// Every other test in this class checks the JSON against my belief about what Xray wants. - /// This one checks it against Xray. It needs no network and no server — run -test - /// parses the configuration and exits — so it is the cheapest test here that can falsify - /// the field names. - /// - [EnvFact(RealityProxyOptions.ExecutablePathVariable)] - public async Task Config_IsAcceptedByXray() - { - byte[] config = XrayClientConfig.Build( - VlessShareLink.Parse(RealityLink("&flow=xtls-rprx-vision")), - "127.0.0.1", 21080, "warning"); - - string executable = Environment.GetEnvironmentVariable(RealityProxyOptions.ExecutablePathVariable)!; - var psi = new ProcessStartInfo(executable) - { - ArgumentList = { "run", "-test", "-c", "stdin:" }, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(psi)!; - await process.StandardInput.BaseStream.WriteAsync(config); - process.StandardInput.Close(); - - string output = await process.StandardOutput.ReadToEndAsync() + - await process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); - - Assert.True(process.ExitCode == 0, $"Xray rejected the generated configuration:\n{output}"); - } -} diff --git a/QuickProxyNet.Tests/TlsKeyScheduleTest.cs b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs index 5caea4e..cde87de 100644 --- a/QuickProxyNet.Tests/TlsKeyScheduleTest.cs +++ b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs @@ -1,5 +1,5 @@ using System.Security.Cryptography; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index 908283c..1701681 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -1,5 +1,5 @@ using System.Security.Cryptography; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/X25519Test.cs b/QuickProxyNet.Tests/X25519Test.cs index 39f4f49..a69b7a2 100644 --- a/QuickProxyNet.Tests/X25519Test.cs +++ b/QuickProxyNet.Tests/X25519Test.cs @@ -1,5 +1,5 @@ using System.Security.Cryptography; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.slnx b/QuickProxyNet.slnx index d1e6e57..ebba7b1 100644 --- a/QuickProxyNet.slnx +++ b/QuickProxyNet.slnx @@ -5,6 +5,5 @@ - diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 4e4c70d..f334d7c 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -1,7 +1,7 @@ using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; -using QuickProxyNet.Reality.Managed; +using QuickProxyNet.Reality; namespace QuickProxyNet; diff --git a/QuickProxyNet/Internal/Reality/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs index 51c4511..3f48a5f 100644 --- a/QuickProxyNet/Internal/Reality/RealityAuth.cs +++ b/QuickProxyNet/Internal/Reality/RealityAuth.cs @@ -1,7 +1,7 @@ using System.Buffers.Binary; using System.Security.Cryptography; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// /// The REALITY authentication primitives: deriving the auth key, sealing it into the TLS diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index bf4cb60..bcba5e1 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -3,7 +3,7 @@ using System.Formats.Asn1; using System.Security.Cryptography; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// Settings for a managed REALITY handshake. internal sealed class RealityTlsOptions diff --git a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs index 7d08432..4527ccf 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// /// The application-data stream of a completed managed REALITY handshake. diff --git a/QuickProxyNet/Internal/Reality/TlsClientHello.cs b/QuickProxyNet/Internal/Reality/TlsClientHello.cs index 75835a5..3c907fd 100644 --- a/QuickProxyNet/Internal/Reality/TlsClientHello.cs +++ b/QuickProxyNet/Internal/Reality/TlsClientHello.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// /// Builds the TLS 1.3 ClientHello that carries REALITY's authentication. diff --git a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs index 32fc1af..add1492 100644 --- a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs +++ b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// /// The TLS 1.3 key schedule (RFC 8446 §7.1) and its traffic-key derivation (§7.3). diff --git a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs index 34dd89c..41d04a6 100644 --- a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs +++ b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs @@ -2,7 +2,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// TLS record content types (RFC 8446 §5.1). internal enum TlsContentType : byte diff --git a/QuickProxyNet/Internal/Reality/TlsWriter.cs b/QuickProxyNet/Internal/Reality/TlsWriter.cs index 15d90a0..b3e392e 100644 --- a/QuickProxyNet/Internal/Reality/TlsWriter.cs +++ b/QuickProxyNet/Internal/Reality/TlsWriter.cs @@ -1,4 +1,4 @@ -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// /// A minimal writer for TLS's length-prefixed wire format. diff --git a/QuickProxyNet/Internal/Reality/X25519.cs b/QuickProxyNet/Internal/Reality/X25519.cs index 0f23471..c71b207 100644 --- a/QuickProxyNet/Internal/Reality/X25519.cs +++ b/QuickProxyNet/Internal/Reality/X25519.cs @@ -2,7 +2,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; -namespace QuickProxyNet.Reality.Managed; +namespace QuickProxyNet.Reality; /// /// X25519 scalar multiplication (RFC 7748), for the key exchange REALITY hides inside the TLS diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index d74854b..7ac5b0b 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -11,7 +11,7 @@ QuickProxyNet Titlehhhh Titlehhhh - QuickProxyNet is a high-performance, zero-dependency .NET library for connecting to servers via HTTP, HTTPS, SOCKS4, SOCKS4a and SOCKS5 proxies, and via the VPN-style protocols VLESS, VMess and Trojan over tcp, ws or httpupgrade. Provides direct Stream access for low-level network operations. VLESS REALITY is available in the QuickProxyNet.Reality package. + 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;vless;vmess;trojan;high-performance Copyright © Titlehhhh 2024 https://github.com/Titlehhhh/QuickProxyNet diff --git a/QuickProxyNet/README.md b/QuickProxyNet/README.md index 6a57b21..51cc745 100644 --- a/QuickProxyNet/README.md +++ b/QuickProxyNet/README.md @@ -4,7 +4,7 @@ High-performance, zero-dependency C# library for connecting through HTTP, HTTPS, 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. -The separate `QuickProxyNet.Reality` package is now only for what a child Xray process still buys: the `grpc`/`xhttp` transports, Vision's TLS-in-TLS splice, and a genuine uTLS fingerprint. +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 diff --git a/README.md b/README.md index 7a8f154..e4efff6 100644 --- a/README.md +++ b/README.md @@ -89,22 +89,60 @@ Also not implemented: Vision's TLS-in-TLS splice. It is a throughput optimizatio ## What's supported, what's not -Percentages are measured by this library's own parsers over a real-world corpus of 20 228 share links (snapshot of 2026-08-21; the list changes daily, so treat these as proportions, not constants). +### 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 in-process | -| VLESS REALITY (incl. `xtls-rprx-vision`) | 46% | Works in-process | -| `grpc` transport | 6.0% | Not supported in-process — use `QuickProxyNet.Reality` | -| Hysteria2 | 2.9% | No client — QUIC/datagram model doesn't fit the library's `Stream` model | -| `xhttp` transport | 2.9% | Not supported in-process — use `QuickProxyNet.Reality` | +| 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`. -### The `QuickProxyNet.Reality` package - -The companion package no longer exists "for REALITY" — that moved into the core. It now covers what the managed stack cannot do yet: the `grpc` and `xhttp` transports, Vision's TLS-in-TLS splice, and a genuine uTLS browser fingerprint, by driving a child Xray process. You bring the Xray binary. +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 diff --git a/docs/README.md b/docs/README.md index 8ad0c81..eaadea7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ Wire-level заметки по протоколам: - [План реализации](implementation-plan.md) — что сделано, что дальше, и замеры покрытия по реальному корпусу ссылок - [Достоверность отпечатка REALITY](reality-fingerprint-plan.md) — побайтовый - разбор ClientHello Chrome 133 и план работ; относится к `QuickProxyNet.Reality` + разбор ClientHello Chrome 133 и план работ; относится к `QuickProxyNet/Internal/Reality/` Наличие документа не означает, что протокол поддержан: там, где поддержки нет, это сказано явно. diff --git a/docs/reality-fingerprint-plan.md b/docs/reality-fingerprint-plan.md index b4c783b..9f941f7 100644 --- a/docs/reality-fingerprint-plan.md +++ b/docs/reality-fingerprint-plan.md @@ -1,6 +1,6 @@ # Making the managed REALITY ClientHello look like Chrome -`QuickProxyNet.Reality/Managed/TlsClientHello.cs` currently emits a valid TLS 1.3 hello that a +`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. From 27dd9689ee3e2b288e2a75c3a0df645cafa4dcb4 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Fri, 21 Aug 2026 22:03:06 +0500 Subject: [PATCH 23/25] fix: make the VPN paths fail the way the rest of the library does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of what a caller actually sees when a VLESS, VMess, Trojan or REALITY connection goes wrong. Most of it was already right; this is the rest. Credentials no longer appear in error text. The "user id is unusable" messages in both clients and both share-link parsers printed the id itself — and a mistyped real UUID lands in exactly that branch, so most of the secret went to whatever logged the exception. They now report the length. The factory's link "summary" was worse: it cut the link at 24 characters to keep credentials out of logs, which after "vless://" leaves 16 characters of UUID and after "trojan://" leaves most of a password. Links are not echoed at all now. REALITY failures are ProxyProtocolExceptions. RealityHandshakeException derived from Exception, so a caller branching on ErrorCode never saw them. It now carries AuthFailed when the peer did not prove it is our server — the decoy certificate, an unbound certificate, a Finished that does not verify — and InvalidResponse otherwise. The record layer's InvalidOperationExceptions on an oversized, truncated or typeless record become the same type: they were the peer's fault but read as ours. A record that fails its tag check is wrapped too, instead of surfacing as AuthenticationTagMismatchException. VMess rejections say so. A VMess server rejects an unknown AuthID by closing without a byte, which surfaced as a bare EndOfStreamException with no protocol name and no hint. It is now ConnectionFailed with the three things to check: the id, a non-zero alterId on the server, and a clock more than about two minutes off. A length block or header sealed under other keys is AuthFailed, matching the verifier mismatch it is the same failure as. Smaller: a WebSocket tunnel breaking after the upgrade is ConnectionFailed, not TransportUpgradeFailed (retrying the upgrade would not help); a malformed or wrong-length pbk is a FormatException naming the value, not "not supported" or an ArgumentException from inside the handshake; Vision truncation messages name the protocol; TrojanClient documents that a wrong password is, by the protocol's design, indistinguishable from a working tunnel from the client's side. Verified against live REALITY nodes through the public API; 509 unit tests pass on net10 and net11. Co-Authored-By: Claude Fable 5 --- QuickProxyNet.Tests/TlsRecordStreamTest.cs | 18 +++++-- QuickProxyNet.Tests/VlessTest.cs | 48 +++++++++++++++++-- QuickProxyNet.Tests/VmessBodyTest.cs | 31 ++++++++---- QuickProxyNet.Tests/VmessClientTest.cs | 2 +- QuickProxyNet/Clients/TrojanClient.cs | 10 ++++ QuickProxyNet/Clients/VlessClient.cs | 17 +++++-- QuickProxyNet/Clients/VmessClient.cs | 2 +- QuickProxyNet/Configs/VlessShareLink.cs | 2 +- QuickProxyNet/Configs/VmessShareLink.cs | 4 +- .../Internal/Reality/RealityTlsClient.cs | 33 +++++++++---- .../Internal/Reality/TlsRecordLayer.cs | 26 +++++++--- .../Internal/Transports/WebSocketStream.cs | 6 ++- QuickProxyNet/Internal/VisionStream.cs | 12 ++--- QuickProxyNet/Internal/Vmess/VmessResponse.cs | 39 +++++++++++++-- QuickProxyNet/ProxyClientFactory.cs | 14 +++--- README.md | 11 ++++- 16 files changed, 214 insertions(+), 61 deletions(-) diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index 1701681..a86246b 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -290,8 +290,11 @@ public async Task OversizedRecord_IsRefused() using var reader = new TlsRecordStream(new MemoryStream(wire)); - await Assert.ThrowsAsync(async () => + // 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. @@ -306,8 +309,11 @@ public async Task RecordShorterThanItsTag_IsRefused() Read = new TlsRecordProtection(suite, Secret(suite)) }; - await Assert.ThrowsAsync(async () => + // 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. @@ -339,8 +345,11 @@ public async Task RecordWithoutAContentType_IsRefused() Read = new TlsRecordProtection(suite, secret) }; - await Assert.ThrowsAsync(async () => + // 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 tampered record does not open. @@ -363,8 +372,9 @@ await writer.WriteAsync( Read = new TlsRecordProtection(suite, secret) }; - await Assert.ThrowsAsync(async () => + 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. diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index 0b52c8e..0abb520 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -1,3 +1,4 @@ +using QuickProxyNet.Reality; using QuickProxyNet.Tests.Helpers; namespace QuickProxyNet.Tests; @@ -436,15 +437,54 @@ public async Task Client_None_DoesNotReadResponseHeaderDuringConnect() Assert.NotEmpty(stream.WrittenBytes); } - [Fact] - public async Task Client_Reality_ThrowsNotSupported() + /// + /// 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=x")); + VlessShareLink.Parse($"vless://{Uuid}@example.com:443?security=reality&pbk={pbk}")); - await Assert.ThrowsAsync( + 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] diff --git a/QuickProxyNet.Tests/VmessBodyTest.cs b/QuickProxyNet.Tests/VmessBodyTest.cs index ee56f7c..5614ebb 100644 --- a/QuickProxyNet.Tests/VmessBodyTest.cs +++ b/QuickProxyNet.Tests/VmessBodyTest.cs @@ -279,9 +279,7 @@ 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 Assert.ThrowsAsync(async () => - await VmessResponse.ReadAsync( - transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + await AssertClosedBeforeResponseAsync(transport); } [Fact] @@ -289,9 +287,24 @@ public async Task ReadResponseHeader_EmptyStream_IsAnErrorNotEof() { var transport = new DuplexTestStream([]); - await Assert.ThrowsAsync(async () => + 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] @@ -300,9 +313,7 @@ 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 Assert.ThrowsAsync(async () => - await VmessResponse.ReadAsync( - transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); + await AssertClosedBeforeResponseAsync(transport); } [Fact] @@ -316,7 +327,9 @@ public async Task ReadResponseHeader_TamperedLengthBlock_FailsAuthentication() await VmessResponse.ReadAsync( transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); - Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + // 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); } @@ -331,7 +344,7 @@ public async Task ReadResponseHeader_TamperedHeader_FailsAuthentication() await VmessResponse.ReadAsync( transport, ResponseBodyKey, ResponseBodyIv, RespV, CancellationToken.None)); - Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); } [Fact] diff --git a/QuickProxyNet.Tests/VmessClientTest.cs b/QuickProxyNet.Tests/VmessClientTest.cs index f2fc012..899efdd 100644 --- a/QuickProxyNet.Tests/VmessClientTest.cs +++ b/QuickProxyNet.Tests/VmessClientTest.cs @@ -982,7 +982,7 @@ public async Task Handshake_TamperedResponseHeader_FailsAuthentication() var ex = await Assert.ThrowsAsync( async () => await body.ReadAsync(new byte[64])); - Assert.Equal(ProxyErrorCode.InvalidResponse, ex.ErrorCode); + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); } [Fact] diff --git a/QuickProxyNet/Clients/TrojanClient.cs b/QuickProxyNet/Clients/TrojanClient.cs index 965c787..8fde059 100644 --- a/QuickProxyNet/Clients/TrojanClient.cs +++ b/QuickProxyNet/Clients/TrojanClient.cs @@ -10,6 +10,16 @@ namespace QuickProxyNet; /// 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; diff --git a/QuickProxyNet/Clients/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index f334d7c..7fd662a 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -34,7 +34,7 @@ public VlessClient(VlessOptions options) Span probe = stackalloc byte[UuidCodec.Size]; if (!UuidCodec.TryWriteBigEndian(options.Id, probe)) throw new ArgumentException( - $"VLESS user id '{options.Id}' is unusable: it is neither a canonical UUID nor " + + $"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)); @@ -153,15 +153,24 @@ private static byte[] DecodeBase64Url(string value) string padded = value.Replace('-', '+').Replace('_', '/'); padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => "" }; + byte[] key; try { - return Convert.FromBase64String(padded); + key = Convert.FromBase64String(padded); } catch (FormatException ex) { - throw new NotSupportedException( - $"The REALITY public key '{value}' is not valid base64url.", 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() diff --git a/QuickProxyNet/Clients/VmessClient.cs b/QuickProxyNet/Clients/VmessClient.cs index 9044e8b..1f9faae 100644 --- a/QuickProxyNet/Clients/VmessClient.cs +++ b/QuickProxyNet/Clients/VmessClient.cs @@ -71,7 +71,7 @@ public VmessClient(VmessOptions options) Span probe = stackalloc byte[UuidCodec.Size]; if (!UuidCodec.TryWriteBigEndian(options.Id, probe)) throw new ArgumentException( - $"VMess user id '{options.Id}' is unusable: it is neither a canonical UUID nor " + + $"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)); diff --git a/QuickProxyNet/Configs/VlessShareLink.cs b/QuickProxyNet/Configs/VlessShareLink.cs index 8713746..b5e5b4b 100644 --- a/QuickProxyNet/Configs/VlessShareLink.cs +++ b/QuickProxyNet/Configs/VlessShareLink.cs @@ -74,7 +74,7 @@ private static bool TryParse( if (!UuidCodec.TryWriteBigEndian(id, probe)) { error = - $"VLESS user id '{id}' is unusable: it is neither a canonical UUID nor a " + + $"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; } diff --git a/QuickProxyNet/Configs/VmessShareLink.cs b/QuickProxyNet/Configs/VmessShareLink.cs index 6335a3e..1769e93 100644 --- a/QuickProxyNet/Configs/VmessShareLink.cs +++ b/QuickProxyNet/Configs/VmessShareLink.cs @@ -181,7 +181,7 @@ private static bool TryParseStandardUri( if (!UuidCodec.TryWriteBigEndian(id, probe)) { error = - $"VMess user id '{id}' is unusable: it is neither a canonical UUID nor a " + + $"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; } @@ -517,7 +517,7 @@ private static bool TryParseJson( if (!UuidCodec.TryWriteBigEndian(id, probe)) { error = - $"VMess user id '{id}' is unusable: it is neither a canonical UUID nor a " + + $"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; } diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index bcba5e1..c42bc87 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -324,7 +324,7 @@ private static void VerifyServerFinished( TlsKeySchedule.FinishedVerifyData(suite.Hash, serverTraffic, transcriptHash, expected); if (body.Length != expected.Length || !CryptographicOperations.FixedTimeEquals(expected, body)) - throw new RealityHandshakeException( + 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."); } @@ -348,13 +348,13 @@ private static byte[] BuildFinished( private static void AssertRealityServer(byte[] certificate, ReadOnlySpan authKey, string serverName) { if (!TryReadEd25519Certificate(certificate, out byte[]? publicKey, out byte[]? signature)) - throw new RealityHandshakeException( + 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( + 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."); } @@ -700,18 +700,35 @@ private static string DescribeAlert(ReadOnlySpan payload) } /// Raised when a managed REALITY handshake cannot be completed. -public sealed class RealityHandshakeException : Exception +/// +/// 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. + /// 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(string message) : base(message) + public RealityHandshakeException(ProxyErrorCode errorCode, string message) : base(errorCode, message) { } - /// Creates the exception. + /// Creates the exception with . /// What went wrong. /// The underlying failure. - public RealityHandshakeException(string message, Exception innerException) : base(message, innerException) + public RealityHandshakeException(string message, Exception innerException) + : base(ProxyErrorCode.InvalidResponse, message, innerException) { } } diff --git a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs index 41d04a6..ddbd820 100644 --- a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs +++ b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs @@ -143,10 +143,21 @@ public void Unprotect( Span nonce = stackalloc byte[TlsCipherSuite.NonceLength]; TlsKeySchedule.BuildNonce(nonce, _iv, _sequenceNumber++); - if (_chaCha is not null) - _chaCha.Decrypt(nonce, ciphertext, tag, plaintext, header); - else - _aes!.Decrypt(nonce, ciphertext, tag, plaintext, header); + 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() @@ -313,7 +324,8 @@ record = default; // 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 InvalidOperationException($"The peer sent a {length}-byte record, over the {MaxCiphertext} limit."); + 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; @@ -332,7 +344,7 @@ record = default; } if (length < TlsCipherSuite.TagLength) - throw new InvalidOperationException("The peer sent an encrypted record shorter than its own tag."); + throw new RealityHandshakeException("The peer sent an encrypted TLS record shorter than its own tag."); int contentLength = length - TlsCipherSuite.TagLength; @@ -349,7 +361,7 @@ record = default; int end = _plaintext.AsSpan(0, contentLength).LastIndexOfAnyExcept((byte)0) + 1; if (end == 0) - throw new InvalidOperationException("The peer sent a record with no content type."); + 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; diff --git a/QuickProxyNet/Internal/Transports/WebSocketStream.cs b/QuickProxyNet/Internal/Transports/WebSocketStream.cs index 2f8233e..fc05c2c 100644 --- a/QuickProxyNet/Internal/Transports/WebSocketStream.cs +++ b/QuickProxyNet/Internal/Transports/WebSocketStream.cs @@ -72,7 +72,9 @@ public override async ValueTask ReadAsync( } catch (WebSocketException ex) { - throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + // 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); } @@ -102,7 +104,7 @@ await _webSocket } catch (WebSocketException ex) { - throw new ProxyProtocolException(ProxyErrorCode.TransportUpgradeFailed, + throw new ProxyProtocolException(ProxyErrorCode.ConnectionFailed, $"The WebSocket transport failed while writing: {ex.Message}", ex); } } diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs index 442b238..82cf0d2 100644 --- a/QuickProxyNet/Internal/VisionStream.cs +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -159,14 +159,14 @@ public override async ValueTask ReadAsync( return 0; // a clean close on a frame boundary is the end of the stream if (Buffered < HeaderSize) - throw new EndOfStreamException("The peer closed the connection inside a Vision frame header."); + 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 peer closed the connection inside a Vision frame."); + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame, mid-response."); if (_remainingContent > 0) { @@ -220,14 +220,14 @@ public override int Read(Span buffer) return 0; if (Buffered < HeaderSize) - throw new EndOfStreamException("The peer closed the connection inside a Vision frame header."); + 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 peer closed the connection inside a Vision frame."); + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame, mid-response."); if (_remainingContent > 0) { @@ -297,7 +297,7 @@ private async ValueTask FillAsync(int count, bool throwOnEof, CancellationToken if (read == 0) { if (throwOnEof) - throw new EndOfStreamException("The peer closed the connection inside a Vision frame header."); + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame header, mid-response."); return; } @@ -315,7 +315,7 @@ private void Fill(int count, bool throwOnEof) if (read == 0) { if (throwOnEof) - throw new EndOfStreamException("The peer closed the connection inside a Vision frame header."); + throw new EndOfStreamException("The VLESS server closed the connection inside an xtls-rprx-vision frame header, mid-response."); return; } diff --git a/QuickProxyNet/Internal/Vmess/VmessResponse.cs b/QuickProxyNet/Internal/Vmess/VmessResponse.cs index 970d5b3..b36da78 100644 --- a/QuickProxyNet/Internal/Vmess/VmessResponse.cs +++ b/QuickProxyNet/Internal/Vmess/VmessResponse.cs @@ -163,6 +163,10 @@ public static async ValueTask ReadAsync( 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); @@ -179,6 +183,10 @@ public static async ValueTask ReadAsync( 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); @@ -216,13 +224,34 @@ private static int OpenLength(byte[] material, byte[] lengthBlock) } catch (CryptographicException ex) { - throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, - "VMess response header length block failed authentication.", 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) { @@ -239,8 +268,10 @@ private static VmessResponseHeader OpenHeader( } catch (CryptographicException ex) { - throw new ProxyProtocolException(ProxyErrorCode.InvalidResponse, - "VMess response header failed authentication.", 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) diff --git a/QuickProxyNet/ProxyClientFactory.cs b/QuickProxyNet/ProxyClientFactory.cs index 63b4658..cdb5eba 100644 --- a/QuickProxyNet/ProxyClientFactory.cs +++ b/QuickProxyNet/ProxyClientFactory.cs @@ -42,7 +42,8 @@ public IProxyClient Create(string link) int separator = trimmed.IndexOf("://", StringComparison.Ordinal); if (separator <= 0) throw new ArgumentException( - $"'{Summarize(trimmed)}' is not a proxy link: expected a scheme followed by '://'.", nameof(link)); + $"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); @@ -64,7 +65,8 @@ public IProxyClient Create(string link) scheme.Equals("socks5", StringComparison.OrdinalIgnoreCase)) { if (!Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)) - throw new FormatException($"'{Summarize(trimmed)}' is not a well-formed {scheme} URI."); + throw new FormatException( + $"The {scheme} proxy link is not a well-formed URI (expected '{scheme}://[user:password@]host:port')."); return Create(uri); } @@ -74,10 +76,6 @@ public IProxyClient Create(string link) "socks4a, socks5, vless, trojan and vmess."); } - /// Shortens a link for an error message, so a credential does not end up in a log. - private static string Summarize(string link) => - link.Length <= 24 ? link : string.Concat(link.AsSpan(0, 24), "…"); - /// /// Creates an IProxyClient instance based on the provided URI, automatically determining the proxy type /// and extracting credentials if they are present in the URI. @@ -117,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/README.md b/README.md index e4efff6..a4614b9 100644 --- a/README.md +++ b/README.md @@ -168,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 @@ -179,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 | From 661d9e9a243b6d80126ed7ef2a49e39c599c95fd Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Sat, 22 Aug 2026 18:06:09 +0500 Subject: [PATCH 24/25] =?UTF-8?q?chore:=20release=20prep=20for=204.0.0=20?= =?UTF-8?q?=E2=80=94=20five=20review=20passes,=20their=20fixes,=20and=20th?= =?UTF-8?q?e=20loopback=20tests=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five independent reviews ran over origin/master..HEAD before tagging: public API diff, correctness, hostile-peer robustness, release plumbing, and test coverage. This is everything they found that was worth acting on. Two loopback test suites had been failing on every machine with Xray 26.x, and not because of the client: freedom grew a default "finalRules" policy that blackholes private targets for traffic arriving through a vless inbound — the REALITY handshake and the VLESS request both succeed, then nothing ever comes back. The echo target in these tests is on loopback. One explicit allow rule in LocalRealityServer (and the docker config, which has the same default and a private target) and all 23 Xray-backed tests pass again. GetAsync now prints Xray's log on a timeout, because "the operation was canceled" after 30 seconds is what made this take so long to find. Public API, before it is frozen: ProxyType.Hysteria2 and .Tuic are gone — no client returned them, and removing them after 4.0.0 would be breaking; the REALITY types are back in the flat QuickProxyNet namespace, as AGENTS.md rule 1 requires; the stale XML-doc on VlessSecurity.Reality ("not yet supported") is rewritten. Correctness: buffers now go back to the pool only after the transport is closed, in VisionStream, VmessStream and RealityTlsStream — returned first, a read still in flight on another thread could complete into an array the pool had already handed to someone else. VmessStream's dispose also survives a ProxyProtocolException from a dead WebSocket transport. A low-order X25519 key_share from the server is a RealityHandshakeException, not a CryptographicException. An IPv6 host that already carries brackets is not bracketed again. And a proxy that resets the connection mid-handshake now surfaces as ProxyProtocolException(ConnectionFailed) rather than a raw IOException — a gap in the "every protocol error is a ProxyProtocolException" promise that the new timeout test happened to expose. Not taken: the suggestion to treat a VMess transport close without the terminator chunk as a clean EOF, as Xray does. End of stream is in band by design here, and a FIN in its place is what truncation looks like; the documented contract stays. Tests: the public path through REALITY end to end (factory and Proxy.ConnectAsync, with and without Vision), which nothing exercised before; a wrong pbk through that path is AuthFailed; every hostile-peer refusal is InvalidResponse; VisionStream's sync Read/Write — separate code from the async path — now runs the same scenarios; RFC 7748 §5.2 iterated X25519 vectors (1 and 1000 rounds); Proxy.ConnectAsync(string) with a silent proxy yields the Timeout code; a malformed link never echoes its credential from any exception in the chain. EnvTheory replaces the one [Theory] that returned early when Xray was absent and so reported as passed. Package: MinVerMinimumMajorMinor 4.0, PackageReleaseNotes, wider tags, README.md case fixed for case-sensitive file systems, copyright years. Sample shows the string entry point instead of the Uri factory. AGENTS.md, docs/implementation-plan.md and docs/vless.md no longer describe the deleted QuickProxyNet.Reality package or call the managed stack unreachable. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 54 +++++---- .../RealityHandshakeBenchmark.cs | 1 - .../RealityRecordBenchmark.cs | 1 - .../RealityTlsSocketBenchmark.cs | 1 - .../RealityTlsStreamBenchmark.cs | 1 - QuickProxyNet.Tests/HostilePeerTest.cs | 8 +- .../LargeRequestDiagnosticTests.cs | 4 +- .../Integration/LocalRealityServer.cs | 18 ++- .../ManagedRealityHandshakeTests.cs | 1 - .../Integration/ManagedRealityTunnelTests.cs | 86 +++++++++++++-- .../Integration/ManagedTlsHandshakeTests.cs | 6 +- QuickProxyNet.Tests/ProxyClientFactoryTest.cs | 51 +++++++++ QuickProxyNet.Tests/RealityAuthTest.cs | 1 - QuickProxyNet.Tests/SkipGates.cs | 14 +++ QuickProxyNet.Tests/TlsKeyScheduleTest.cs | 1 - QuickProxyNet.Tests/TlsRecordStreamTest.cs | 1 - QuickProxyNet.Tests/VisionTest.cs | 67 +++++++---- QuickProxyNet.Tests/VlessTest.cs | 1 - QuickProxyNet.Tests/X25519Test.cs | 28 ++++- QuickProxyNet/Clients/ProxyClient.cs | 17 ++- QuickProxyNet/Clients/VlessClient.cs | 1 - QuickProxyNet/Configs/VlessOptions.cs | 6 +- QuickProxyNet/Internal/Reality/RealityAuth.cs | 2 +- .../Internal/Reality/RealityTlsClient.cs | 14 ++- .../Internal/Reality/RealityTlsStream.cs | 11 +- .../Internal/Reality/TlsClientHello.cs | 2 +- .../Internal/Reality/TlsKeySchedule.cs | 2 +- .../Internal/Reality/TlsRecordLayer.cs | 2 +- QuickProxyNet/Internal/Reality/TlsWriter.cs | 2 +- QuickProxyNet/Internal/Reality/X25519.cs | 2 +- QuickProxyNet/Internal/VisionStream.cs | 16 ++- QuickProxyNet/Internal/Vmess/VmessStream.cs | 21 +++- QuickProxyNet/ProxyType.cs | 4 +- QuickProxyNet/QuickProxyNet.csproj | 13 ++- Sample/Program.cs | 104 +++++------------- docs/implementation-plan.md | 59 +++++----- docs/vless.md | 20 ++-- tests/docker/README.md | 2 +- tests/docker/xray/config.json | 8 +- 39 files changed, 431 insertions(+), 222 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29b5992..1c5bf26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,11 +145,15 @@ independent: a REALITY server whose `dest` points at a decoy TLS inbound in the same process, so nothing leaves the machine. -**Open question, deliberately left open:** everything under `Managed/` is `internal`. -The headline capability — REALITY with no external binary — is therefore unreachable -by anyone consuming the package. Deciding its public shape (a `RealityClient : -IProxyClient`? folded into `VlessClient`? a third package?) is unfinished work, not an -oversight. +**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 @@ -370,7 +374,7 @@ docker compose -p quickproxynet-test -f tests/docker/docker-compose.yml down -v ## Diagnostics -`tools/CorpusCheck` runs the share-link parsers over ~17k real-world links +`tools/CorpusCheck` runs the share-link parsers over ~20k real-world links (PypsCFG `merged_all.txt`) and groups failures by reason: ```bash @@ -400,7 +404,7 @@ derived from git tags through MinVer. - 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 three TFMs) and `dotnet test`. Do not trust a report. + 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. @@ -420,20 +424,22 @@ socket") that has to be solved before they can be. ## What Is Worth Implementing Next -Measured with `tools/CorpusCheck` over 21 403 real links (2026-08-14), counting what -can actually **connect**, not what parses. See `docs/implementation-plan.md` §7 for -the full table. - -| Blocker | Links | % of corpus | -| --- | ---: | ---: | -| REALITY (needs uTLS — `SslStream` cannot do it) | 10 653 | 49.8% | -| gRPC | 991 | 4.6% | -| xhttp (Xray-only) | 789 | 3.7% | -| Hysteria2 / TUIC (QUIC) | 472 | 2.2% | - -The point of that table: **QUIC is the worst remaining investment**, not the next -phase. It is the heaviest architectural work in the roadmap — it breaks the "one -`ConnectAsync`, one socket" model — and buys 2.2%. The old roadmap listed it as -phase 4 purely because it was next in the document, which is not a reason. REALITY -is half the corpus and is gated on a uTLS ClientHello, so it is a separate project -rather than a feature. +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/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs index 16e366a..904bdcb 100644 --- a/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityHandshakeBenchmark.cs @@ -4,7 +4,6 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs index 1cc09dc..a87e9c1 100644 --- a/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityRecordBenchmark.cs @@ -9,7 +9,6 @@ using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs index 947dbdc..d3f5c06 100644 --- a/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityTlsSocketBenchmark.cs @@ -9,7 +9,6 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs index b37629d..edf91e2 100644 --- a/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs +++ b/QuickProxyNet.Benchmarks/RealityTlsStreamBenchmark.cs @@ -8,7 +8,6 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Toolchains.InProcess.NoEmit; -using QuickProxyNet.Reality; namespace QuickProxyNet.Benchmarks; diff --git a/QuickProxyNet.Tests/HostilePeerTest.cs b/QuickProxyNet.Tests/HostilePeerTest.cs index 8de0193..61236c8 100644 --- a/QuickProxyNet.Tests/HostilePeerTest.cs +++ b/QuickProxyNet.Tests/HostilePeerTest.cs @@ -1,4 +1,3 @@ -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; @@ -108,8 +107,13 @@ private static async Task ExpectRefusalAsync(Func( + 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; } /// diff --git a/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs index a95a364..4a6ca00 100644 --- a/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs +++ b/QuickProxyNet.Tests/Integration/LargeRequestDiagnosticTests.cs @@ -60,14 +60,12 @@ public async Task Echo_HandlesLargeRequestsDirectly(int padding) /// ceiling so the test measures the proxy working rather than the ceiling. /// /// - [Theory] + [EnvTheory(LocalRealityServer.ExecutablePathVariable)] [InlineData(1_000)] [InlineData(16_000)] public async Task Socks5_HandlesLargeRequests(int padding) { string executable = Environment.GetEnvironmentVariable(LocalRealityServer.ExecutablePathVariable)!; - if (string.IsNullOrEmpty(executable)) - return; using LoopbackEchoServer echo = LoopbackEchoServer.Start(); diff --git a/QuickProxyNet.Tests/Integration/LocalRealityServer.cs b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs index 6a613b2..1b027c9 100644 --- a/QuickProxyNet.Tests/Integration/LocalRealityServer.cs +++ b/QuickProxyNet.Tests/Integration/LocalRealityServer.cs @@ -143,7 +143,10 @@ void Collect(object? _, DataReceivedEventArgs e) lock (log) { log.Add(e.Data); - if (log.Count > 40) + // 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); } } @@ -249,6 +252,19 @@ private static byte[] BuildConfig( 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(); diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs index 3b4c662..a0e4694 100644 --- a/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedRealityHandshakeTests.cs @@ -1,5 +1,4 @@ using System.Net.Sockets; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests.Integration; diff --git a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs index 18ff97a..3be5afd 100644 --- a/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedRealityTunnelTests.cs @@ -1,6 +1,5 @@ using System.Net.Sockets; using System.Text; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests.Integration; @@ -58,15 +57,84 @@ private static async Task OpenTunnelAsync( return await VlessHelper.EstablishVlessTunnelAsync(tls, vless, "127.0.0.1", targetPort, cancellationToken); } - private static async Task GetAsync(Stream tunnel, string path, CancellationToken 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); - using var reader = new StreamReader(tunnel, Encoding.ASCII); - return await reader.ReadToEndAsync(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); } /// @@ -81,7 +149,7 @@ public async Task ManagedReality_CarriesVlessToATarget() await using Stream tunnel = await OpenTunnelAsync(server, echo.Port, timeout.Token); - Assert.Contains(LoopbackEchoServer.Body, await GetAsync(tunnel, "/", timeout.Token)); + Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); } /// @@ -99,7 +167,7 @@ public async Task ManagedReality_CarriesPayloadsAcrossRecordBoundaries() // Comfortably past TlsRecordStream.MaxPlaintext, so the write path has to emit several // records and the server has to reassemble them. - string response = await GetAsync(tunnel, "/" + new string('a', 40_000), timeout.Token); + string response = await GetAsync(server, tunnel, "/" + new string('a', 40_000), timeout.Token); Assert.Contains(LoopbackEchoServer.Body, response); } @@ -118,7 +186,7 @@ public async Task ManagedReality_SupportsSequentialTunnels() 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(tunnel, "/", timeout.Token)); + Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); } } @@ -142,7 +210,7 @@ public async Task ManagedReality_WithVisionFlow_CarriesVlessToATarget() await using Stream tunnel = await OpenTunnelAsync(server, echo.Port, timeout.Token, VisionStream.FlowName); - Assert.Contains(LoopbackEchoServer.Body, await GetAsync(tunnel, "/", timeout.Token)); + Assert.Contains(LoopbackEchoServer.Body, await GetAsync(server, tunnel, "/", timeout.Token)); } /// @@ -159,7 +227,7 @@ public async Task ManagedReality_WithVisionFlow_CarriesPayloadsPastTheFramedPref await using Stream tunnel = await OpenTunnelAsync(server, echo.Port, timeout.Token, VisionStream.FlowName); - string response = await GetAsync(tunnel, "/" + new string('a', 40_000), timeout.Token); + 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 index 0038e2e..6dba352 100644 --- a/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs +++ b/QuickProxyNet.Tests/Integration/ManagedTlsHandshakeTests.cs @@ -1,5 +1,4 @@ using System.Net.Sockets; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests.Integration; @@ -82,6 +81,7 @@ public async Task WrongPublicKey_IsRefusedRatherThanTunnelled() async () => await RealityTlsClient.HandshakeAsync(tcp.GetStream(), Options(wrongKey), timeout.Token)); Assert.Contains("REALITY", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); } /// @@ -94,9 +94,11 @@ public async Task UnknownShortId_IsRefused() using TcpClient tcp = await ConnectAsync(server); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( async () => await RealityTlsClient.HandshakeAsync( tcp.GetStream(), Options(shortId: "cdef"), timeout.Token)); + + Assert.Equal(ProxyErrorCode.AuthFailed, ex.ErrorCode); } /// diff --git a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs index 9c1572c..0fae083 100644 --- a/QuickProxyNet.Tests/ProxyClientFactoryTest.cs +++ b/QuickProxyNet.Tests/ProxyClientFactoryTest.cs @@ -160,6 +160,57 @@ await Proxy.ConnectAsync( 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() { diff --git a/QuickProxyNet.Tests/RealityAuthTest.cs b/QuickProxyNet.Tests/RealityAuthTest.cs index e9e0e7f..655f235 100644 --- a/QuickProxyNet.Tests/RealityAuthTest.cs +++ b/QuickProxyNet.Tests/RealityAuthTest.cs @@ -1,6 +1,5 @@ using System.Buffers.Binary; using System.Security.Cryptography; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/SkipGates.cs b/QuickProxyNet.Tests/SkipGates.cs index 0d7eb98..675b43e 100644 --- a/QuickProxyNet.Tests/SkipGates.cs +++ b/QuickProxyNet.Tests/SkipGates.cs @@ -82,6 +82,20 @@ 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. diff --git a/QuickProxyNet.Tests/TlsKeyScheduleTest.cs b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs index cde87de..87e22cd 100644 --- a/QuickProxyNet.Tests/TlsKeyScheduleTest.cs +++ b/QuickProxyNet.Tests/TlsKeyScheduleTest.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index a86246b..7f463cd 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/VisionTest.cs b/QuickProxyNet.Tests/VisionTest.cs index e41098b..9f5a057 100644 --- a/QuickProxyNet.Tests/VisionTest.cs +++ b/QuickProxyNet.Tests/VisionTest.cs @@ -48,13 +48,18 @@ private static VisionStream Wrap(byte[] serverBytes, out MemoryStream sent) return new VisionStream(duplex, UuidBigEndian); } - private static async Task ReadAllAsync(Stream stream, int chunk = 4096) + /// + /// 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 = await stream.ReadAsync(buffer); + int n = sync ? stream.Read(buffer.AsSpan()) : await stream.ReadAsync(buffer); if (n == 0) break; @@ -78,8 +83,10 @@ public async Task Read_StripsASingleClosingFrame() Assert.Equal(expected, await ReadAllAsync(stream)); } - [Fact] - public async Task Read_StripsSeveralFrames_AndOnlyTheFirstCarriesTheUuid() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_StripsSeveralFrames_AndOnlyTheFirstCarriesTheUuid(bool sync) { byte[] wire = [ @@ -91,7 +98,7 @@ .. Frame(PaddingEnd, "three"u8, padding: 17, withUuid: false), await using VisionStream stream = Wrap(wire, out _); - Assert.Equal("onetwothreeraw", Encoding.ASCII.GetString(await ReadAllAsync(stream))); + Assert.Equal("onetwothreeraw", Encoding.ASCII.GetString(await ReadAllAsync(stream, sync: sync))); } /// @@ -99,8 +106,10 @@ .. Frame(PaddingEnd, "three"u8, padding: 17, withUuid: false), /// can arrive one byte at a time. This is the case that a naive implementation passes in /// testing and fails against a real server. /// - [Fact] - public async Task Read_SurvivesFramesSplitAcrossEveryByteBoundary() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_SurvivesFramesSplitAcrossEveryByteBoundary(bool sync) { byte[] wire = [ @@ -112,7 +121,7 @@ .. Frame(PaddingEnd, "world"u8, padding: 3, withUuid: false), 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))); + Assert.Equal("hello world!", Encoding.ASCII.GetString(await ReadAllAsync(stream, chunk: 3, sync: sync))); } [Fact] @@ -139,8 +148,10 @@ public async Task Read_ShortStreamIsPayload() /// 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. /// - [Fact] - public async Task Read_DirectCommandEndsTheFraming() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_DirectCommandEndsTheFraming(bool sync) { const byte PaddingDirect = 0x02; byte[] wire = @@ -151,7 +162,7 @@ .. 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))); + Assert.Equal("framedeverything after is raw", Encoding.ASCII.GetString(await ReadAllAsync(stream, sync: sync))); } /// @@ -159,18 +170,22 @@ .. Encoding.ASCII.GetBytes("everything after is raw") /// stream, not corrupted it. The distinction is the difference between a clean EOF and an /// exception on every completed download. /// - [Fact] - public async Task Read_CloseOnAFrameBoundaryIsACleanEnd() + [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))); + Assert.Equal("all there is", Encoding.ASCII.GetString(await ReadAllAsync(stream, sync: sync))); } - [Fact] - public async Task Read_TruncatedFrameThrows() + [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); @@ -178,18 +193,28 @@ public async Task Read_TruncatedFrameThrows() await using VisionStream stream = Wrap(wire, out _); - await Assert.ThrowsAsync(async () => await ReadAllAsync(stream)); + await Assert.ThrowsAsync(async () => await ReadAllAsync(stream, sync: sync)); } // === writing === - [Fact] - public async Task Write_PadsTheFirstWriteAndThenRunsRaw() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Write_PadsTheFirstWriteAndThenRunsRaw(bool sync) { await using VisionStream stream = Wrap([], out MemoryStream sent); - await stream.WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray()); - await stream.WriteAsync("second"u8.ToArray()); + 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]); diff --git a/QuickProxyNet.Tests/VlessTest.cs b/QuickProxyNet.Tests/VlessTest.cs index 0abb520..bad29ca 100644 --- a/QuickProxyNet.Tests/VlessTest.cs +++ b/QuickProxyNet.Tests/VlessTest.cs @@ -1,4 +1,3 @@ -using QuickProxyNet.Reality; using QuickProxyNet.Tests.Helpers; namespace QuickProxyNet.Tests; diff --git a/QuickProxyNet.Tests/X25519Test.cs b/QuickProxyNet.Tests/X25519Test.cs index a69b7a2..b16f544 100644 --- a/QuickProxyNet.Tests/X25519Test.cs +++ b/QuickProxyNet.Tests/X25519Test.cs @@ -1,5 +1,4 @@ using System.Security.Cryptography; -using QuickProxyNet.Reality; namespace QuickProxyNet.Tests; @@ -48,6 +47,33 @@ public void Rfc7748_ScalarMultiplication(string scalar, string u, string expecte 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() diff --git a/QuickProxyNet/Clients/ProxyClient.cs b/QuickProxyNet/Clients/ProxyClient.cs index 491e1e5..a9ef0ab 100644 --- a/QuickProxyNet/Clients/ProxyClient.cs +++ b/QuickProxyNet/Clients/ProxyClient.cs @@ -67,8 +67,10 @@ protected ProxyClient(string protocol, string host, int port, NetworkCredential // 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}]" : host; + host.Contains(':') && !host.StartsWith('[') ? $"[{host}]" : host; public Uri ProxyUri { get; private set; } public abstract ProxyType Type { get; } @@ -126,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(); @@ -177,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/VlessClient.cs b/QuickProxyNet/Clients/VlessClient.cs index 7fd662a..e7509c7 100644 --- a/QuickProxyNet/Clients/VlessClient.cs +++ b/QuickProxyNet/Clients/VlessClient.cs @@ -1,7 +1,6 @@ using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; -using QuickProxyNet.Reality; namespace QuickProxyNet; diff --git a/QuickProxyNet/Configs/VlessOptions.cs b/QuickProxyNet/Configs/VlessOptions.cs index a0394d5..e018b43 100644 --- a/QuickProxyNet/Configs/VlessOptions.cs +++ b/QuickProxyNet/Configs/VlessOptions.cs @@ -12,9 +12,9 @@ public enum VlessSecurity Tls, /// - /// REALITY transport security (security=reality). Parsed for completeness but - /// not yet supported at connect time — it requires a browser-like uTLS ClientHello - /// fingerprint that cannot produce. + /// 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 } diff --git a/QuickProxyNet/Internal/Reality/RealityAuth.cs b/QuickProxyNet/Internal/Reality/RealityAuth.cs index 3f48a5f..a56a3aa 100644 --- a/QuickProxyNet/Internal/Reality/RealityAuth.cs +++ b/QuickProxyNet/Internal/Reality/RealityAuth.cs @@ -1,7 +1,7 @@ using System.Buffers.Binary; using System.Security.Cryptography; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// /// The REALITY authentication primitives: deriving the auth key, sealing it into the TLS diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index c42bc87..018a021 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -3,7 +3,7 @@ using System.Formats.Asn1; using System.Security.Cryptography; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// Settings for a managed REALITY handshake. internal sealed class RealityTlsOptions @@ -168,7 +168,17 @@ public static async ValueTask HandshakeAsync( try { - X25519.Agree(secrets.Shared, hello.PrivateKey, parsed.KeyShare); + 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(), diff --git a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs index 4527ccf..7dbf233 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -1,6 +1,6 @@ using System.Runtime.CompilerServices; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// /// The application-data stream of a completed managed REALITY handshake. @@ -226,8 +226,8 @@ protected override void Dispose(bool disposing) if (disposing) { - _records.Dispose(); _transport.Dispose(); + _records.Dispose(); } base.Dispose(disposing); @@ -240,8 +240,13 @@ public override async ValueTask DisposeAsync() _disposed = true; _pending = ReadOnlyMemory.Empty; - _records.Dispose(); + + // 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 index 3c907fd..c011cf0 100644 --- a/QuickProxyNet/Internal/Reality/TlsClientHello.cs +++ b/QuickProxyNet/Internal/Reality/TlsClientHello.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// /// Builds the TLS 1.3 ClientHello that carries REALITY's authentication. diff --git a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs index add1492..b2fff0a 100644 --- a/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs +++ b/QuickProxyNet/Internal/Reality/TlsKeySchedule.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using System.Text; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// /// The TLS 1.3 key schedule (RFC 8446 §7.1) and its traffic-key derivation (§7.3). diff --git a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs index ddbd820..caf92c8 100644 --- a/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs +++ b/QuickProxyNet/Internal/Reality/TlsRecordLayer.cs @@ -2,7 +2,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// TLS record content types (RFC 8446 §5.1). internal enum TlsContentType : byte diff --git a/QuickProxyNet/Internal/Reality/TlsWriter.cs b/QuickProxyNet/Internal/Reality/TlsWriter.cs index b3e392e..fd8c886 100644 --- a/QuickProxyNet/Internal/Reality/TlsWriter.cs +++ b/QuickProxyNet/Internal/Reality/TlsWriter.cs @@ -1,4 +1,4 @@ -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// /// A minimal writer for TLS's length-prefixed wire format. diff --git a/QuickProxyNet/Internal/Reality/X25519.cs b/QuickProxyNet/Internal/Reality/X25519.cs index c71b207..b894d41 100644 --- a/QuickProxyNet/Internal/Reality/X25519.cs +++ b/QuickProxyNet/Internal/Reality/X25519.cs @@ -2,7 +2,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; -namespace QuickProxyNet.Reality; +namespace QuickProxyNet; /// /// X25519 scalar multiplication (RFC 7748), for the key exchange REALITY hides inside the TLS diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs index 82cf0d2..ee1ea1f 100644 --- a/QuickProxyNet/Internal/VisionStream.cs +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -378,7 +378,7 @@ public override async ValueTask WriteAsync( } finally { - ArrayPool.Shared.Return(frame); + ArrayPool.Shared.Return(frame, clearArray: true); // the caller's first packet } return; @@ -408,7 +408,7 @@ public override void Write(ReadOnlySpan buffer) } finally { - ArrayPool.Shared.Return(frame); + ArrayPool.Shared.Return(frame, clearArray: true); // the caller's first packet } return; @@ -474,10 +474,13 @@ public override async ValueTask DisposeAsync() return; _disposed = true; - ReturnBuffer(); + // 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); } @@ -487,10 +490,9 @@ protected override void Dispose(bool disposing) { if (!_disposed && disposing) { - ReturnBuffer(); - if (!_leaveInnerOpen) _inner.Dispose(); + ReturnBuffer(); } _disposed = true; @@ -502,7 +504,9 @@ 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); + ArrayPool.Shared.Return(buffer, clearArray: true); } } diff --git a/QuickProxyNet/Internal/Vmess/VmessStream.cs b/QuickProxyNet/Internal/Vmess/VmessStream.cs index dc26d6f..a6c89c4 100644 --- a/QuickProxyNet/Internal/Vmess/VmessStream.cs +++ b/QuickProxyNet/Internal/Vmess/VmessStream.cs @@ -243,7 +243,10 @@ private async ValueTask ReceiveSealedChunkAsync(CancellationToken cancellat { _receiveSealed ??= ArrayPool.Shared.Rent(InitialReceiveBufferSize); - // A short read of the prefix is truncation, never a clean end of stream. + // 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)); @@ -383,18 +386,24 @@ public override async ValueTask DisposeAsync() { await CompleteWriteAsync(CancellationToken.None); } - catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException) + catch (Exception ex) when (ex is IOException or ObjectDisposedException + or OperationCanceledException or ProxyProtocolException) { - // A broken transport must not turn disposal into a failure. + // 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; - ReleaseResources(); + // 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); @@ -422,7 +431,7 @@ protected override void Dispose(bool disposing) _inner.Write(_sendBuffer!.AsSpan(0, length)); _inner.Flush(); } - catch (Exception ex) when (ex is IOException or ObjectDisposedException) + catch (Exception ex) when (ex is IOException or ObjectDisposedException or ProxyProtocolException) { // See DisposeAsync. } @@ -431,9 +440,9 @@ protected override void Dispose(bool disposing) finally { _disposed = true; - ReleaseResources(); if (!_leaveInnerOpen) _inner.Dispose(); + ReleaseResources(); } } else diff --git a/QuickProxyNet/ProxyType.cs b/QuickProxyNet/ProxyType.cs index 21b1806..41a9e37 100644 --- a/QuickProxyNet/ProxyType.cs +++ b/QuickProxyNet/ProxyType.cs @@ -9,7 +9,5 @@ public enum ProxyType Socks5, Vless, Vmess, - Trojan, - Hysteria2, - Tuic + Trojan } diff --git a/QuickProxyNet/QuickProxyNet.csproj b/QuickProxyNet/QuickProxyNet.csproj index 7ac5b0b..40f24d1 100644 --- a/QuickProxyNet/QuickProxyNet.csproj +++ b/QuickProxyNet/QuickProxyNet.csproj @@ -1,21 +1,22 @@ - + net8.0;net9.0;net10.0;net11.0 enable enable latest v - 3.0 + 4.0 QuickProxyNet Titlehhhh Titlehhhh QuickProxyNet is a high-performance, zero-dependency .NET library for connecting to servers via HTTP, HTTPS, SOCKS4, SOCKS4a and SOCKS5 proxies, and via the VPN-style protocols VLESS, VMess and Trojan over tcp, ws or httpupgrade. Provides direct Stream access for low-level network operations. VLESS REALITY and the xtls-rprx-vision flow are implemented in managed code, with no external binary. - proxy;networking;http;socks;vless;vmess;trojan;high-performance - Copyright © Titlehhhh 2024 + 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 - + 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/implementation-plan.md b/docs/implementation-plan.md index 9e5b81d..60608af 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -16,7 +16,7 @@ - `BinaryPrimitives` для network byte order; - без LINQ в hot path; - helpers `internal`, публичное API — с XML-докой; -- multi-target `net8.0`/`net9.0`/`net10.0`. +- multi-target `net8.0`/`net9.0`/`net10.0`/`net11.0`. Корпус для проверки корректности — реальные share-ссылки из PypsCFG (`vless://`, `vmess://`, `trojan://`, `hy2://`, `tuic://`, ~17.7k конфигов). @@ -31,7 +31,7 @@ | 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 | uTLS fingerprint | нет | Очень высокий | отдельно | +| VLESS REALITY / XTLS-vision | TCP | собственный TLS 1.3 (X25519, HKDF, record layer) | нет | Очень высокий | **сделано** (§8) | Обоснование порядка: VLESS `none`/`tls` не тянет новых зависимостей и прогоняет всю новую архитектуру (config-парсер, UUID big-endian, address @@ -200,8 +200,9 @@ time-sync, `security` (`aes-128-gcm`/`chacha20-poly1305`), `alterId=0`. `System.Net.Quic`, lifecycle одного QUIC-соединения на несколько стримов. **Депризорити­зировано** — см. §7. -**Отдельно:** VLESS REALITY / XTLS-vision (uTLS fingerprint — не покрывается -стандартным `SslStream`). +**REALITY / XTLS-vision:** сделано, в ядре, управляемым TLS 1.3 — см. §8. Не сделан +браузерный отпечаток ClientHello; это следующий шаг по REALITY +(`reality-fingerprint-plan.md`). ## 7. Что делать дальше: решение по цифрам (2026-08-14) @@ -257,31 +258,30 @@ grpc, которые падают уже на `ConnectAsync`. Явно НЕ поддерживалось (кидали `NotSupportedException`): `reality`, непустой `flow`, транспорты `ws`/`grpc`/`xhttp`/`httpupgrade`, команды UDP/Mux. -С тех пор закрыты `ws` и `httpupgrade` (фаза 4), а `reality` и `flow` — в -отдельном пакете `QuickProxyNet.Reality` (§8). Остаются `grpc`, `xhttp` и -UDP/Mux. +С тех пор закрыты `ws` и `httpupgrade` (фаза 4), а `reality` и +`flow=xtls-rprx-vision` — в самом ядре, через `VlessClient` (§8). Остаются `grpc`, +`xhttp` и UDP/Mux. -## 8. REALITY: что вышло (2026-08-20) +## 8. REALITY: что вышло (2026-08-20, уточнено 2026-08-22) -Прогноз из §7 — «отдельный проект, а не фича» — подтвердился буквально: получился -отдельный пакет `QuickProxyNet.Reality`. А вот вывод «пока нет способа подделать -uTLS-отпечаток, честный `NotSupportedException` — единственное правильное -поведение» оказался верным лишь наполовину, и разбираться стоило именно с этой -половиной. +Прогноз из §7 — «отдельный проект, а не фича» — подтвердился по объёму работы, но +не по форме результата. А вывод «пока нет способа подделать uTLS-отпечаток, честный +`NotSupportedException` — единственное правильное поведение» оказался верным лишь +наполовину, и разбираться стоило именно с этой половиной. -Верно то, что **ядро** библиотеки не может говорить на REALITY: `SslStream` -отдаёт рукопожатие Schannel или OpenSSL и не даёт написать ClientHello. Неверно -то, что из этого следует отказ как единственный выход. Их два, и они отвечают на -разные вопросы: +Верно то, что `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, и на живых узлах из корпуса. -1. **Процесс-компаньон.** Локальный Xray с loopback-инбаундом SOCKS5 за фасадом - `RealityProxy`. Даёт всё сразу — REALITY, Vision, транспорты под ними — ценой - бинаря, который поставляет вызывающая сторона. Конфигурация уходит в Xray - через stdin, поэтому UUID не попадает на диск. -2. **Управляемая реализация.** X25519, аутентификация REALITY, клиент TLS 1.3 и - record layer на C#. Проходит настоящее рукопожатие с Xray-core 26.3.27 и - проносит VLESS без внешнего процесса. +По дороге существовал второй вариант — пакет `QuickProxyNet.Reality`, который +поднимал дочерний Xray за фасадом `RealityProxy`. Он был удалён, не выходя в NuGet: +когда управляемый путь доказал себя на реальных серверах, вторая реализация ради +`grpc`/`xhttp` и чужого отпечатка перестала стоить своей поддержки. ### Что оказалось дешевле, чем выглядело @@ -309,9 +309,10 @@ HMAC-SHA512. Всё, кроме X25519, есть в платформе. Слож расширения на каждое соединение), `MLKem` из .NET 10 привязан к ОС и потому непригоден как основа, а ML-KEM-768 придётся реализовать управляемо на все TFM. -### Открытый вопрос +### Публичная форма — решено -Весь `Managed/` помечен `internal`. То есть заявленная главная возможность — -REALITY без внешнего бинаря — потребителям пакета недоступна. Это незакрытая -работа, а не недосмотр: публичную форму (`RealityClient : IProxyClient`? внутрь -`VlessClient`? третий пакет?) ещё предстоит выбрать. +Внутрь `VlessClient`: `security=reality` в `VlessOptions`, или просто ссылка в +`ProxyClientFactory.Create(string)` / `Proxy.ConnectAsync(string, …)`. Отдельный +`RealityClient` и третий пакет отвергнуты — у пользователя в руках `vless://`-ссылка, +и она сама говорит, какой режим безопасности нужен. Наружу из `Internal/Reality/` +торчит только `RealityHandshakeException : ProxyProtocolException`. diff --git a/docs/vless.md b/docs/vless.md index 6688d33..54e28e7 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -160,16 +160,19 @@ TCP connect -> SslStream.AuthenticateAsClientAsync -> VLESS -> Stream ### `security=reality` -REALITY занимает место TLS, но не является обычным `SslStream`: +Реализовано, в ядре. REALITY занимает место TLS, но не является `SslStream` — +рукопожатие написано своё (`Internal/Reality/`: X25519, HKDF key schedule, record +layer, ClientHello с запечатанным в `session_id` ключом): ```text -TCP connect -> REALITY/uTLS handshake -> VLESS -> Stream +TCP connect -> RealityTlsClient.HandshakeAsync -> VLESS -> Stream ``` -Нужны public key, short id, SNI/serverName, uTLS fingerprint и проверка -REALITY-specific certificate behavior. Это отдельный transport security слой. -Его нельзя полноценно сделать через стандартный .NET `SslStream`, потому что -`SslStream` не дает точный browser-like ClientHello fingerprint. +Из ссылки берутся `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` @@ -212,8 +215,9 @@ TLS. Клиент, который её игнорирует, зависает н - Для `security=tls` сначала завернуть socket в `SslStream`. - UUID byte order покрыть unit-тестом. - Domain length ограничен одним байтом. -- Response header надо прочитать до возврата stream, иначе пользователь увидит - `00 00` перед байтами target-а. +- Response header читается лениво, на первом `Read` (`VlessResponseStream`): ни + Xray, ни sing-box не шлют его, пока target не ответил, и чтение до возврата + stream дедлочит любой client-speaks-first протокол. См. AGENTS.md, правило 2. ## Источники diff --git a/tests/docker/README.md b/tests/docker/README.md index 21f2d49..f886bd4 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -43,7 +43,7 @@ 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) | +| `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 | diff --git a/tests/docker/xray/config.json b/tests/docker/xray/config.json index 1766538..2c9de3a 100644 --- a/tests/docker/xray/config.json +++ b/tests/docker/xray/config.json @@ -225,7 +225,13 @@ { "tag": "direct", "protocol": "freedom", - "settings": {} + "settings": { + "finalRules": [ + { + "action": "allow" + } + ] + } } ] } From d756e6e391840d33579693d02bc0673e19967e22 Mon Sep 17 00:00:00 2001 From: Titlehhhh Date: Sat, 22 Aug 2026 18:13:38 +0500 Subject: [PATCH 25/25] fix: bound every loop a hostile peer could hold open, and finish the error-type pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hostile-peer review found no way to make the client allocate without limit or crash; what it found were reads that never return while a server keeps sending something legal and empty. Each is now bounded, and the bound is a protocol error the caller can catch: - a REALITY server flight is at most 8 messages before a Finished (a real one is 4); - a zero-length handshake record is refused outright (RFC 8446 §5.1 forbids it), and empty application-data records during the handshake stop at 64; - after the handshake, 64 consecutive records carrying no application data — empty records, ChangeCipherSpec, session tickets — end the read with an error instead of never; - a Vision stream gives up after 64 padding-only frames in a row (Xray sends one or two); - a WebSocket transport gives up after 64 consecutive empty frames. Vision also no longer waits for 21 bytes before deciding whether the server is framing. The first byte that is not the UUID settles it, so a server that is not framing and answers with a five-byte greeting and then waits for the client gets its greeting delivered instead of deadlocking on a header that is never coming. Two reviewers flagged this independently. Two more exceptions that escaped the ProxyProtocolException contract: a server that hangs up mid-handshake is now ConnectionFailed with the pbk/sid/sni hint — that is what a REALITY server with no fallback does to a client it does not recognise, so the hint is the useful part — and a VMess chunk that fails its tag is InvalidResponse with the AEAD exception as inner, rather than the AEAD exception itself surfacing from a Stream read. Tests for each bound that can be scripted without handshake keys: the empty handshake record, the post-handshake empty-record flood over a hand-sealed record stream, the padding-only Vision flood, the short non-Vision answer, and the hang-up. The four VMess tests that pinned the raw AEAD exception now pin the wrapped one. Deferred, on record: reassembling post-handshake messages split across records (Go sends one ticket per record, so it does not bite in practice); the sync Read paths run through GetAwaiter().GetResult() and therefore ignore Socket.ReadTimeout. Co-Authored-By: Claude Fable 5 --- QuickProxyNet.Tests/HostilePeerTest.cs | 27 ++++++- QuickProxyNet.Tests/TlsRecordStreamTest.cs | 42 ++++++++++ QuickProxyNet.Tests/VisionTest.cs | 76 +++++++++++++++++++ QuickProxyNet.Tests/VmessBodyTest.cs | 8 +- .../Internal/Reality/RealityTlsClient.cs | 52 +++++++++++++ .../Internal/Reality/RealityTlsStream.cs | 20 +++++ .../Internal/Transports/WebSocketStream.cs | 12 ++- QuickProxyNet/Internal/VisionStream.cs | 67 ++++++++++++---- QuickProxyNet/Internal/Vmess/VmessStream.cs | 25 ++++-- 9 files changed, 300 insertions(+), 29 deletions(-) diff --git a/QuickProxyNet.Tests/HostilePeerTest.cs b/QuickProxyNet.Tests/HostilePeerTest.cs index 61236c8..6110ac9 100644 --- a/QuickProxyNet.Tests/HostilePeerTest.cs +++ b/QuickProxyNet.Tests/HostilePeerTest.cs @@ -284,15 +284,36 @@ public async Task ChangeCipherSpecFlood_IsRefused() Assert.Contains("ChangeCipherSpec", ex.Message); } - /// A peer that hangs up mid-handshake must not be reported as anything else. + /// + /// 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 PeerThatSaysNothing_Fails() + public async Task PeerThatHangsUp_IsConnectionFailedWithAHint() { await using var peer = new ScriptedPeer(_ => []); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await Assert.ThrowsAnyAsync( + 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); } /// diff --git a/QuickProxyNet.Tests/TlsRecordStreamTest.cs b/QuickProxyNet.Tests/TlsRecordStreamTest.cs index 7f463cd..066e44f 100644 --- a/QuickProxyNet.Tests/TlsRecordStreamTest.cs +++ b/QuickProxyNet.Tests/TlsRecordStreamTest.cs @@ -351,6 +351,48 @@ public async Task RecordWithoutAContentType_IsRefused() 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() diff --git a/QuickProxyNet.Tests/VisionTest.cs b/QuickProxyNet.Tests/VisionTest.cs index 9f5a057..2ae2b6a 100644 --- a/QuickProxyNet.Tests/VisionTest.cs +++ b/QuickProxyNet.Tests/VisionTest.cs @@ -196,6 +196,43 @@ public async Task Read_TruncatedFrameThrows(bool sync) 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] @@ -304,6 +341,45 @@ public void IsVision_MatchesOnlyTheImplementedFlow() 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 { diff --git a/QuickProxyNet.Tests/VmessBodyTest.cs b/QuickProxyNet.Tests/VmessBodyTest.cs index 5614ebb..9e38c26 100644 --- a/QuickProxyNet.Tests/VmessBodyTest.cs +++ b/QuickProxyNet.Tests/VmessBodyTest.cs @@ -598,7 +598,7 @@ public async Task Read_BadTag_IsAHardErrorNotEof() var transport = new DuplexTestStream(wire); var stream = ClientStream(transport); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await stream.ReadAsync(new byte[64])); } @@ -611,7 +611,7 @@ public async Task Read_TamperedTerminator_IsAHardErrorNotEof() var stream = ClientStream(transport); // The empty chunk is authenticated: a broken tag must not be reported as EOF. - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await stream.ReadAsync(new byte[64])); Assert.False(stream.IsReadCompleted); } @@ -623,7 +623,7 @@ public async Task Read_ChunksOutOfOrder_FailTheNonceCheck() var transport = new DuplexTestStream(Hex(RespAesChunk1)); var stream = ClientStream(transport); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await stream.ReadAsync(new byte[64])); } @@ -865,7 +865,7 @@ public async Task ReadAndWriteDirectionsUseIndependentKeys() var transport = new DuplexTestStream(Hex(ReqAesChunk0)); var stream = ClientStream(transport); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await stream.ReadAsync(new byte[64])); } diff --git a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs index 018a021..e682ba5 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsClient.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsClient.cs @@ -63,6 +63,12 @@ internal sealed class RealityTlsClient 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]; @@ -190,11 +196,20 @@ public static async ValueTask HandshakeAsync( // ---- 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: @@ -268,6 +283,16 @@ await records.WriteAsync(TlsContentType.ChangeCipherSpec, ChangeCipherSpecPayloa 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(); @@ -568,10 +593,17 @@ private sealed class HandshakeReader(TlsRecordStream records) /// private const int MaxChangeCipherSpec = 8; + /// + /// Cap on empty application-data records during the handshake. Each is legal on its own + /// and carries nothing; a stream of them is a peer keeping us busy. + /// + private const int MaxEmptyRecords = 64; + private byte[] _buffer = ArrayPool.Shared.Rent(TlsRecordStream.MaxCiphertext); private int _length; private int _consumed; private int _changeCipherSpecSeen; + private int _emptyRecordsSeen; /// Application data that arrived before the handshake finished. public List Leftover { get; } = []; @@ -628,6 +660,11 @@ public async ValueTask NextAsync(CancellationToken cancellatio "than passed to the caller."); case TlsContentType.ApplicationData: + if (record.Payload.IsEmpty && ++_emptyRecordsSeen > MaxEmptyRecords) + throw new RealityHandshakeException( + $"The peer sent more than {MaxEmptyRecords} empty application-data records " + + "during the handshake."); + if (Leftover.Count + record.Payload.Length > MaxLeftover) throw new RealityHandshakeException( $"The peer sent more than {MaxLeftover} bytes of application data before " + @@ -637,6 +674,12 @@ public async ValueTask NextAsync(CancellationToken cancellatio 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; @@ -741,4 +784,13 @@ 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 index 7dbf233..adc3396 100644 --- a/QuickProxyNet/Internal/Reality/RealityTlsStream.cs +++ b/QuickProxyNet/Internal/Reality/RealityTlsStream.cs @@ -35,6 +35,11 @@ internal sealed class RealityTlsStream : Stream 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; @@ -128,15 +133,18 @@ record = await _records.ReadAsync(cancellationToken).ConfigureAwait(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: @@ -158,6 +166,18 @@ record = await _records.ReadAsync(cancellationToken).ConfigureAwait(false); } } + /// + /// 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) diff --git a/QuickProxyNet/Internal/Transports/WebSocketStream.cs b/QuickProxyNet/Internal/Transports/WebSocketStream.cs index fc05c2c..f8f3be8 100644 --- a/QuickProxyNet/Internal/Transports/WebSocketStream.cs +++ b/QuickProxyNet/Internal/Transports/WebSocketStream.cs @@ -21,6 +21,9 @@ namespace QuickProxyNet; /// 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; @@ -62,7 +65,10 @@ public override async ValueTask ReadAsync( // 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. + // 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; @@ -86,6 +92,10 @@ public override async ValueTask ReadAsync( 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."); } } diff --git a/QuickProxyNet/Internal/VisionStream.cs b/QuickProxyNet/Internal/VisionStream.cs index ee1ea1f..3791d65 100644 --- a/QuickProxyNet/Internal/VisionStream.cs +++ b/QuickProxyNet/Internal/VisionStream.cs @@ -54,6 +54,13 @@ internal sealed class VisionStream : Stream /// 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. @@ -78,6 +85,7 @@ private enum Mode private byte _command = CommandPaddingContinue; private int _remainingContent; private int _remainingPadding; + private int _paddingOnlyFrames; private bool _uplinkPadded; private bool _disposed; @@ -139,10 +147,19 @@ public override async ValueTask ReadAsync( if (_mode == Mode.Undecided) { - // The decision needs a whole first frame header. A stream that ends before then - // was never framed, so whatever arrived is payload. - await FillAsync(UuidSize + HeaderSize, throwOnEof: false, cancellationToken).ConfigureAwait(false); - DecideMode(); + // 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; } @@ -202,8 +219,15 @@ public override int Read(Span buffer) if (_mode == Mode.Undecided) { - Fill(UuidSize + HeaderSize, throwOnEof: false); - DecideMode(); + while (!TryDecideMode()) + { + if (FillSome() == 0) + { + _mode = Mode.Raw; + break; + } + } + continue; } @@ -248,20 +272,27 @@ public override int Read(Span buffer) public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); /// - /// Decides, from the bytes buffered so far, whether the peer is speaking Vision framing. + /// 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 void DecideMode() + private bool TryDecideMode() { - if (Buffered >= UuidSize + HeaderSize && _buffer.AsSpan(_start, UuidSize).SequenceEqual(_uuid)) + int compared = Math.Min(Buffered, UuidSize); + if (compared > 0 && !_buffer.AsSpan(_start, compared).SequenceEqual(_uuid.AsSpan(0, compared))) { - _start += UuidSize; - _mode = Mode.Framed; - return; + // 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; } - // Not our UUID — the server answered in plain VLESS despite the flow, which is what a - // non-Vision server does. Everything buffered is payload. - _mode = Mode.Raw; + 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. @@ -275,6 +306,12 @@ private void ReadFrameHeader() _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) diff --git a/QuickProxyNet/Internal/Vmess/VmessStream.cs b/QuickProxyNet/Internal/Vmess/VmessStream.cs index a6c89c4..d5332e4 100644 --- a/QuickProxyNet/Internal/Vmess/VmessStream.cs +++ b/QuickProxyNet/Internal/Vmess/VmessStream.cs @@ -45,8 +45,9 @@ internal enum VmessSecurity : byte /// 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 . Neither is ever -/// reported as a clean end of stream. +/// 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 @@ -286,10 +287,22 @@ private void EnsurePlainCapacity(int plaintextLength) private void OpenChunk(int sealedLength, Memory plaintext) { int plaintextLength = sealedLength - TagSize; - _reader.Open( - _receiveSealed!.AsSpan(0, plaintextLength), - _receiveSealed.AsSpan(plaintextLength, TagSize), - plaintext.Span); + 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 ================================