From 249854b73cf7d1dcb1998d6d0665e87148a07ad7 Mon Sep 17 00:00:00 2001 From: cppla Date: Fri, 9 Oct 2026 12:05:36 +0800 Subject: [PATCH] Add opt-in web-H3 resumption through maintained QUIC fork --- .github/workflows/ci.yml | 12 + README.md | 26 +- SECURITY.md | 36 +- THIRD_PARTY_NOTICES.md | 4 +- cmd/autocar/common.go | 2 +- cmd/autocar/h3_profile_migration_test.go | 2 +- cmd/autocar/web_cli_test.go | 9 +- docs/DEPENDENCY-MAINTENANCE.md | 123 ++++-- docs/WEB_COVER.md | 48 ++- go.mod | 5 +- go.sum | 8 +- internal/tunnel/web_fingerprint.go | 14 +- internal/tunnel/web_fingerprint_test.go | 3 + internal/tunnel/web_h3_client.go | 19 +- internal/tunnel/web_h3_config.go | 6 +- .../tunnel/web_h3_resumption_cancel_test.go | 202 ++++++++++ .../tunnel/web_h3_resumption_policy_test.go | 241 ++++++++++++ internal/tunnel/web_h3_resumption_test.go | 88 ++++- .../tunnel/web_h3_resumption_wire_test.go | 372 ++++++++++++++++++ internal/tunnel/web_h3_test.go | 11 +- scripts/check-dependency-boundary.sh | 100 +++-- scripts/check-upstream-advisories.sh | 4 +- scripts/govulncheck.sh | 13 +- scripts/test_dependency_boundary.py | 128 +++++- 24 files changed, 1311 insertions(+), 165 deletions(-) create mode 100644 internal/tunnel/web_h3_resumption_cancel_test.go create mode 100644 internal/tunnel/web_h3_resumption_policy_test.go create mode 100644 internal/tunnel/web_h3_resumption_wire_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a04d4f..8787a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,18 @@ jobs: name: utls-upstream-advisories path: ${{ runner.temp }}/utls-upstream-advisories.json if-no-files-found: error + - name: Query official QUIC source-lineage advisories + if: always() + run: | + ./scripts/govulncheck.sh --upstream-quic > "$RUNNER_TEMP/quic-official-upstream-advisories.json" + ./scripts/check-upstream-advisories.sh "$RUNNER_TEMP/quic-official-upstream-advisories.json" + - name: Preserve official QUIC source-lineage advisory query + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: quic-official-upstream-advisories + path: ${{ runner.temp }}/quic-official-upstream-advisories.json + if-no-files-found: error build: name: Build ${{ matrix.goos }}/${{ matrix.goarch }} diff --git a/README.md b/README.md index b9947d5..8819bcd 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,10 @@ HTTP/3/UDP,UDP 不可用时让新 TCP 流继续走 HTTPS/HTTP/2/TCP;SOCKS5 U AutoCAR 的身份验证、`autocar/2` 协议、TCP/UDP framing、速率协商、pacing、 熔断回退和资源边界均由 AutoCAR 实现;自有协议不提供第三方代理协议兼容模式。 Native 模式继续使用上游 `github.com/quic-go/quic-go`;web H3 则透明依赖 -`github.com/apernet/quic-go` fork,并精确锁定到 -`v0.63.1-0.20261004180939-a10df75c260c`,用于客户端 Chrome QUIC 握手画像。 +`github.com/apernet/quic-go` 模块身份,并通过精确远程 replacement 使用自维护的 +`github.com/cppla/quic-go`。其上游基线为 +`v0.63.1-0.20261004180939-a10df75c260c`;版本、校验和与补丁维护见 +[依赖维护说明](docs/DEPENDENCY-MAINTENANCE.md)。 项目不依赖外部代理应用模块,依赖边界由自动检查验证。 ## 设计目标与实现边界 @@ -246,15 +248,21 @@ H2 可显式选择 `--h2-fingerprint=chrome-155`(固定的新模板)、`chro `--h3-fingerprint=chrome-2026-10` 是默认值,固定使用上述依赖版本提供的完整客户端 QUIC/TLS 握手画像,并固定为与画像中版本参数一致的 QUIC v1。 +新选项 `--h3-fingerprint=chrome-2026-10-resume` 在同一客户端重连时使用 +有效票据恢复 TLS 1.3 会话;不启用 0-RTT,新连接仍重新认证。缓存仅在内存中、 +按客户端隔离,重启进程后不会保留。默认值和已有 `chrome-2026-10` 保持完整握手。 +会话恢复用于减少完整握手开销;尚未量化延迟收益,也不代表流量等同真实浏览器。 `--h3-fingerprint=native` 是互操作与故障回滚选项:它关闭该 fork 的 ChromeParrot 行为,但仍属于 web H3,不会切换成 `autocar/2` 或第三方代理 协议,也不会把依赖替换为 native 模式使用的上游模块。 -升级前请检查客户端配置:显式的 `chrome-2026-08` 已被拒绝,不会静默映射到新画像; -请有意识地改为 `chrome-2026-10` 或 `native`。未填写 `h3-fingerprint` 的旧配置会采用 -新默认值,因此也会改变客户端握手。新生成的 web `init` 客户端配置会明确固定 -`chrome-2026-10`;仅运行服务端不需要迁移客户端画像选项。此更新不启用 H3 Chrome -画像的 TLS 会话恢复或 0-RTT,也不构成隐蔽性保证;旧采集结果仍只属于其冻结版本。 +从十月画像之前的版本升级时,请检查客户端配置:显式的 `chrome-2026-08` 已被拒绝, +不会静默映射到新画像;请有意识地改为 `chrome-2026-10` 或 `native`。那些版本中 +未填写 `h3-fingerprint` 的配置升级后会采用十月默认画像,改变客户端握手。 +本次会话恢复改造不再改变默认画像。新生成的 web `init` 客户端配置会明确固定 +`chrome-2026-10`;仅运行服务端不需要迁移客户端画像选项。只有显式选择上述 +`-resume` 选项才启用 Chrome H3 会话恢复;所有画像仍禁用 0-RTT。 +这不构成隐蔽性保证;旧采集结果仍只属于其冻结版本。 在启动本地代理前,可用同一组隧道参数做一次真实端到端探测: @@ -451,8 +459,8 @@ iptables 下的 UDP `sendmsg` 直接返回 `EPERM`。有损阶段只硬验证协 `scripts/` 中: -- `check-dependency-boundary.sh` 只允许 go.mod 精确锁定上述唯一 - `github.com/apernet/quic-go` 版本,拒绝已知外部代理应用模块、local replace 和 +- `check-dependency-boundary.sh` 只允许 go.mod 精确锁定上述 web QUIC 基线及 + 两个自维护库的远程版本,保持官方 native QUIC 不被替换,拒绝已知外部代理应用模块、local replace 和 vendored/copied 外部源码目录,并扫描已跟踪及未跟踪的 Go 源; - `docker-integration.sh` 在隔离容器网络中验证 QUIC、TLS、自动回退、`doctor`、错误令牌拒绝和非 root 只读运行; - `govulncheck.sh` 安装固定版本的扫描器并检查可达漏洞;上游 quic-go 公告不会自动 diff --git a/SECURITY.md b/SECURITY.md index d3bbbf8..ff0bb43 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,13 +37,16 @@ AutoCAR does not hide endpoint IPs, packet size, timing, traffic volume, or the use of QUIC/TLS. Web mode serves a real configured H1/H2/H3 origin and routes unauthenticated requests through that cover, but normal HTTP semantics do not make all observable behavior identical to a browser. The H2 client uses a fixed -Chrome 133 uTLS ClientHello reference; that is not a claim about the complete +Chrome 133 or explicitly selected Chrome 155 uTLS ClientHello reference; that is not a claim about the complete TLS/H2 fingerprint. Web H3 defaults to the fixed `chrome-2026-10` client handshake profile from the exactly pinned `github.com/apernet/quic-go` fork and uses a zero-length source CID. That client-only profile does not reproduce the relay, H3 SETTINGS, CONNECT traffic, packet sizes, reuse, or timing. This profile still disables TLS session resumption: a replacement connection performs a full -handshake even with a configured session cache. Neither the dependency update +handshake even with a configured session cache. The separate opt-in +`chrome-2026-10-resume` profile can resume TLS using a bounded, per-client +memory-only cache. It still requires new proxy authentication for each physical +connection, and all profiles keep 0-RTT disabled. Neither the dependency update nor the profile is proof of a classification advantage. AutoCAR makes no undetectability guarantee. @@ -241,13 +244,15 @@ parties. ## Dependency boundary Native AutoCAR builds on official upstream quic-go. Web H3 uses -`github.com/apernet/quic-go` pinned exactly to -`v0.63.1-0.20261004180939-a10df75c260c`; H2 uses x/net HTTP/2 and uTLS for its -fixed ClientHello reference. A targeted CI boundary allows only that exact web -QUIC fork version and one exact published `github.com/cppla/utls` remote -replacement for the original uTLS module. The same replacement covers H2 and -the web-H3 adapter; direct imports of the replacement path are rejected to -avoid a second uTLS module identity. The boundary rejects the known external +`github.com/apernet/quic-go` at source baseline +`v0.63.1-0.20261004180939-a10df75c260c`, globally replaced by an exact +published `github.com/cppla/quic-go` revision; H2 uses x/net HTTP/2 and uTLS. +A targeted CI boundary allows only that exact web QUIC source baseline and +two exact published remote replacements: the maintained QUIC fork and +`github.com/cppla/utls` for the original uTLS module. The uTLS replacement +covers H2 and the web-H3 adapter; direct imports of either replacement path +are rejected to avoid duplicate module identities. Official native QUIC is +not replaced. The boundary rejects the known external proxy application module, local replacements, and copied/vendored external-source directories, and scans tracked and untracked Go source. @@ -265,14 +270,17 @@ has no vulnerabilities. The managed uTLS replacement has the same database-identity limitation, even though its original import paths are retained. CI separately queries the exact -original uTLS baseline, retains the JSON output, and stops for human review if -advisories are returned. This is not a reachability result for the fork. The +original uTLS baseline and the web-H3 lineage's official QUIC baseline, retains +both JSON outputs, and stops for human review if advisories are returned. +These queries are not reachability results for the forks. The review must also consider relevant Go TLS security fixes. See the executable update and advisory-review procedure in [dependency maintenance](docs/DEPENDENCY-MAINTENANCE.md). The current client profile is versioned as `chrome-2026-10`; the retired -`chrome-2026-08` name is rejected, not aliased. Existing client configurations -that omit the profile select the new default on upgrade. Review that handshake -change before deployment; new web `init` client files pin the profile explicitly. +`chrome-2026-08` name is rejected, not aliased. When upgrading from builds predating +the October profile, client configurations that omit the profile adopt the +October default and change their handshake. Review that migration before +deployment. This resumption update does not change the current default; +new web `init` client files pin the profile explicitly. Server-only deployments have no client-profile setting to migrate. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c0353e7..344482d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,6 +8,8 @@ The SHA-256 value is calculated from the upstream file's original bytes; line en ## `github.com/apernet/quic-go` `v0.63.1-0.20261004180939-a10df75c260c` +Effective source replacement: `github.com/cppla/quic-go v0.63.1-0.20261009040133-c1cae948af15`. + ### `LICENSE` SHA-256: `77d0b7b53e8abb84cf4dd3f9945a7fdf27044240d2e8023966a721a9a46fe96e` @@ -427,7 +429,7 @@ SOFTWARE. ## `github.com/refraction-networking/utls` `v1.8.3-0.20261006222701-ff1b50fbbe9a` -Effective source replacement: `github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30`. +Effective source replacement: `github.com/cppla/utls v0.0.0-20261009031926-14c2a4cb1403`. ### `LICENSE` diff --git a/cmd/autocar/common.go b/cmd/autocar/common.go index e2447f0..2e6490d 100644 --- a/cmd/autocar/common.go +++ b/cmd/autocar/common.go @@ -115,7 +115,7 @@ func addTunnelFlags(fs *flag.FlagSet, flags *tunnelFlags) { flags.h2Fingerprint = h2FingerprintFlag(tunnel.FingerprintChrome133) fs.Var(&flags.h2Fingerprint, "h2-fingerprint", "web H2 wire profile: chrome-133, chrome-155, or native; empty uses chrome-133 (h2/web-auto only)") flags.h3Fingerprint = h3FingerprintFlag(tunnel.H3FingerprintChrome202610) - fs.Var(&flags.h3Fingerprint, "h3-fingerprint", "web H3 wire profile: chrome-2026-10 or native; chrome-2026-08 is retired") + fs.Var(&flags.h3Fingerprint, "h3-fingerprint", "web H3 wire profile: chrome-2026-10 (full handshake), chrome-2026-10-resume (opt-in tickets, no 0-RTT), or native; chrome-2026-08 is retired") fs.StringVar(&flags.pacing, "pacing", "adaptive", "QUIC application pacing: adaptive, reno, or fixed-rate") fs.StringVar(&flags.pacingProfile, "pacing-profile", "balanced", "adaptive pacing profile: conservative, balanced, or aggressive") fs.Uint64Var(&flags.uploadMbps, "upload-mbps", 0, "client-to-relay fixed pacing rate in Mbit/s") diff --git a/cmd/autocar/h3_profile_migration_test.go b/cmd/autocar/h3_profile_migration_test.go index 45ac2e8..c413974 100644 --- a/cmd/autocar/h3_profile_migration_test.go +++ b/cmd/autocar/h3_profile_migration_test.go @@ -120,7 +120,7 @@ func TestRetiredH3FingerprintConfigCommandsStayOffline(t *testing.T) { } func TestH3FingerprintFlagPreservesNonRetiredValues(t *testing.T) { - for _, profile := range []string{"", "chrome-2026-10", "native", "unused-by-native"} { + for _, profile := range []string{"", "chrome-2026-10", "chrome-2026-10-resume", "native", "unused-by-native"} { fs := flag.NewFlagSet("native-client", flag.ContinueOnError) fs.SetOutput(io.Discard) var tf tunnelFlags diff --git a/cmd/autocar/web_cli_test.go b/cmd/autocar/web_cli_test.go index 55a35da..af9de57 100644 --- a/cmd/autocar/web_cli_test.go +++ b/cmd/autocar/web_cli_test.go @@ -159,6 +159,9 @@ func TestTunnelHelpListsWebTransports(t *testing.T) { if flags.h3Fingerprint != h3FingerprintFlag(tunnel.H3FingerprintChrome202610) || !strings.Contains(output.String(), "h3-fingerprint") || !strings.Contains(output.String(), "chrome-2026-10") { t.Errorf("tunnel help omitted versioned H3 fingerprint profile: %s", output.String()) } + if !strings.Contains(output.String(), "chrome-2026-10-resume") || !strings.Contains(output.String(), "no 0-RTT") { + t.Errorf("tunnel help omitted the explicit H3 resumption policy: %s", output.String()) + } if flags.h2Fingerprint != h2FingerprintFlag(tunnel.FingerprintChrome133) || !strings.Contains(output.String(), "h2-fingerprint") || !strings.Contains(output.String(), "chrome-155") { t.Errorf("tunnel help omitted H2 profiles or changed the legacy default: %s", output.String()) } @@ -189,12 +192,15 @@ func TestBuildExplicitAndAutomaticWebDialers(t *testing.T) { for _, test := range []struct { mode string wantPacket bool + profile h3FingerprintFlag }{ {mode: "h3", wantPacket: true}, {mode: "h2", wantPacket: false}, {mode: "web-auto", wantPacket: true}, + {mode: "h3", wantPacket: true, profile: h3FingerprintFlag(tunnel.H3FingerprintChrome202610Resume)}, + {mode: "web-auto", wantPacket: true, profile: h3FingerprintFlag(tunnel.H3FingerprintChrome202610Resume)}, } { - t.Run(test.mode, func(t *testing.T) { + t.Run(test.mode+"/"+string(test.profile), func(t *testing.T) { dialer, err := buildTunnelDialer(tunnelFlags{ server: "127.0.0.1:443", mode: test.mode, @@ -206,6 +212,7 @@ func TestBuildExplicitAndAutomaticWebDialers(t *testing.T) { fallbackTTL: time.Second, pacing: "adaptive", pacingProfile: "balanced", + h3Fingerprint: test.profile, }) if err != nil { t.Fatal(err) diff --git a/docs/DEPENDENCY-MAINTENANCE.md b/docs/DEPENDENCY-MAINTENANCE.md index fff7188..e50a17e 100644 --- a/docs/DEPENDENCY-MAINTENANCE.md +++ b/docs/DEPENDENCY-MAINTENANCE.md @@ -1,30 +1,47 @@ -# Maintaining the uTLS compatibility fork +# Maintaining the compatibility forks -AutoCAR maintains one small compatibility fork at `github.com/cppla/utls`. -Its module declaration and AutoCAR imports remain +AutoCAR maintains two narrowly scoped compatibility forks. Both retain their +upstream module identities and use global **remote, exact-version** replacements: + +| Use | Original module identity | Maintained source | +| --- | --- | --- | +| H2 and web-H3 TLS | `github.com/refraction-networking/utls` | `github.com/cppla/utls` | +| Web-H3 QUIC adapter | `github.com/apernet/quic-go` | `github.com/cppla/quic-go` | + +Native transport continues to use official `github.com/quic-go/quic-go` without +a replacement. The web-H3 fork does not redirect that independent dependency. +Do not require/import either `github.com/cppla/*` replacement path directly: +that creates a second module/type identity instead of repairing the existing +dependency graph. Local replacements, version-scoped replacements, duplicate +identities and copied/vendor source bypasses are rejected by the boundary gate. + +## uTLS source lineage + +The uTLS module declaration and AutoCAR imports remain `github.com/refraction-networking/utls`. A global **remote, exact-version** `replace` in AutoCAR's `go.mod` selects the published fork for both H2 and the -web-H3 adapter's transitive imports. It is not a local source override. Do not -also require/import `github.com/cppla/utls`: that creates a second module/type -identity instead of repairing the existing dependency graph. +web-H3 adapter's transitive imports. It is not a local source override. The upstream baseline is `v1.8.3-0.20261006222701-ff1b50fbbe9a` (`ff1b50fbbe9a6dff1dcb1cfc0493bd5b0f073f67`). It requires Go 1.27; -AutoCAR's minimum supported toolchain is Go 1.27.2. Native transport continues -to use official quic-go. Web H3 retains its existing, independently pinned -QUIC dependency; this change does not require another maintained QUIC fork. +AutoCAR's minimum supported toolchain is Go 1.27.2. The initial compatibility patch queue fixes custom-QUIC shutdown signaling and transport-parameter ownership after preset cloning. Its deterministic shutdown and real QUIC-TLS parameter tests live in the fork; see [the fork maintenance notes](https://github.com/cppla/utls/blob/master/FORK.md). +The session-resumption follow-up also propagates cancellation out of a paused +resume event, routes custom ClientHello build failures through QUIC channel +cleanup, and exposes a terminal TLS error once. Deterministic regressions cover +both custom and Go hellos, real verified tickets, cancellation and repeated Close. AutoCAR separately checks the H3 Initial shape and reconnect contract. H2 now uses upstream's PSK/HelloRetryRequest support on the same physical connection, with fresh proxy authentication after reconnect, instead of the old private-error-text-triggered cold redial. This does not change the explicitly -selected `chrome-133` or `chrome-2026-10` templates, enable H3 profile resumption, -or establish passive browser similarity. +selected `chrome-133` or `chrome-2026-10` templates or establish passive browser +similarity. H3 resumption requires the separate QUIC adapter patch below; it is +not a consequence of merely replacing uTLS. The separate H2 `chrome-155` option selects upstream's explicit `HelloChrome_155`, never `HelloChrome_Auto`. It needs no further dependency @@ -35,6 +52,34 @@ fixture, checksum, parser attribution and license under `internal/tunnel/testdata/chrome155/`. This checks a bounded historical wire reference, not browser equivalence or current-version freshness. +## Web-H3 QUIC source lineage + +The web-H3 module keeps the original require +`github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c`, from the public +`v0.63.0-mod-rename` branch at exact commit +`a10df75c260cee8161f3261d63a37bd125a6bb2a`. The new maintained repository starts +from that published source, not an older default branch or an unpublished +experiment. The original module declaration and imports stay unchanged; only +the exact replacement selects `github.com/cppla/quic-go`. + +The fork adds explicit opt-in browser-profile session resumption, its +regressions, and narrowly scoped test/build maintenance. It also fixes CRYPTO +tail offset and memory ownership across later handshake flights, including +cold HelloRetryRequest handshakes, without changing the initial cold packet +layout. See [the exact patch queue](https://github.com/cppla/quic-go/blob/main/FORK.md). +Preserve the existing full-handshake profile, +certificate verification, transport-parameter ownership and per-client cache +isolation. A resumed TLS connection still requires fresh AutoCAR authentication; +it must not enable 0-RTT or reuse a previous connection's authentication state. +Cold/warm functional and wire checks do not establish passive browser similarity. + +The official QUIC source baseline for advisory review is +`github.com/quic-go/quic-go v0.63.0`. This is separate from the renamed web-H3 +module's pseudo-version and from the independently updatable native transport +dependency. Update `WEB_QUIC_OFFICIAL_BASELINE` in `scripts/govulncheck.sh` and +its offline fixture when the web-H3 source lineage changes. Preserve and review +the intermediate adapter patches as well as the new maintained patch queue. + ## Updating the patch queue 1. Preserve the upstream history, copyright headers and all license/notice @@ -43,17 +88,20 @@ reference, not browser equivalence or current-version freshness. Keep fixes separate from mechanical upstream updates. Do not remove preset isolation or change a frozen ClientHello to restore pointer aliasing. 2. Publish the reviewed fork commit, then resolve it with - `go list -m -json github.com/cppla/utls@` and retain the returned - version and origin hash. Pin that exact published pseudo-version in both - `go.mod` and `ALLOWED_UTLS_VERSION` in - `scripts/check-dependency-boundary.sh`; never use a branch, `latest`, local - replacement, or invented pseudo-version. The original require records the - actual upstream baseline, not the fork's version. Update its matching - allowlist only when that baseline really changes. + `go list -m -json github.com/cppla/utls@` or + `go list -m -json github.com/cppla/quic-go@` and retain the + returned version and origin hash. Pin that exact published pseudo-version + in `go.mod` and the corresponding `ALLOWED_UTLS_VERSION` or + `ALLOWED_WEB_QUIC_FORK_VERSION` in `scripts/check-dependency-boundary.sh`; + never use a branch, `latest`, local replacement, or invented pseudo-version. + The original requires record the actual source baselines, not fork versions. + Update their matching allowlists only when those baselines really change. 3. Check that H2 and web H3 resolve the same original uTLS module with one - replacement. Retain module sums and regenerate `THIRD_PARTY_NOTICES.md` with - `make notices`; the generator records the replacement and reads its actual - license files. Do not edit generated attribution to hide the fork. + replacement. Check that web H3 resolves the maintained QUIC replacement + while native transport still resolves official QUIC. Retain module sums and + regenerate `THIRD_PARTY_NOTICES.md` with `make notices`; the generator records + each replacement and reads its actual license files. Do not edit generated + attribution to hide either fork. 4. Run `make check`, the targeted compatibility tests, race tests and the applicable CI/release gates with `GOTOOLCHAIN=local` and an explicitly installed supported Go version. A newer downloaded toolchain must not be @@ -72,15 +120,22 @@ Run these separate checks from the selected AutoCAR commit: ```sh ./scripts/govulncheck.sh ./scripts/govulncheck.sh --upstream-utls > /tmp/autocar-utls-upstream-advisories.json +./scripts/check-upstream-advisories.sh /tmp/autocar-utls-upstream-advisories.json +./scripts/govulncheck.sh --upstream-quic > /tmp/autocar-quic-official-upstream-advisories.json +./scripts/check-upstream-advisories.sh /tmp/autocar-quic-official-upstream-advisories.json ``` -Both use the pinned `govulncheck` tool version in the wrapper. The first scans -the actual package/call graph. The second uses `-mode=query -json` against the -**original upstream module and baseline version** from `go.mod`; it is a -module-level advisory query, not a reachability scan of the fork. Its output -is a stream of JSON objects, not one JSON document. CI retains this output and -fails when the query returns an OSV advisory, pending explicit human review. -There is no automatic ignore list. A query failure is not a clean result. +All scans/queries use the pinned `govulncheck` tool version in the wrapper. +The source scan inspects the actual package/call graph. The uTLS query uses +`-mode=query -json` against its **original module and baseline version** from +`go.mod`. The separate QUIC query uses the **official source-lineage baseline** +recorded above, not the renamed adapter/fork path and not whatever native QUIC +version happens to be selected. These are module-level advisory queries, not +reachability scans of either fork. Query output is a stream of JSON objects, +not one JSON document. CI preserves both outputs independently, even when +another scan fails, and fails when a query returns an OSV advisory pending +explicit human review. There is no automatic ignore list. A query failure is +not a clean result. The initial baseline query on 2026-10-08 at approximately 10:57 UTC, using Go 1.27.1 and govulncheck v1.7.0, returned no matching OSV entries; the reported @@ -97,11 +152,17 @@ entry. Keep the review with the dependency-update PR or release evidence. `govulncheck` follows a replacement's module path, so retaining original import paths does **not** automatically retain upstream advisory coverage. The query -helps recover known original-uTLS candidates; it cannot prove the fork safe. +helps recover known original-uTLS candidates; the official QUIC query separately +recovers candidates from the web adapter's source lineage. Neither proves the +maintained fork safe, and an empty advisory result is not a copied-source review. Also review relevant Go `crypto/tls` security changes because uTLS carries its own TLS implementation, and review official QUIC advisories against the exact -web-H3 fork lineage. Scanning the official native QUIC module does not cover -that renamed fork. Record those source/patch reviews separately; do not call +web-H3 fork lineage. For QUIC, compare each affected official file/symbol and fix +against the exact original adapter baseline and maintained patch queue; account +for added or divergent adapter code as well. Scanning the official native QUIC +module does not cover that renamed fork, and querying the renamed adapter path +is not a substitute for the official source-lineage query. Record those +source/patch reviews separately; do not call them automated reachability results or reuse an older review for changed code. This is an update/release procedure, not a background monitoring service. diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index 33e1c32..47f9513 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -435,17 +435,21 @@ enables the fixed full client QUIC/TLS handshake profile and zero-length source CID supplied by the pinned `github.com/apernet/quic-go` fork. The profile is locked to QUIC v1 because its fixed version-information transport parameter is part of that v1 handshake image. +`--h3-fingerprint=chrome-2026-10-resume` explicitly adds ticket-based TLS +resumption to that cold profile, without 0-RTT; the default is unchanged. +See [the resumption boundaries](#fingerprint-boundary) before opting in. `--h3-fingerprint=native` disables ChromeParrot inside that same fork as an explicit interoperability and rollback choice. Here `native` names only the H3 fingerprint fallback; it does not select AutoCAR's native `autocar/2` protocol. The retired `chrome-2026-08` name is rejected, not mapped to the new image. To upgrade an explicitly pinned client config, choose `chrome-2026-10` or `native` -and validate it with `client --check`. Old configs that omit the profile adopt -the new default and therefore change their handshake after upgrade. New web -`init` client files record the current profile explicitly. Review this change -before deploying clients; server-only configurations have no client-profile -migration. +and validate it with `client --check`. When upgrading from builds predating the +October profile, configs that omit the profile adopt the October default and +change their handshake. Review that migration before deploying clients. This +resumption update leaves the current default unchanged. New web `init` client +files record the current profile explicitly; server-only configurations have +no client-profile migration. For `web-auto`, `0 < --quic-attempt-timeout < --open-timeout` is required. When the relay name resolves to both address families, H3 interleaves IPv6 and @@ -639,8 +643,10 @@ clocks must be synchronized closely enough to satisfy the acceptance window. ## Fingerprint boundary The H3 client uses the fixed `chrome-2026-10` profile by default. AutoCAR obtains -that profile from the `github.com/apernet/quic-go` fork, -pinned to `v0.63.1-0.20261004180939-a10df75c260c`. It applies the fork's +that profile from the `github.com/apernet/quic-go` module, globally replaced +by an exact published `github.com/cppla/quic-go` revision. The source baseline +remains `v0.63.1-0.20261004180939-a10df75c260c`; see +[dependency maintenance](DEPENDENCY-MAINTENANCE.md). It applies the fork's ChromeParrot client handshake image—including ClientHello, client transport parameters and Initial packetization—and uses a zero-length client source CID. ChromeParrot is client-only: it does not turn the AutoCAR relay into a particular @@ -648,16 +654,32 @@ Chrome-facing CDN/server implementation, and it does not make H3 SETTINGS, CONNECT/authentication traffic, packet sizes, connection reuse or timing match Chrome. The `native` rollback profile disables this client image. -The fixed H3 Chrome profile also disables TLS session resumption inside the -pinned fork. Supplying `tls.Config.ClientSessionCache` does not change that: -each replacement QUIC connection performs a full TLS handshake. The H3 `native` -profile can resume TLS when a caller-provided cache has a valid ticket and the -server permits it; a nil cache or `SessionTicketsDisabled` retains full -handshakes. Neither profile enables 0-RTT. Reusing an already-open QUIC +The existing `chrome-2026-10` profile retains full TLS handshakes. +Supplying `tls.Config.ClientSessionCache` alone does not change that. +The separate opt-in `chrome-2026-10-resume` profile uses the same cold +QUIC/ClientHello template and permits TLS 1.3 ticket resumption on reconnect. +Each configured client owns a bounded 64-entry, memory-only uTLS-native cache; +it is never shared with H2, another client, or the standard-library cache. +The caller's non-nil standard-library cache enables the policy, but its +incompatible session contents are not translated. A nil cache or +`SessionTicketsDisabled` keeps full handshakes, even with the new profile. +The CLI's normal TLS configuration permits this opt-in. Restarting the process +starts cold; there is no ticket file or global cache. + +The H3 `native` profile can also resume TLS when a caller-provided cache has a +valid ticket and the server permits it. No profile enables 0-RTT, including +when a server issues an early-data-capable ticket. Reusing an already-open QUIC connection for another stream is connection reuse, not TLS resumption. Every replacement physical connection still starts fresh proxy authentication, including when its TLS session resumes. +Select the new option explicitly with +`--h3-fingerprint=chrome-2026-10-resume`, or set the same value in the +client JSON's `h3-fingerprint` field. Existing/default configurations and +newly generated bundles stay on `chrome-2026-10`. Roll back by selecting it +again. A changed warm ClientHello is not a new real-browser reference: +actual traffic equivalence remains unproven. + The H3 loopback reconnect regression checks these distinct behaviors with the same client and server, an actual received-ticket signal, and both peers' TLS state. It does not measure browser similarity. The real-browser calibration diff --git a/go.mod b/go.mod index 73fbd09..aa46bc7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/cppla/autocar go 1.27.2 // Preserve upstream type identity for H2 and the web-H3 adapter. See docs/DEPENDENCY-MAINTENANCE.md. -replace github.com/refraction-networking/utls => github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30 +replace github.com/refraction-networking/utls => github.com/cppla/utls v0.0.0-20261009031926-14c2a4cb1403 + +// The web adapter is independent of native transport's official QUIC module. +replace github.com/apernet/quic-go => github.com/cppla/quic-go v0.63.1-0.20261009040133-c1cae948af15 require ( github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c diff --git a/go.sum b/go.sum index 357fdd8..03d56af 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c h1:cxK8qTA0YCsj68A7zLYDCfPgMTOyt3ePkHh+oXEmYU0= -github.com/apernet/quic-go v0.63.1-0.20261004180939-a10df75c260c/go.mod h1:J7UZVOMF2M1LiE3iiUZWPuy8YCPAcLJOwVURrfJCvc0= -github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30 h1:7+CZIuN8ogrJtDEMSOJwZO0BpPYyiV2LVZK3nGCAQiM= -github.com/cppla/utls v0.0.0-20261009014536-ff869e255a30/go.mod h1:rc/ctWeKlh9LSk5k90KlSPcwF7P6D31mn/9GI2NAfBI= +github.com/cppla/quic-go v0.63.1-0.20261009040133-c1cae948af15 h1:pymPD+qKo2BoQchlq1Ke70OXRAWPfJp25Jjj7+1+F8A= +github.com/cppla/quic-go v0.63.1-0.20261009040133-c1cae948af15/go.mod h1:TqjSZptjiHgfRItER3Mn5ymcv8OBcpfZkC2W9qn4WI0= +github.com/cppla/utls v0.0.0-20261009031926-14c2a4cb1403 h1:4mCd3Kcelm0jFNEWI//rA80LenP2OiMc4E4it17nLQU= +github.com/cppla/utls v0.0.0-20261009031926-14c2a4cb1403/go.mod h1:rc/ctWeKlh9LSk5k90KlSPcwF7P6D31mn/9GI2NAfBI= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw= diff --git a/internal/tunnel/web_fingerprint.go b/internal/tunnel/web_fingerprint.go index 386c4b6..974896b 100644 --- a/internal/tunnel/web_fingerprint.go +++ b/internal/tunnel/web_fingerprint.go @@ -56,6 +56,12 @@ const ( // today". It is the default for web-cover HTTP/3 connections. H3FingerprintChrome202610 H3FingerprintProfile = "chrome-2026-10" + // H3FingerprintChrome202610Resume explicitly opts into TLS 1.3 ticket + // resumption on the October profile. It never enables 0-RTT or reuses proxy + // authentication across physical connections. The default remains full TLS + // handshakes, including when the caller provides a session cache. + H3FingerprintChrome202610Resume H3FingerprintProfile = "chrome-2026-10-resume" + // H3FingerprintChrome202608 identifies the retired August 2026 profile. // Deprecated: this profile is rejected, not aliased to a different wire // image. Explicitly migrate to H3FingerprintChrome202610 or native. @@ -76,13 +82,19 @@ func normalizeH3FingerprintProfile(profile H3FingerprintProfile) (H3FingerprintP return H3FingerprintChrome202610, nil case H3FingerprintChrome202608: return "", ErrH3FingerprintProfileRetired + case H3FingerprintChrome202610Resume: + return H3FingerprintChrome202610Resume, nil case H3FingerprintNative: return H3FingerprintNative, nil default: - return "", fmt.Errorf("tunnel: unsupported web-cover HTTP/3 fingerprint profile %q; supported profiles are %q and %q", profile, H3FingerprintChrome202610, H3FingerprintNative) + return "", fmt.Errorf("tunnel: unsupported web-cover HTTP/3 fingerprint profile %q; supported profiles are %q, %q and %q", profile, H3FingerprintChrome202610, H3FingerprintChrome202610Resume, H3FingerprintNative) } } +func isWebH3ChromeProfile(profile H3FingerprintProfile) bool { + return profile == H3FingerprintChrome202610 || profile == H3FingerprintChrome202610Resume +} + // webH2TLSClientConn keeps the rest of the HTTP/2 transport independent of // the TLS implementation while exposing the standard-library state shape used // by diagnostics and tests. diff --git a/internal/tunnel/web_fingerprint_test.go b/internal/tunnel/web_fingerprint_test.go index de773e1..3e88a7e 100644 --- a/internal/tunnel/web_fingerprint_test.go +++ b/internal/tunnel/web_fingerprint_test.go @@ -55,6 +55,9 @@ func TestH3FingerprintProfileValidation(t *testing.T) { if got, err := normalizeH3FingerprintProfile(H3FingerprintNative); err != nil || got != H3FingerprintNative { t.Fatalf("native H3 profile = %q, %v", got, err) } + if got, err := normalizeH3FingerprintProfile(H3FingerprintChrome202610Resume); err != nil || got != H3FingerprintChrome202610Resume { + t.Fatalf("opt-in resumed H3 profile = %q, %v", got, err) + } if _, err := normalizeH3FingerprintProfile("chrome-current"); err == nil || !strings.Contains(err.Error(), "unsupported") { t.Fatalf("unversioned H3 profile error = %v", err) } diff --git a/internal/tunnel/web_h3_client.go b/internal/tunnel/web_h3_client.go index a17af87..53760ec 100644 --- a/internal/tunnel/web_h3_client.go +++ b/internal/tunnel/web_h3_client.go @@ -15,6 +15,7 @@ import ( "github.com/apernet/quic-go/http3" "github.com/cppla/autocar/internal/protocol" "github.com/cppla/autocar/internal/transport" + utls "github.com/refraction-networking/utls" ) const webH3HappyEyeballsDelay = 250 * time.Millisecond @@ -26,7 +27,9 @@ type WebH3ClientConfig struct { TLSConfig *tls.Config QUICConfig *quic.Config // FingerprintProfile defaults to chrome-2026-10, a fixed full QUIC - // handshake profile. Native is retained for interoperability and rollback. + // handshake profile. chrome-2026-10-resume explicitly enables ticket + // resumption when TLSConfig permits it, without 0-RTT. Native is retained + // for interoperability and rollback. FingerprintProfile H3FingerprintProfile DialTimeout time.Duration HandshakeTimeout time.Duration @@ -166,6 +169,12 @@ func NewWebH3Client(config WebH3ClientConfig) (*WebH3Client, error) { handshakeTimeout = defaultHandshakeTimeout } quicConfig := hardenedWebH3ClientConfig(config.QUICConfig, handshakeTimeout, fingerprint) + if fingerprint == H3FingerprintChrome202610Resume && tlsConfig.ClientSessionCache != nil && !tlsConfig.SessionTicketsDisabled { + // Native uTLS sessions are opaque and cannot share crypto/tls cache + // entries. Keep a bounded cache private to this H3 client, independent + // of its H2 fallback and every other client made from the same config. + quicConfig.ChromeParrotSessionCache = utls.NewLRUClientSessionCache(64) + } maxUDPSessions, err := normalizedUDPCount(config.MaxUDPSessions, defaultClientMaxUDPSessions, "maximum web-cover UDP sessions") if err != nil { return nil, err @@ -208,14 +217,14 @@ func NewWebH3Client(config WebH3ClientConfig) (*WebH3Client, error) { } func validateWebH3FingerprintTLSConfig(config *tls.Config, profile H3FingerprintProfile) error { - if profile != H3FingerprintChrome202610 { + if !isWebH3ChromeProfile(profile) { return nil } if config.VerifyConnection != nil { - return errors.New("tunnel: chrome-2026-10 HTTP/3 fingerprint profile does not support crypto/tls VerifyConnection; use VerifyPeerCertificate or the native rollback profile") + return fmt.Errorf("tunnel: %s HTTP/3 fingerprint profile does not support crypto/tls VerifyConnection; use VerifyPeerCertificate or the native rollback profile", profile) } if config.GetConfigForClient != nil || config.GetCertificate != nil || len(config.Certificates) > 0 { - return errors.New("tunnel: chrome-2026-10 HTTP/3 fingerprint profile does not support server-side TLS fields or static client certificates") + return fmt.Errorf("tunnel: %s HTTP/3 fingerprint profile does not support server-side TLS fields or static client certificates", profile) } return nil } @@ -661,7 +670,7 @@ func (c *WebH3Client) dialSessionAddress(ctx context.Context, address net.IPAddr return nil, fmt.Errorf("listen %s for %s: %w", network, address.String(), err) } quicTransport := &quic.Transport{Conn: packet} - if c.fingerprint == H3FingerprintChrome202610 { + if isWebH3ChromeProfile(c.fingerprint) { quicTransport.ConnectionIDGenerator = quic.ZeroLengthConnectionIDGenerator{} } remote := &net.UDPAddr{IP: address.IP, Port: port, Zone: address.Zone} diff --git a/internal/tunnel/web_h3_config.go b/internal/tunnel/web_h3_config.go index 4bb99b2..f5bd12b 100644 --- a/internal/tunnel/web_h3_config.go +++ b/internal/tunnel/web_h3_config.go @@ -57,6 +57,7 @@ func hardenWebH3ServerConfig(input *quic.Config, maxStreams int, handshakeTimeou // ChromeParrot is a client-only wire profile. The public cover server keeps // ordinary server behavior even if a caller reuses a client config. cfg.ChromeParrot = false + cfg.ChromeParrotSessionCache = nil cfg.HandshakeIdleTimeout = boundedPositiveDuration(cfg.HandshakeIdleTimeout, handshakeTimeout) cfg.MaxIdleTimeout = boundedPositiveDuration(cfg.MaxIdleTimeout, webH3MaxIdleTimeout) // A server keepalive would retain unauthenticated cover connections. @@ -79,7 +80,10 @@ func hardenedWebH3ClientConfig(input *quic.Config, handshakeTimeout time.Duratio cfg.MaxIncomingUniStreams = webH3RequiredIncomingUniStreams cfg.Allow0RTT = false cfg.EnableDatagrams = true - cfg.ChromeParrot = profile == H3FingerprintChrome202610 + cfg.ChromeParrot = isWebH3ChromeProfile(profile) + // The client constructor alone owns the opt-in cache and its lifetime. + // Never retain a caller's cache across profiles or client instances. + cfg.ChromeParrotSessionCache = nil cfg.HandshakeIdleTimeout = boundedPositiveDuration(cfg.HandshakeIdleTimeout, handshakeTimeout) cfg.MaxIdleTimeout = boundedPositiveDuration(cfg.MaxIdleTimeout, webH3MaxIdleTimeout) // A deterministic keepalive interval is a strong long-lived-flow marker. diff --git a/internal/tunnel/web_h3_resumption_cancel_test.go b/internal/tunnel/web_h3_resumption_cancel_test.go new file mode 100644 index 0000000..920184b --- /dev/null +++ b/internal/tunnel/web_h3_resumption_cancel_test.go @@ -0,0 +1,202 @@ +package tunnel + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +func TestWebH3ResumptionHandshakeCancellation(t *testing.T) { + for _, mode := range []string{"caller_cancel", "client_close"} { + t.Run(mode, func(t *testing.T) { + serverTLS, clientTLS := webH2ResumptionTLSConfigs(t) + clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(4) + serverTLS.SetSessionTicketKeys([][32]byte{{1, 2, 3}}) + entered, release := make(chan struct{}), make(chan struct{}) + var signal, unblock sync.Once + defer unblock.Do(func() { close(release) }) + serverTLS.UnwrapSession = func(identity []byte, state tls.ConnectionState) (*tls.SessionState, error) { + signal.Do(func() { close(entered) }) + <-release + return serverTLS.DecryptTicket(identity, state) + } + target, closeTarget := startHalfCloseTarget(t) + t.Cleanup(closeTarget) + var dials atomic.Int32 + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: countingDialer{dials: &dials}, Cover: http.NotFoundHandler(), + }) + if err != nil { + t.Fatal(err) + } + serveWebH3ForTest(t, server) + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: server.Addr().String(), Token: webTestToken, TLSConfig: clientTLS, + FingerprintProfile: H3FingerprintChrome202610Resume, DialTimeout: 2 * time.Second, HandshakeTimeout: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cache := watchWebH3UTLSTickets(t, client) + entropy := &webH2AuthEntropyCounter{} + client.signer = newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, entropy) + if err := exchange(client, target, "prime resumption cancellation"); err != nil { + t.Fatal(err) + } + waitWebH3Ticket(t, cache.stored) + first := webH3SelectedSession(t, client) + client.retire(first.conn) + assertWebH3SessionRetired(t, client, first) + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(context.Canceled) + opened := make(chan error, 1) + go func() { + conn, err := client.DialContext(ctx, "tcp", target) + if conn != nil { + _ = conn.Close() + } + opened <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("warm handshake did not reach real server ticket processing") + } + client.mu.Lock() + attempt := client.dial + client.mu.Unlock() + if attempt == nil { + t.Fatal("warm handshake finished before the cancellation gate") + } + want := error(net.ErrClosed) + if mode == "caller_cancel" { + want = errors.New("cancel warm H3 ticket handshake") + cancel(want) + } else { + closed := make(chan error, 1) + go func() { closed <- client.Close() }() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("Close did not join the unfinished resumed handshake") + } + } + select { + case err := <-opened: + if !errors.Is(err, want) { + t.Fatalf("warm handshake cancellation = %v, want %v", err, want) + } + case <-time.After(3 * time.Second): + t.Fatal("warm handshake ignored cancellation") + } + unblock.Do(func() { close(release) }) + select { + case <-attempt.done: + case <-time.After(3 * time.Second): + t.Fatal("shared warm handshake did not finish after release") + } + if dials.Load() != 1 || entropy.nonceReads.Load() != 1 { + t.Fatal("canceled warm handshake reached proxy authentication or a destination") + } + }) + } +} + +func TestWebH3ResumptionCanceledContinuationKeepsHealthySibling(t *testing.T) { + serverTLS, clientTLS := webH2ResumptionTLSConfigs(t) + clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(4) + entered, left := make(chan struct{}), make(chan struct{}) + const blocked = "blocked.invalid:9" + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, Cover: http.NotFoundHandler(), + Dialer: transport.DialFunc(func(ctx context.Context, network, target string) (net.Conn, error) { + if target == blocked { + close(entered) + <-ctx.Done() + close(left) + return nil, context.Cause(ctx) + } + return (&net.Dialer{}).DialContext(ctx, network, target) + }), + }) + if err != nil { + t.Fatal(err) + } + serveWebH3ForTest(t, server) + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: server.Addr().String(), Token: webTestToken, TLSConfig: clientTLS, + FingerprintProfile: H3FingerprintChrome202610Resume, DialTimeout: time.Second, HandshakeTimeout: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cache := watchWebH3UTLSTickets(t, client) + target, closeTarget := startHalfCloseTarget(t) + t.Cleanup(closeTarget) + if err := exchange(client, target, "prime sibling test"); err != nil { + t.Fatal(err) + } + waitWebH3Ticket(t, cache.stored) + first := webH3SelectedSession(t, client) + client.retire(first.conn) + assertWebH3SessionRetired(t, client, first) + sibling, err := client.DialContext(context.Background(), "tcp", startWebTCPEcho(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sibling.Close() }) + warm := webH3SelectedSession(t, client) + if !warm.conn.ConnectionState().TLS.DidResume { + t.Fatal("healthy sibling is not on a resumed physical connection") + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + opened := make(chan error, 1) + go func() { + conn, err := client.DialContext(ctx, "tcp", blocked) + if conn != nil { + _ = conn.Close() + } + opened <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("continuation never reached the destination gate") + } + cancel() + select { + case err := <-opened: + if !errors.Is(err, context.Canceled) { + t.Fatalf("continuation cancellation = %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("continuation cancellation did not return") + } + select { + case <-left: + case <-time.After(3 * time.Second): + t.Fatal("server retained the canceled destination dial") + } + assertWebH3StreamEcho(t, sibling) + if err := exchange(client, target, "healthy later continuation"); err != nil { + t.Fatal(err) + } + if webH3SelectedSession(t, client) != warm || warm.conn.Context().Err() != nil { + t.Fatal("one canceled stream retired the healthy resumed connection") + } +} diff --git a/internal/tunnel/web_h3_resumption_policy_test.go b/internal/tunnel/web_h3_resumption_policy_test.go new file mode 100644 index 0000000..e9458f9 --- /dev/null +++ b/internal/tunnel/web_h3_resumption_policy_test.go @@ -0,0 +1,241 @@ +package tunnel + +import ( + "context" + "crypto/tls" + "net/http" + "net/netip" + "sync/atomic" + "testing" + "time" + + "github.com/apernet/quic-go" + "github.com/apernet/quic-go/http3" + utls "github.com/refraction-networking/utls" +) + +func TestWebH3ResumptionCachePolicyAndIsolation(t *testing.T) { + _, clientTLS := webH2ResumptionTLSConfigs(t) + clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(4) + supplied := utls.NewLRUClientSessionCache(4) + input := &quic.Config{ChromeParrotSessionCache: supplied, Allow0RTT: true} + for _, test := range []struct { + name string + profile H3FingerprintProfile + nilCache bool + disabled bool + want bool + }{ + {name: "default"}, + {name: "fixed", profile: H3FingerprintChrome202610}, + {name: "native", profile: H3FingerprintNative}, + {name: "opt_in", profile: H3FingerprintChrome202610Resume, want: true}, + {name: "nil_cache", profile: H3FingerprintChrome202610Resume, nilCache: true}, + {name: "tickets_disabled", profile: H3FingerprintChrome202610Resume, disabled: true}, + } { + t.Run(test.name, func(t *testing.T) { + config := clientTLS.Clone() + config.SessionTicketsDisabled = test.disabled + if test.nilCache { + config.ClientSessionCache = nil + } + var previous utls.ClientSessionCache + for range 2 { + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: "relay.invalid:443", Token: webTestToken, + TLSConfig: config, QUICConfig: input, FingerprintProfile: test.profile, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cache := client.quicConfig.ChromeParrotSessionCache + if (cache != nil) != test.want || cache == supplied || (cache != nil && cache == previous) { + t.Fatal("H3 cache policy retained a shared cache or ignored explicit opt-in/TLS policy") + } + if client.quicConfig.Allow0RTT { + t.Fatal("H3 client allowed early application data") + } + if cache != client.quicConfig.Clone().ChromeParrotSessionCache { + t.Fatal("physical reconnect did not retain this client's cache") + } + previous = cache + } + }) + } + if input.ChromeParrotSessionCache != supplied || !input.Allow0RTT { + t.Fatal("client constructor mutated the caller's QUIC config") + } + server := hardenedWebH3ServerConfig(input, 8, time.Second) + if server.ChromeParrotSessionCache != nil || server.Allow0RTT || server.ChromeParrot { + t.Fatal("server retained client resumption or early-data policy") + } + client, err := NewWebClient(WebClientConfig{ + ServerAddress: "relay.invalid:443", Token: webTestToken, TLSConfig: clientTLS, + H3FingerprintProfile: H3FingerprintChrome202610Resume, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + h3 := client.primary.dialer.(*WebH3Client) + h2 := client.fallback.dialer.(*WebH2Client) + if h3.quicConfig.ChromeParrotSessionCache == nil || h2.utlsSessionCache == nil || h3.quicConfig.ChromeParrotSessionCache == h2.utlsSessionCache { + t.Fatal("web-auto H2 and H3 do not own independent native uTLS caches") + } +} + +func TestWebH3ResumptionRejectsUnsupportedTLSCallbacks(t *testing.T) { + for _, profile := range []H3FingerprintProfile{H3FingerprintChrome202610, H3FingerprintChrome202610Resume} { + t.Run(string(profile), func(t *testing.T) { + _, config := webH2ResumptionTLSConfigs(t) + config.ClientSessionCache = tls.NewLRUClientSessionCache(4) + config.VerifyConnection = func(tls.ConnectionState) error { return nil } + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: "relay.invalid:443", Token: webTestToken, + TLSConfig: config, FingerprintProfile: profile, + }) + if client != nil { + _ = client.Close() + t.Fatal("unsupported TLS callback was silently ignored") + } + if err == nil { + t.Fatal("unsupported TLS callback did not fail before network access") + } + }) + } +} + +func TestWebH3ResumptionConnectUDPReauthenticatesAndRejectsOldContinuations(t *testing.T) { + serverTLS, clientTLS := webH2ResumptionTLSConfigs(t) + clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(4) + tcpTarget, closeTarget := startHalfCloseTarget(t) + t.Cleanup(closeTarget) + udpTarget := startWebUDPEcho(t) + resolver := newWebUDPTestResolver(map[string]netip.AddrPort{udpTarget.String(): udpTarget}) + var dials atomic.Int32 + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: countingDialer{dials: &dials}, UDPResolver: resolver, Cover: http.NotFoundHandler(), + }) + if err != nil { + t.Fatal(err) + } + observed := make(chan webH3ResumptionRequest, 8) + handler := server.server.Handler + server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn := r.Context().Value(webH3ConnectionContextKey{}).(*quic.Conn) + auth := r.Context().Value(webServerConnectionAuthContextKey{}).(*webServerConnectionAuth) + observed <- webH3ResumptionRequest{conn: conn, state: conn.ConnectionState(), auth: auth, authPhase: auth.phaseSnapshot()} + handler.ServeHTTP(w, r) + }) + serveWebH3ForTest(t, server) + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: server.Addr().String(), Token: webTestToken, TLSConfig: clientTLS, + FingerprintProfile: H3FingerprintChrome202610Resume, DialTimeout: time.Second, HandshakeTimeout: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cache := watchWebH3UTLSTickets(t, client) + entropy := &webH2AuthEntropyCounter{} + client.signer = newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, entropy) + readObservation := func(wantResume bool, wantPhase webServerConnectionAuthPhase) webH3ResumptionRequest { + t.Helper() + select { + case event := <-observed: + if event.state.TLS.DidResume != wantResume || event.state.Used0RTT || !event.state.TLS.HandshakeComplete || event.authPhase != wantPhase { + t.Fatalf("server resumed=%v 0-RTT=%v complete=%v auth=%v, want %v/false/true/%v", event.state.TLS.DidResume, event.state.Used0RTT, event.state.TLS.HandshakeComplete, event.authPhase, wantResume, wantPhase) + } + return event + case <-time.After(3 * time.Second): + t.Fatal("server request observation missing") + return webH3ResumptionRequest{} + } + } + openUDP := func(payload string) { + t.Helper() + packet, err := client.DialPacket(context.Background()) + if err != nil { + t.Fatal(err) + } + defer packet.Close() + assertWebUDPEcho(t, packet, []byte(payload), udpTarget.String()) + } + openUDP("cold UDP bootstrap") + coldServer := readObservation(false, webServerConnectionAuthFresh) + first := webH3SelectedSession(t, client) + firstAuth := first.auth + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + path, _, err := connectUDPPath(udpTarget.String()) + if err != nil { + t.Fatal(err) + } + var oldRequests []*http.Request + for _, udp := range []bool{false, true} { + binding := webAuthBinding{transport: webAuthTransportH3, method: http.MethodConnect, authority: tcpTarget} + if udp { + binding.authority, binding.protocol, binding.path = server.Addr().String(), webConnectUDPProtocol, path + } + bearer, exchange, err := firstAuth.authorization(ctx, binding) + if err != nil { + t.Fatal(err) + } + firstAuth.complete(exchange) + request := newWebH2ConnectRequest(tcpTarget, bearer) + // http3 uses Proto for Extended CONNECT's :protocol, not HTTP version. + request.Proto = "" + if udp { + request = newConnectUDPTestRequest(t, server.Addr().String(), path, bearer) + } + oldRequests = append(oldRequests, request) + } + waitWebH3Ticket(t, cache.stored) + assertWebH3SessionUsers(t, client, first, 0) + client.retire(first.conn) + assertWebH3SessionRetired(t, client, first) + conn, h3, err := client.connection(ctx) + if err != nil { + t.Fatal(err) + } + if state := conn.ConnectionState(); !state.TLS.DidResume || state.Used0RTT || !state.TLS.HandshakeComplete || len(state.TLS.VerifiedChains) == 0 { + t.Fatal("physical UDP reconnect did not resume a verified 1-RTT TLS session") + } + // A valid short credential from the previous physical connection is not + // authentication on the resumed connection, for either request protocol. + for _, request := range oldRequests { + stream, err := h3.OpenRequestStream(ctx) + if err != nil { + t.Fatal(err) + } + _ = stream.SetDeadline(time.Now().Add(2 * time.Second)) + if err := stream.SendRequestHeader(request); err != nil { + t.Fatal(err) + } + response, err := stream.ReadResponse() + stream.CancelRead(quic.StreamErrorCode(http3.ErrCodeRequestCanceled)) + stream.CancelWrite(quic.StreamErrorCode(http3.ErrCodeRequestCanceled)) + if err != nil || response.StatusCode != http.StatusNotFound { + t.Fatalf("old continuation rejection: response=%v error=%v", response, err) + } + readObservation(true, webServerConnectionAuthFresh) + if dials.Load() != 0 || resolver.count(udpTarget.String()) != 1 { + t.Fatal("old continuation reached a TCP or UDP destination") + } + } + openUDP("resumed UDP requires fresh bootstrap") + warmServer := readObservation(true, webServerConnectionAuthFresh) + warm := webH3SelectedSession(t, client) + if warm.conn != conn || warm == first || warm.auth == firstAuth || warm.auth.key == firstAuth.key || warmServer.auth == coldServer.auth || warmServer.conn == coldServer.conn { + t.Fatal("resumed UDP request reused the old proxy-authentication session") + } + if err := exchange(client, tcpTarget, "resumed TCP continuation"); err != nil { + t.Fatal(err) + } + readObservation(true, webServerConnectionAuthEstablished) + if entropy.nonceReads.Load() != 2 || dials.Load() != 1 || resolver.count(udpTarget.String()) != 2 { + t.Fatalf("bootstrap/TCP/UDP count=%d/%d/%d, want 2/1/2", entropy.nonceReads.Load(), dials.Load(), resolver.count(udpTarget.String())) + } +} diff --git a/internal/tunnel/web_h3_resumption_test.go b/internal/tunnel/web_h3_resumption_test.go index 92a80c6..74ac0af 100644 --- a/internal/tunnel/web_h3_resumption_test.go +++ b/internal/tunnel/web_h3_resumption_test.go @@ -11,6 +11,7 @@ import ( "github.com/apernet/quic-go" "github.com/apernet/quic-go/http3" + utls "github.com/refraction-networking/utls" ) type webH3ResumptionCache struct { @@ -39,6 +40,49 @@ func (c *webH3ResumptionCache) Get(key string) (*tls.ClientSessionState, bool) { return state, ok } +type webH3ResumptionUTLSCache struct { + inner utls.ClientSessionCache + stored chan struct{} + once sync.Once + puts atomic.Int64 + hits atomic.Int64 +} + +func (c *webH3ResumptionUTLSCache) Put(key string, state *utls.ClientSessionState) { + c.inner.Put(key, state) + if state != nil { + c.puts.Add(1) + c.once.Do(func() { close(c.stored) }) + } +} + +func (c *webH3ResumptionUTLSCache) Get(key string) (*utls.ClientSessionState, bool) { + state, ok := c.inner.Get(key) + if ok { + c.hits.Add(1) + } + return state, ok +} + +func watchWebH3UTLSTickets(t *testing.T, client *WebH3Client) *webH3ResumptionUTLSCache { + t.Helper() + if client.quicConfig.ChromeParrotSessionCache == nil { + t.Fatal("opt-in client did not allocate a private uTLS session cache") + } + cache := &webH3ResumptionUTLSCache{inner: client.quicConfig.ChromeParrotSessionCache, stored: make(chan struct{})} + client.quicConfig.ChromeParrotSessionCache = cache + return cache +} + +func waitWebH3Ticket(t *testing.T, stored <-chan struct{}) { + t.Helper() + select { + case <-stored: + case <-time.After(3 * time.Second): + t.Fatal("TLS cache never received a real session ticket") + } +} + type webH3ResumptionRequest struct { conn *quic.Conn state quic.ConnectionState @@ -56,9 +100,14 @@ func TestWebH3ResumptionReconnectContract(t *testing.T) { cacheEnabled bool ticketsDisabled bool wantResume bool + rejectTicket bool }{ {name: "native_cache", profile: H3FingerprintNative, cacheEnabled: true, wantResume: true}, {name: "default_chrome_cache", cacheEnabled: true}, + {name: "chrome_resume_cache", profile: H3FingerprintChrome202610Resume, cacheEnabled: true, wantResume: true}, + {name: "chrome_resume_rejected_ticket", profile: H3FingerprintChrome202610Resume, cacheEnabled: true, rejectTicket: true}, + {name: "chrome_resume_nil_cache", profile: H3FingerprintChrome202610Resume}, + {name: "chrome_resume_tickets_disabled", profile: H3FingerprintChrome202610Resume, cacheEnabled: true, ticketsDisabled: true}, {name: "native_nil_cache", profile: H3FingerprintNative}, {name: "native_tickets_disabled", profile: H3FingerprintNative, cacheEnabled: true, ticketsDisabled: true}, {name: "default_chrome_tickets_disabled", cacheEnabled: true, ticketsDisabled: true}, @@ -72,6 +121,13 @@ func TestWebH3ResumptionReconnectContract(t *testing.T) { t.Fatal(err) } serverTLS.SetSessionTicketKeys([][32]byte{ticketKey}) + var rejectedTickets atomic.Int32 + if test.rejectTicket { + serverTLS.UnwrapSession = func([]byte, tls.ConnectionState) (*tls.SessionState, error) { + rejectedTickets.Add(1) + return nil, nil + } + } cache := &webH3ResumptionCache{inner: tls.NewLRUClientSessionCache(4), stored: make(chan struct{})} clientTLS.ClientSessionCache = nil if test.cacheEnabled { @@ -126,6 +182,14 @@ func TestWebH3ResumptionReconnectContract(t *testing.T) { if test.profile == "" && client.fingerprint != H3FingerprintChrome202610 { t.Fatal("default H3 profile changed") } + stored, puts, hits := cache.stored, &cache.puts, &cache.hits + wantTicketUse := test.wantResume || test.rejectTicket + if test.profile == H3FingerprintChrome202610Resume && wantTicketUse { + chromeCache := watchWebH3UTLSTickets(t, client) + stored, puts, hits = chromeCache.stored, &chromeCache.puts, &chromeCache.hits + } else if client.quicConfig.ChromeParrotSessionCache != nil { + t.Fatal("full-handshake control allocated a Chrome session cache") + } entropy := &webH2AuthEntropyCounter{} client.signer = newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, entropy) @@ -135,12 +199,8 @@ func TestWebH3ResumptionReconnectContract(t *testing.T) { var resumed [3]bool for phase, name := range []string{"cold", "same_connection_stream", "physical_reconnect"} { if phase == 2 { - if test.wantResume { - select { - case <-cache.stored: - case <-time.After(3 * time.Second): - t.Fatal("native TLS cache never received a real session ticket") - } + if wantTicketUse { + waitWebH3Ticket(t, stored) } assertWebH3SessionUsers(t, client, first, 0) client.retire(first.conn) @@ -216,14 +276,20 @@ func TestWebH3ResumptionReconnectContract(t *testing.T) { if dials.Load() != 3 { t.Errorf("authenticated destination dials=%d, want 3", dials.Load()) } - if test.wantResume { - if cache.puts.Load() == 0 || cache.hits.Load() == 0 { - t.Error("native resumption lacked a real stored/loaded ticket") + if wantTicketUse { + if puts.Load() == 0 || hits.Load() == 0 { + t.Error("resumption lacked a real stored/loaded ticket") } - } else if cache.puts.Load() != 0 || cache.hits.Load() != 0 { + } else if puts.Load() != 0 || hits.Load() != 0 { t.Error("full-handshake control unexpectedly used the TLS cache") } - t.Logf("TLS DidResume cold/reuse/reconnect=%v; cache puts=%d hits=%d; proxy bootstraps=%d", resumed, cache.puts.Load(), cache.hits.Load(), entropy.nonceReads.Load()) + if test.profile == H3FingerprintChrome202610Resume && (cache.puts.Load() != 0 || cache.hits.Load() != 0) { + t.Fatal("uTLS resumption used the caller's crypto/tls cache") + } + if test.rejectTicket && rejectedTickets.Load() != 1 { + t.Fatalf("server ticket rejections=%d, want 1 on the same reconnect", rejectedTickets.Load()) + } + t.Logf("TLS DidResume cold/reuse/reconnect=%v; cache puts=%d hits=%d; proxy bootstraps=%d", resumed, puts.Load(), hits.Load(), entropy.nonceReads.Load()) }) } } diff --git a/internal/tunnel/web_h3_resumption_wire_test.go b/internal/tunnel/web_h3_resumption_wire_test.go new file mode 100644 index 0000000..a375c07 --- /dev/null +++ b/internal/tunnel/web_h3_resumption_wire_test.go @@ -0,0 +1,372 @@ +package tunnel + +import ( + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/apernet/quic-go" + "github.com/apernet/quic-go/quicvarint" +) + +type webH3WirePacketCounts struct { + initial, handshake, zeroRTT, oneRTT int +} + +// Only invariant, unencrypted packet boundaries and type bits are inspected. +// For v1, the long-header Length includes the protected packet number and +// ciphertext. A UDP datagram may contain several such packets; a short header +// has no Length and consumes the remainder, so ciphertext must never be scanned +// for apparent packet headers. Chrome's pinned profile permits QUIC v1 only. +func countWebH3WirePackets(datagram []byte) (webH3WirePacketCounts, error) { + var counts webH3WirePacketCounts + if len(datagram) == 0 { + return counts, io.ErrUnexpectedEOF + } + for len(datagram) > 0 { + if datagram[0]&0x40 == 0 { + return counts, errors.New("QUIC fixed bit missing") + } + if datagram[0]&0x80 == 0 { + // A protected short header needs at least a packet number and AEAD + // tag even when the destination connection ID is empty. + if len(datagram) < 18 { + return counts, io.ErrUnexpectedEOF + } + counts.oneRTT++ + return counts, nil + } + if len(datagram) < 7 || binary.BigEndian.Uint32(datagram[1:5]) != 1 { + return counts, errors.New("truncated or non-v1 QUIC long header") + } + kind := (datagram[0] >> 4) & 3 + if kind == 3 { + return counts, errors.New("client emitted a Retry packet") + } + dcidLen := int(datagram[5]) + if dcidLen > 20 || 6+dcidLen >= len(datagram) { + return counts, errors.New("invalid QUIC destination connection ID") + } + scidLen := int(datagram[6+dcidLen]) + if scidLen > 20 || 7+dcidLen+scidLen > len(datagram) { + return counts, errors.New("invalid QUIC source connection ID") + } + offset := 7 + dcidLen + scidLen + if kind == 0 { + tokenLen, n, err := quicvarint.Parse(datagram[offset:]) + if err != nil { + return counts, err + } + offset += n + if tokenLen > uint64(len(datagram)-offset) { + return counts, io.ErrUnexpectedEOF + } + offset += int(tokenLen) + } + length, n, err := quicvarint.Parse(datagram[offset:]) + if err != nil { + return counts, err + } + offset += n + if length < 17 || length > uint64(len(datagram)-offset) { + return counts, io.ErrUnexpectedEOF + } + switch kind { + case 0: + counts.initial++ + case 1: + counts.zeroRTT++ + case 2: + counts.handshake++ + } + datagram = datagram[offset+int(length):] + } + return counts, nil +} + +func TestWebH3WirePacketCounterHandlesCoalescing(t *testing.T) { + packet := func(kind byte) []byte { + b := []byte{0xc3 | kind<<4, 0, 0, 0, 1, 2, 1, 2, 0} + if kind == 0 { + b = quicvarint.Append(b, 70) + b = append(b, make([]byte, 70)...) + } + b = quicvarint.Append(b, 66) + return append(b, make([]byte, 66)...) + } + short := append([]byte{0x43}, make([]byte, 32)...) + coalesced := append(packet(0), packet(1)...) + coalesced = append(coalesced, packet(2)...) + coalesced = append(coalesced, short...) + got, err := countWebH3WirePackets(coalesced) + if err != nil || got != (webH3WirePacketCounts{initial: 1, zeroRTT: 1, handshake: 1, oneRTT: 1}) { + t.Fatalf("coalesced packet counts=%+v error=%v", got, err) + } + // These bytes are short-header ciphertext, not a second coalesced packet. + got, err = countWebH3WirePackets(append(short, packet(1)...)) + if err != nil || got != (webH3WirePacketCounts{oneRTT: 1}) { + t.Fatalf("short-header ciphertext was reparsed: counts=%+v error=%v", got, err) + } + wrongVersion := packet(0) + wrongVersion[4] = 2 + for name, invalid := range map[string][]byte{ + "empty": nil, "short_header": {0x43}, "missing_fixed_bit": {0}, + "bad_version": wrongVersion, "truncated_coalesced": append(packet(0), 0xd0), + "truncated_payload": packet(1)[:20], "retry_from_client": packet(3), + } { + if _, err := countWebH3WirePackets(invalid); err == nil { + t.Errorf("%s did not fail closed", name) + } + } +} + +type webH3RecordingProxy struct { + socket *net.UDPConn + done chan struct{} + changed chan struct{} + mu sync.Mutex + counts webH3WirePacketCounts + err error +} + +func newWebH3RecordingProxy(t *testing.T, destination string) *webH3RecordingProxy { + t.Helper() + remote, err := net.ResolveUDPAddr("udp4", destination) + if err != nil { + t.Fatal(err) + } + socket, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + p := &webH3RecordingProxy{socket: socket, done: make(chan struct{}), changed: make(chan struct{}, 1)} + t.Cleanup(func() { + _ = socket.Close() + select { + case <-p.done: + case <-time.After(2 * time.Second): + t.Error("recording UDP proxy did not stop") + } + }) + go func() { + defer close(p.done) + var client *net.UDPAddr + buffer := make([]byte, 64<<10) + for { + n, source, err := socket.ReadFromUDP(buffer) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + p.fail(err) + } + return + } + forward := remote + if source.Port == remote.Port && source.IP.Equal(remote.IP) { + if client == nil { + p.fail(errors.New("server replied before client was observed")) + return + } + forward = client + } else { + if client == nil { + client = source + } else if source.Port != client.Port || !source.IP.Equal(client.IP) { + p.fail(errors.New("recording proxy received an unexpected second client")) + return + } + counts, parseErr := countWebH3WirePackets(buffer[:n]) + if parseErr != nil { + p.fail(fmt.Errorf("outbound QUIC datagram: %w", parseErr)) + return + } + p.mu.Lock() + p.counts.initial += counts.initial + p.counts.handshake += counts.handshake + p.counts.zeroRTT += counts.zeroRTT + p.counts.oneRTT += counts.oneRTT + p.mu.Unlock() + select { + case p.changed <- struct{}{}: + default: + } + } + if _, err := socket.WriteToUDP(buffer[:n], forward); err != nil { + if !errors.Is(err, net.ErrClosed) { + p.fail(err) + } + return + } + } + }() + return p +} + +func (p *webH3RecordingProxy) fail(err error) { + p.mu.Lock() + p.err = err + p.mu.Unlock() +} + +func (p *webH3RecordingProxy) snapshot(t *testing.T) webH3WirePacketCounts { + t.Helper() + p.mu.Lock() + defer p.mu.Unlock() + if p.err != nil { + t.Fatal(p.err) + } + return p.counts +} + +type webH3WireHandshakeGate struct { + entered, release chan struct{} + once sync.Once +} + +func (g *webH3WireHandshakeGate) block() { + g.once.Do(func() { close(g.entered) }) + <-g.release +} + +func TestWebH3ResumptionWireNeverSendsEarlyApplicationData(t *testing.T) { + serverTLS, clientTLS := webH2ResumptionTLSConfigs(t) + clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(4) + serverTLS.SetSessionTicketKeys([][32]byte{{4, 5, 6}}) + certificate := serverTLS.Certificates[0] + serverTLS.Certificates = nil + var activeGate atomic.Pointer[webH3WireHandshakeGate] + serverTLS.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + activeGate.Load().block() + return &certificate, nil + } + serverTLS.UnwrapSession = func(identity []byte, state tls.ConnectionState) (*tls.SessionState, error) { + activeGate.Load().block() + return serverTLS.DecryptTicket(identity, state) + } + target, closeTarget := startHalfCloseTarget(t) + t.Cleanup(closeTarget) + var dials, requests atomic.Int32 + var prematureRequest atomic.Bool + server, err := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, + Dialer: countingDialer{dials: &dials}, Cover: http.NotFoundHandler(), + }) + if err != nil { + t.Fatal(err) + } + observations := make(chan quic.ConnectionState, 2) + handler := server.server.Handler + server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn := r.Context().Value(webH3ConnectionContextKey{}).(*quic.Conn) + state := conn.ConnectionState() + if !state.TLS.HandshakeComplete { + prematureRequest.Store(true) + } + requests.Add(1) + select { + case observations <- state: + default: + // An unexpected extra request must fail the assertion, not leave a + // server handler blocked while the failed test is cleaning up. + prematureRequest.Store(true) + } + handler.ServeHTTP(w, r) + }) + serveWebH3ForTest(t, server) + client, err := NewWebH3Client(WebH3ClientConfig{ + ServerAddress: server.Addr().String(), Token: webTestToken, TLSConfig: clientTLS, + FingerprintProfile: H3FingerprintChrome202610Resume, DialTimeout: 5 * time.Second, HandshakeTimeout: 5 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + cache := watchWebH3UTLSTickets(t, client) + entropy := &webH2AuthEntropyCounter{} + client.signer = newWebAuthSigner(mustWebAuthKey(t, webTestToken), nil, entropy) + for phase, name := range []string{"cold", "resumed"} { + t.Run(name, func(t *testing.T) { + gate := &webH3WireHandshakeGate{entered: make(chan struct{}), release: make(chan struct{})} + var release sync.Once + defer release.Do(func() { close(gate.release) }) + activeGate.Store(gate) + proxy := newWebH3RecordingProxy(t, server.Addr().String()) + // The previous physical session was fully retired. Preserve the + // verified DNS identity and client-owned cache across UDP endpoints. + client.address = proxy.socket.LocalAddr().String() + opened := make(chan error, 1) + go func() { opened <- exchange(client, target, name+" wire test") }() + select { + case <-gate.entered: + case err := <-opened: + t.Fatalf("handshake skipped the server gate: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("handshake did not reach the server gate") + } + before := proxy.snapshot(t) + if before.initial == 0 { + t.Fatal("no actual outbound Initial was observed") + } + // Wait for additional Initial traffic while TLS remains blocked, + // not a sleep or a post-handshake ConnectionState-only assertion. + // Header inspection does not distinguish ACKs from CRYPTO retries. + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + for proxy.snapshot(t).initial <= before.initial { + select { + case <-proxy.changed: + case <-proxy.done: + t.Fatal("packet recorder stopped during the gated handshake") + case <-deadline.C: + t.Fatal("no additional Initial traffic observed while handshake was blocked") + } + } + blocked := proxy.snapshot(t) + if blocked.zeroRTT != 0 || blocked.oneRTT != 0 || requests.Load() != int32(phase) || dials.Load() != int32(phase) || entropy.nonceReads.Load() != int64(phase) { + t.Fatalf("application activity before TLS completion: packets=%+v requests=%d dials=%d bootstraps=%d", blocked, requests.Load(), dials.Load(), entropy.nonceReads.Load()) + } + release.Do(func() { close(gate.release) }) + select { + case err := <-opened: + if err != nil { + t.Fatal(err) + } + case <-time.After(3 * time.Second): + t.Fatal("authenticated request did not finish after handshake release") + } + select { + case state := <-observations: + if state.TLS.DidResume != (phase == 1) || !state.TLS.HandshakeComplete || state.Used0RTT { + t.Fatal("server did not observe the expected complete 1-RTT handshake") + } + case <-time.After(time.Second): + t.Fatal("authenticated server request observation missing") + } + waitWebH3Ticket(t, cache.stored) + session := webH3SelectedSession(t, client) + if state := session.conn.ConnectionState(); state.TLS.DidResume != (phase == 1) || !state.TLS.HandshakeComplete || state.Used0RTT { + t.Fatal("client did not observe the expected complete 1-RTT handshake") + } + client.retire(session.conn) + assertWebH3SessionRetired(t, client, session) + final := proxy.snapshot(t) + if final.zeroRTT != 0 || final.initial == 0 || final.handshake == 0 || final.oneRTT == 0 || prematureRequest.Load() { + t.Fatalf("outbound packet evidence incomplete or contained early data: %+v", final) + } + t.Logf("observed outbound packets: Initial=%d Handshake=%d 0-RTT=%d 1-RTT=%d; resumed=%t", final.initial, final.handshake, final.zeroRTT, final.oneRTT, phase == 1) + }) + if t.Failed() { + return + } + } + if cache.hits.Load() == 0 || requests.Load() != 2 || dials.Load() != 2 || entropy.nonceReads.Load() != 2 { + t.Fatal("wire test lacked actual ticket reuse or fresh connection authentication") + } +} diff --git a/internal/tunnel/web_h3_test.go b/internal/tunnel/web_h3_test.go index b2abad3..813fc02 100644 --- a/internal/tunnel/web_h3_test.go +++ b/internal/tunnel/web_h3_test.go @@ -338,7 +338,16 @@ func TestWebH3AddressRaceFallsBackAcrossFamilies(t *testing.T) { } func TestWebH3DefaultChromeInitialWireShape(t *testing.T) { + for _, profile := range []H3FingerprintProfile{"", H3FingerprintChrome202610Resume} { + t.Run(string(profile), func(t *testing.T) { + testWebH3ChromeInitialWireShape(t, profile) + }) + } +} + +func testWebH3ChromeInitialWireShape(t *testing.T, profile H3FingerprintProfile) { serverTLS, clientTLS := testTLSConfigs(t) + clientTLS.ClientSessionCache = tls.NewLRUClientSessionCache(4) server, err := ListenWebH3(WebH3ServerConfig{ Address: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, Dialer: transport.DialFunc((&net.Dialer{}).DialContext), Cover: http.NotFoundHandler(), @@ -394,7 +403,7 @@ func TestWebH3DefaultChromeInitialWireShape(t *testing.T) { client, err := NewWebH3Client(WebH3ClientConfig{ ServerAddress: proxy.LocalAddr().String(), Token: webTestToken, TLSConfig: clientTLS, - DialTimeout: time.Second, HandshakeTimeout: 2 * time.Second, + FingerprintProfile: profile, DialTimeout: time.Second, HandshakeTimeout: 2 * time.Second, }) if err != nil { t.Fatal(err) diff --git a/scripts/check-dependency-boundary.sh b/scripts/check-dependency-boundary.sh index ea61392..f202c0a 100755 --- a/scripts/check-dependency-boundary.sh +++ b/scripts/check-dependency-boundary.sh @@ -3,10 +3,12 @@ set -Eeuo pipefail readonly ALLOWED_WEB_QUIC_MODULE='github.com/apernet/quic-go' readonly ALLOWED_WEB_QUIC_VERSION='v0.63.1-0.20261004180939-a10df75c260c' +readonly ALLOWED_WEB_QUIC_REPLACEMENT='github.com/cppla/quic-go' +readonly ALLOWED_WEB_QUIC_FORK_VERSION='v0.63.1-0.20261009040133-c1cae948af15' readonly ALLOWED_UTLS_MODULE='github.com/refraction-networking/utls' readonly ALLOWED_UTLS_UPSTREAM_VERSION='v1.8.3-0.20261006222701-ff1b50fbbe9a' readonly ALLOWED_UTLS_REPLACEMENT='github.com/cppla/utls' -readonly ALLOWED_UTLS_VERSION='v0.0.0-20261009014536-ff869e255a30' +readonly ALLOWED_UTLS_VERSION='v0.0.0-20261009031926-14c2a4cb1403' readonly FORBIDDEN_HYSTERIA_PATTERN='github\.com/apernet/hysteria(/|[[:space:]"`]|$)' readonly FORBIDDEN_HYSTERIA_GO_PATTERN='["`]github\.com/apernet/hysteria(/[^"`[:space:]]*)?["`]' status=0 @@ -31,20 +33,22 @@ if [[ -n ${noncanonical_module_tokens} ]]; then status=1 fi -if [[ ${ALLOWED_UTLS_VERSION} == TODO_* ]]; then - echo "error: the managed uTLS fork's published version has not been pinned" >&2 - status=1 -elif [[ ! ${ALLOWED_UTLS_VERSION} =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(0\.)?[0-9]{14}-[0-9a-f]{12}$ ]]; then - echo "error: the managed uTLS fork must use an exact published pseudo-version" >&2 - status=1 -fi - # Keep the upstream module identity for both direct and transitive imports. Only -# one global, remote, immutable replacement is allowed; a version-scoped replace -# could leave another selected upstream version unpatched. -utls_counts=$( - awk -v module="${ALLOWED_UTLS_MODULE}" -v upstream="${ALLOWED_UTLS_UPSTREAM_VERSION}" \ - -v replacement="${ALLOWED_UTLS_REPLACEMENT}" -v version="${ALLOWED_UTLS_VERSION}" ' +# one global, remote, immutable replacement per fork is allowed; a version-scoped +# replace could leave another selected upstream version unpatched. +check_managed_fork() { + local label=$1 module=$2 upstream=$3 replacement=$4 version=$5 + local counts source_count target_count require_count replace_count + if [[ ${version} == TODO_* ]]; then + echo "error: the managed ${label} fork's published version has not been pinned" >&2 + status=1 + elif [[ ! ${version} =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(0\.)?[0-9]{14}-[0-9a-f]{12}$ ]]; then + echo "error: the managed ${label} fork must use an exact published pseudo-version" >&2 + status=1 + fi + counts=$( + awk -v module="${module}" -v upstream="${upstream}" \ + -v replacement="${replacement}" -v version="${version}" ' { line = $0 sub(/[[:space:]]*\/\/.*/, "", line) @@ -62,44 +66,38 @@ utls_counts=$( part[first + 2] == replacement && part[first + 3] == version) exact_replace++ } END { print source_count + 0, target_count + 0, exact_require + 0, exact_replace + 0 } - ' go.mod -) -read -r utls_source_count utls_target_count utls_require_count utls_replace_count <<<"${utls_counts}" -if (( utls_source_count != 2 || utls_target_count != 1 || utls_require_count != 1 || utls_replace_count != 1 )); then - echo "error: require exactly ${ALLOWED_UTLS_MODULE} ${ALLOWED_UTLS_UPSTREAM_VERSION} and globally replace it with ${ALLOWED_UTLS_REPLACEMENT} ${ALLOWED_UTLS_VERSION}" >&2 - status=1 -fi + ' go.mod + ) + read -r source_count target_count require_count replace_count <<<"${counts}" + if (( source_count != 2 || target_count != 1 || require_count != 1 || replace_count != 1 )); then + echo "error: require exactly ${module} ${upstream} and globally replace it with ${replacement} ${version}" >&2 + status=1 + fi +} +check_managed_fork uTLS "${ALLOWED_UTLS_MODULE}" "${ALLOWED_UTLS_UPSTREAM_VERSION}" \ + "${ALLOWED_UTLS_REPLACEMENT}" "${ALLOWED_UTLS_VERSION}" +check_managed_fork 'web QUIC' "${ALLOWED_WEB_QUIC_MODULE}" "${ALLOWED_WEB_QUIC_VERSION}" \ + "${ALLOWED_WEB_QUIC_REPLACEMENT}" "${ALLOWED_WEB_QUIC_FORK_VERSION}" -module_counts=$( - awk -v module="${ALLOWED_WEB_QUIC_MODULE}" -v version="${ALLOWED_WEB_QUIC_VERSION}" ' +# The managed web adapter must not redirect the independent native transport. +native_quic_replacements=$( + awk ' { line = $0 sub(/[[:space:]]*\/\/.*/, "", line) sub(/^[[:space:]]+/, "", line) - sub(/[[:space:]]+$/, "", line) fields = split(line, part, /[[:space:]]+/) - for (field = 1; field <= fields; field++) { - if (part[field] == module || index(part[field], module "/") == 1) { - count++ - } - } - if (part[1] == "require" && part[2] == module) { - if (fields == 3 && part[3] == version) { - exact++ - } - } else if (part[1] == module) { - if (fields == 2 && part[2] == version) { - exact++ - } + first = part[1] == "replace" ? 2 : 1 + if ((part[first] == "github.com/quic-go/quic-go" || + index(part[first], "github.com/quic-go/quic-go/") == 1) && index(line, "=>")) { + print NR ":" line } } - END { print count + 0, exact + 0 } ' go.mod ) -read -r web_quic_count exact_web_quic_count <<<"${module_counts}" -if (( web_quic_count != 1 || exact_web_quic_count != 1 )); then - echo "error: go.mod must require exactly ${ALLOWED_WEB_QUIC_MODULE} ${ALLOWED_WEB_QUIC_VERSION}:" >&2 - awk -v module="${ALLOWED_WEB_QUIC_MODULE}" 'index($0, module) { print NR ":" $0 }' go.mod >&2 +if [[ -n ${native_quic_replacements} ]]; then + echo "error: native transport must keep the official QUIC module without replacement:" >&2 + printf '%s\n' "${native_quic_replacements}" >&2 status=1 fi @@ -115,17 +113,6 @@ else fi fi -web_quic_replacements=$( - awk -v module="${ALLOWED_WEB_QUIC_MODULE}" ' - index($0, module) && index($0, "=>") { print NR ":" $0 } - ' go.mod -) -if [[ -n ${web_quic_replacements} ]]; then - echo "error: the pinned web QUIC module must not be replaced:" >&2 - printf '%s\n' "${web_quic_replacements}" >&2 - status=1 -fi - local_replacements=$( awk ' { @@ -151,8 +138,8 @@ if [[ -n ${local_replacements} ]]; then fi while IFS= read -r -d '' source_file; do - if grep -qE '["`]github\.com/cppla/utls(/[^"`[:space:]]*)?["`]' "${source_file}"; then - echo "error: ${source_file#./} imports the replacement path directly; retain the original uTLS import path" >&2 + if grep -qE '["`]github\.com/cppla/(utls|quic-go)(/[^"`[:space:]]*)?["`]' "${source_file}"; then + echo "error: ${source_file#./} imports the replacement path directly; retain the original module import path" >&2 status=1 fi if imports=$(grep -nE "${FORBIDDEN_HYSTERIA_GO_PATTERN}" "${source_file}"); then @@ -224,7 +211,8 @@ for source_dir in \ vendor/github.com/apernet/hysteria \ vendor/github.com/apernet/quic-go \ vendor/github.com/refraction-networking/utls \ - vendor/github.com/cppla/utls; do + vendor/github.com/cppla/utls \ + vendor/github.com/cppla/quic-go; do if [[ -e ${source_dir} || -L ${source_dir} ]]; then external_sources+="${source_dir}"$'\n' fi diff --git a/scripts/check-upstream-advisories.sh b/scripts/check-upstream-advisories.sh index 2286f80..460202a 100755 --- a/scripts/check-upstream-advisories.sh +++ b/scripts/check-upstream-advisories.sh @@ -9,12 +9,12 @@ fi # govulncheck's pinned JSON handler writes each top-level OSV field on its own # line. Query mode returns success even when it emits advisory candidates. if grep -qE '^[[:space:]]*"osv"[[:space:]]*:' -- "$1"; then - echo 'Original uTLS baseline has advisory candidates. Review applicability and patches; this is not a fork reachability result.' >&2 + echo 'Original upstream baseline has advisory candidates. Review applicability and patches; this is not a fork reachability result.' >&2 exit 1 else grep_status=$? if (( grep_status != 1 )); then - echo "error: could not inspect the original uTLS advisory query" >&2 + echo "error: could not inspect the original upstream advisory query" >&2 exit "${grep_status}" fi fi diff --git a/scripts/govulncheck.sh b/scripts/govulncheck.sh index 87ec387..0a02e1a 100755 --- a/scripts/govulncheck.sh +++ b/scripts/govulncheck.sh @@ -2,12 +2,17 @@ set -Eeuo pipefail mode=${1:-source} -if (( $# > 1 )) || [[ ${mode} != source && ${mode} != --upstream-utls ]]; then - echo "usage: $0 [--upstream-utls]" >&2 +if (( $# > 1 )) || [[ ${mode} != source && ${mode} != --upstream-utls && ${mode} != --upstream-quic ]]; then + echo "usage: $0 [--upstream-utls|--upstream-quic]" >&2 exit 2 fi readonly GOVULNCHECK_VERSION=v1.7.0 +# Official source lineage of the web QUIC baseline a10df75c260c. This is +# deliberately separate from both its renamed module identity and the native +# transport's independently updatable official dependency in go.mod. Review +# and update this version whenever the web QUIC source baseline changes. +readonly WEB_QUIC_OFFICIAL_BASELINE=v0.63.0 TOOL_DIR=${RUNNER_TEMP:-/tmp}/autocar-govulncheck-${GOVULNCHECK_VERSION} mkdir -p "${TOOL_DIR}" GOBIN="${TOOL_DIR}" go install "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" @@ -22,4 +27,8 @@ if [[ ${mode} == --upstream-utls ]]; then "${TOOL_DIR}/govulncheck" -mode=query -json "github.com/refraction-networking/utls@${upstream_version}" exit fi +if [[ ${mode} == --upstream-quic ]]; then + "${TOOL_DIR}/govulncheck" -mode=query -json "github.com/quic-go/quic-go@${WEB_QUIC_OFFICIAL_BASELINE}" + exit +fi "${TOOL_DIR}/govulncheck" ./... diff --git a/scripts/test_dependency_boundary.py b/scripts/test_dependency_boundary.py index 1dd4db0..07b59c0 100644 --- a/scripts/test_dependency_boundary.py +++ b/scripts/test_dependency_boundary.py @@ -17,6 +17,12 @@ FORK_VERSION = PINS["ALLOWED_UTLS_VERSION"] if FORK_VERSION.startswith("TODO_"): FORK_VERSION = "v1.8.3-0.20261008120000-0123456789ab" +QUIC_FORK_VERSION = PINS["ALLOWED_WEB_QUIC_FORK_VERSION"] +if QUIC_FORK_VERSION.startswith("TODO_"): + QUIC_FORK_VERSION = "v0.0.0-20261009120000-0123456789ab" +FIXTURE_BOUNDARY = BOUNDARY.replace(PINS["ALLOWED_UTLS_VERSION"], FORK_VERSION).replace( + PINS["ALLOWED_WEB_QUIC_FORK_VERSION"], QUIC_FORK_VERSION +) class DependencyBoundaryTests(unittest.TestCase): @@ -25,17 +31,20 @@ def setUp(self): self.addCleanup(self.temporary.cleanup) self.root = Path(self.temporary.name) self.script = self.root / "check.sh" - self.script.write_text( - BOUNDARY.replace(PINS["ALLOWED_UTLS_VERSION"], FORK_VERSION), encoding="utf-8" - ) + self.script.write_text(FIXTURE_BOUNDARY, encoding="utf-8") self.upstream = PINS["ALLOWED_UTLS_MODULE"] self.fork = PINS["ALLOWED_UTLS_REPLACEMENT"] self.replacement = f"replace {self.upstream} => {self.fork} {FORK_VERSION}" + self.quic_upstream = PINS["ALLOWED_WEB_QUIC_MODULE"] + self.quic_fork = PINS["ALLOWED_WEB_QUIC_REPLACEMENT"] + self.quic_replacement = ( + f"replace {self.quic_upstream} => {self.quic_fork} {QUIC_FORK_VERSION}" + ) self.module = ( "module example.invalid/fixture\n\ngo 1.27.2\n\nrequire (\n" f"\t{PINS['ALLOWED_WEB_QUIC_MODULE']} {PINS['ALLOWED_WEB_QUIC_VERSION']}\n" f"\t{self.upstream} {PINS['ALLOWED_UTLS_UPSTREAM_VERSION']}\n" - ")\n\n" + self.replacement + "\n" + ")\n\n" + self.replacement + "\n" + self.quic_replacement + "\n" ) def check(self, module=None): @@ -50,13 +59,19 @@ def test_exact_remote_pin_passes(self): self.assertEqual(result.returncode, 0, result.stdout) def test_block_replacement_and_comments_pass(self): - block = f"replace (\n\t{self.upstream} => {self.fork} {FORK_VERSION} // frozen\n)" - result = self.check(self.module.replace(self.replacement, block)) + block = ( + f"replace (\n\t{self.upstream} => {self.fork} {FORK_VERSION} // frozen\n" + f"\t{self.quic_upstream} => {self.quic_fork} {QUIC_FORK_VERSION} // frozen\n)" + ) + result = self.check( + self.module.replace(self.replacement, block).replace(self.quic_replacement, "") + ) self.assertEqual(result.returncode, 0, result.stdout) def test_original_import_and_official_native_quic_remain_allowed(self): (self.root / "main.go").write_text( f'package fixture\nimport _ "{self.upstream}"\n' + f'import _ "{self.quic_upstream}/http3"\n' 'import _ "github.com/quic-go/quic-go"\n', encoding="utf-8" ) result = self.check() @@ -83,6 +98,24 @@ def test_upstream_baseline_drift_fails(self): result = self.check(self.module.replace(PINS["ALLOWED_UTLS_UPSTREAM_VERSION"], "v1.8.2")) self.assertNotEqual(result.returncode, 0, result.stdout) + def test_quic_missing_changed_scoped_or_duplicate_replacement_fails(self): + replacements = { + "missing": "", + "other owner": self.quic_replacement.replace(self.quic_fork, "github.com/other/quic-go"), + "different version": self.quic_replacement.replace(QUIC_FORK_VERSION, "v0.63.0"), + "branch": self.quic_replacement.replace(QUIC_FORK_VERSION, "main"), + "local relative": f"replace {self.quic_upstream} => ../quic-go", + "local absolute": f"replace {self.quic_upstream} => /tmp/quic-go", + "version scoped": self.quic_replacement.replace(" =>", " v0.63.0 =>"), + "duplicate": self.quic_replacement + "\n" + self.quic_replacement, + "reversed": f"replace {self.quic_fork} => {self.quic_upstream} {QUIC_FORK_VERSION}", + "submodule": self.quic_replacement.replace(self.quic_upstream, self.quic_upstream + "/http3"), + } + for label, replacement in replacements.items(): + with self.subTest(label=label): + result = self.check(self.module.replace(self.quic_replacement, replacement)) + self.assertNotEqual(result.returncode, 0, result.stdout) + def test_quoted_or_escaped_module_tokens_cannot_bypass_policy(self): escaped_upstream = r'"github.com/refraction-networking/\x75tls"' escaped_fork = r'"github.com/cppla/\u0075tls"' @@ -96,6 +129,11 @@ def test_quoted_or_escaped_module_tokens_cannot_bypass_policy(self): "quoted local path": 'replace example.invalid/other => "./other"', "quoted token": 'require "example.invalid/other" v1.0.0', "raw quoted token": f"require {chr(96)}example.invalid/other{chr(96)} v1.0.0", + "escaped QUIC scoped override": ( + r'replace "github.com/apernet/quic\x2dgo" v0.63.0' + " => github.com/other/quic-go v0.63.0" + ), + "escaped QUIC dual identity": r'require "github.com/cppla/quic\x2dgo" v0.63.0', } for label, extra in extras.items(): with self.subTest(label=label): @@ -116,6 +154,8 @@ def test_escaped_import_paths_cannot_bypass_policy(self): r' _ "github.com/cppla/\165tls"' + '\n)\n', r'package fixture; import ("fmt"; alias "github.com/cppla/\x75tls")', r'package fixture; import /* comment */ "github.com/cppla/\x75tls"', + r'package fixture; import "github.com/cppla/quic\x2dgo"', + r'package fixture; import _ "github.com/cppla/quic-go/\x68ttp3"', ) for source in sources: with self.subTest(source=source): @@ -147,7 +187,19 @@ def test_fork_cannot_be_required_or_imported_directly(self): self.assertNotEqual(result.returncode, 0, result.stdout) self.assertIn("imports the replacement path directly", result.stdout) - def test_web_quic_pin_and_no_replacement_policy_remain_enforced(self): + def test_quic_fork_cannot_be_required_or_imported_directly(self): + for path in (self.quic_fork, self.quic_fork + "/http3"): + with self.subTest(path=path): + source = self.root / "main.go" + source.unlink(missing_ok=True) + result = self.check(self.module + f"\nrequire {path} {QUIC_FORK_VERSION}\n") + self.assertNotEqual(result.returncode, 0, result.stdout) + source.write_text(f'package fixture\nimport _ "{path}"\n', encoding="utf-8") + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("imports the replacement path directly", result.stdout) + + def test_web_quic_original_baseline_remains_enforced(self): changed = self.module.replace(PINS["ALLOWED_WEB_QUIC_VERSION"], "v0.63.0") replaced = self.module + ( f"\nreplace {PINS['ALLOWED_WEB_QUIC_MODULE']} => github.com/cppla/quic-go v0.63.0\n" @@ -156,6 +208,18 @@ def test_web_quic_pin_and_no_replacement_policy_remain_enforced(self): result = self.check(module) self.assertNotEqual(result.returncode, 0, result.stdout) + def test_native_quic_cannot_be_redirected_with_web_fork(self): + for replacement in ( + "replace github.com/quic-go/quic-go => github.com/other/quic-go v0.63.0", + "replace github.com/quic-go/quic-go v0.63.0 => github.com/other/quic-go v0.63.0", + "replace (\n github.com/quic-go/quic-go => github.com/other/quic-go v0.63.0\n)", + "replace github.com/quic-go/quic-go/http3 => github.com/other/http3 v0.63.0", + ): + with self.subTest(replacement=replacement): + result = self.check(self.module + "\n" + replacement + "\n") + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("native transport must keep the official QUIC module", result.stdout) + def test_unrelated_local_replace_still_fails(self): result = self.check(self.module + "\nreplace example.invalid/other => ./other\n") self.assertNotEqual(result.returncode, 0, result.stdout) @@ -172,9 +236,29 @@ def test_copied_utls_sources_fail(self): self.assertNotEqual(result.returncode, 0, result.stdout) directory.rmdir() + def test_copied_quic_sources_fail(self): + for relative in ( + "third_party/quic-go", "vendor/github.com/apernet/quic-go", + "vendor/github.com/cppla/quic-go", + ): + with self.subTest(path=relative): + directory = self.root / relative + directory.mkdir(parents=True) + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + directory.rmdir() + + def test_symlinked_quic_sources_fail(self): + (self.root / "third_party").mkdir() + # Even a dangling symlink must not bypass the copied-source check. + (self.root / "third_party/quic-go").symlink_to(self.root / "absent") + result = self.check() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("prohibited vendored or copied", result.stdout) + def test_unpublished_placeholder_fails_closed(self): self.script.write_text( - BOUNDARY.replace(PINS["ALLOWED_UTLS_VERSION"], "TODO_PUBLISHED_CPPLA_UTLS_VERSION"), + FIXTURE_BOUNDARY.replace(FORK_VERSION, "TODO_PUBLISHED_CPPLA_UTLS_VERSION"), encoding="utf-8", ) result = self.check(self.module.replace(FORK_VERSION, "TODO_PUBLISHED_CPPLA_UTLS_VERSION")) @@ -183,12 +267,26 @@ def test_unpublished_placeholder_fails_closed(self): def test_allowlist_itself_cannot_pin_a_branch(self): self.script.write_text( - BOUNDARY.replace(PINS["ALLOWED_UTLS_VERSION"], "main"), encoding="utf-8" + FIXTURE_BOUNDARY.replace(FORK_VERSION, "main"), encoding="utf-8" ) result = self.check(self.module.replace(FORK_VERSION, "main")) self.assertNotEqual(result.returncode, 0, result.stdout) self.assertIn("exact published pseudo-version", result.stdout) + def test_quic_unpublished_or_mutable_allowlist_fails_closed(self): + for version, diagnostic in ( + ("TODO_PUBLISHED_QUIC_VERSION", "published version has not been pinned"), + ("main", "exact published pseudo-version"), + ("v0.63.0", "exact published pseudo-version"), + ): + with self.subTest(version=version): + self.script.write_text( + FIXTURE_BOUNDARY.replace(QUIC_FORK_VERSION, version), encoding="utf-8" + ) + result = self.check(self.module.replace(QUIC_FORK_VERSION, version)) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn(diagnostic, result.stdout) + class UpstreamAdvisoryGateTests(unittest.TestCase): def setUp(self): @@ -264,6 +362,16 @@ def test_query_uses_original_baseline_and_is_not_a_source_scan(self): f"{PINS['ALLOWED_UTLS_MODULE']}@{PINS['ALLOWED_UTLS_UPSTREAM_VERSION']}", ]) + def test_quic_query_uses_official_source_lineage_not_renamed_fork_or_native_version(self): + # A native transport version may differ from the web fork's lineage; + # neither that nor the renamed module is the web QUIC advisory key. + self.env["AUTOCAR_TEST_UPSTREAM_VERSION"] = "v99.0.0" + result = self.run_wrapper("--upstream-quic") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(result.stdout.splitlines(), [ + "-mode=query", "-json", "github.com/quic-go/quic-go@v0.63.0", + ]) + def test_missing_baseline_and_unknown_options_fail(self): self.env["AUTOCAR_TEST_UPSTREAM_VERSION"] = "" self.assertNotEqual(self.run_wrapper("--upstream-utls").returncode, 0) @@ -273,7 +381,7 @@ def test_query_and_source_tool_errors_propagate(self): Path(self.env["AUTOCAR_TEST_VULN_TOOL"]).write_text( '#!/bin/sh\nexit 42\n', encoding="utf-8" ) - for args in ((), ("--upstream-utls",)): + for args in ((), ("--upstream-utls",), ("--upstream-quic",)): with self.subTest(args=args): result = self.run_wrapper(*args) self.assertEqual(result.returncode, 42, result.stdout)