Skip to content

Add VPN protocols - #2

Merged
Titlehhhh merged 26 commits into
masterfrom
feature/add-vpn-protocols
Aug 22, 2026
Merged

Add VPN protocols#2
Titlehhhh merged 26 commits into
masterfrom
feature/add-vpn-protocols

Conversation

@Titlehhhh

Copy link
Copy Markdown
Owner

No description provided.

@Titlehhhh Titlehhhh changed the title Add VPN protocol documentation Add VPN protocols Jun 25, 2026
Titlehhhh and others added 25 commits July 23, 2026 19:40
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>
@Titlehhhh
Titlehhhh merged commit 0ed667c into master Aug 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant