Add VPN protocols - #2
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Brings in the benchmark work: X25519 against .NET 11's platform X25519DiffieHellman, the record layer at 64 B to 16 KiB for both AES-GCM and ChaCha20-Poly1305, and end-to-end RealityTlsStream throughput with allocations reported per MiB. Two results worth keeping in the repo rather than in a chat log: - ChaCha20-Poly1305 measured 7.3x slower than AES-128-GCM here (277 vs 2022 MB/s): on Windows it goes through CNG while AES-GCM is hardware accelerated. A server is free to select it, so the tunnel can land on that cliff. Not a correctness or camouflage problem, but nobody should meet it by surprise. - .NET 11's X25519DiffieHellman is 2.6x faster than the managed curve. That is ~0.4 ms per connection against a network round trip of tens of milliseconds, and the managed implementation has to stay for net8/9/10 regardless, so it is not a reason to branch and not a reason to delete anything. The allocation numbers these were taken against are the ones the previous commit removes; rerunning is how that commit's claim gets checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e's list 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) <noreply@anthropic.com>
…one place 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…a string 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
…the loopback tests back
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 <noreply@anthropic.com>
…error-type pass 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.