mirror of
https://github.com/Runnin4ik/dpi-detector.git
synced 2026-10-19 09:23:17 +00:00
quic: test 2's QUIC column sends the RFC 9000 Initial itself
`net/quic.rs` builds the hello for QUIC: the header protection, the `CRYPTO` stream and the RFC 9001 §5.2 keys; `probe/quic.rs` is the column, and `probe/quic_quinn.rs` runs the same column through `quinn` (feature `quinn-probe`, off by default — not a shipping candidate, as `Cargo.toml` says). The shapes needed two things for it: `Variant::NoGrease`, because a browser greases over TCP and not over QUIC (measured on a live Chrome's QUIC hello), and `sends_extension`/`extension_body`, so an edit starts from the shape's own bytes and a shape that does not send an extension does not get one. The vendored hook gains `ClientHelloProfile::quic`: a QUIC hello's `legacy_session_id` is empty (RFC 9001 §8.4), which the patched hook could not reach from the connection's protocol alone. `scripts/quic/` is the stand that validates the column against aioquic; `tools/quinn-stand/` is the out-of-workspace second one, and `capture-run.ps1` captures the column's traffic for a look at the wire.
This commit is contained in:
parent
7063ce34dc
commit
4836520713
53 changed files with 6206 additions and 60 deletions
|
|
@ -33,16 +33,16 @@ core, no `anyhow`, no `unwrap()` outside tests. Logs: `tracing` (`WARN`; `-v`
|
|||
|
||||
## Key Directories
|
||||
|
||||
- `crates/dpi-core/src/classify/` — `types.rs` (`DpiStatus`, 33 variants, `ConnectionStage`), `detail.rs` (`Detail`, `AlertKind`, `StackKind`), `classifier.rs` (error ⇒ verdict), `stream.rs` (`DpiProbeStream`).
|
||||
- `crates/dpi-core/src/net/` — `tcp.rs`, `tls.rs` (`TlsProfile`, cert verifier), `fingerprint/` (30 profiles: `shapes/`, `identity.rs`, `h2.rs`, `variant.rs`), `http.rs` (the h1/h2 sender with the fingerprint on it), `connector.rs` (`DpiTlsConnector`), `ja3.rs`/`ja4.rs`, `pq_kx.rs`, `cert_compression.rs`, `hpke.rs`, `follow_up.rs`, `bind.rs`, `sysinfo/`, `netinfo.rs`.
|
||||
- `crates/dpi-core/src/classify/` — `types.rs` (`DpiStatus`, 38 variants, `ConnectionStage`), `detail.rs` (`Detail`, `AlertKind`, `StackKind`), `classifier.rs` (error ⇒ verdict), `stream.rs` (`DpiProbeStream`).
|
||||
- `crates/dpi-core/src/net/` — `tcp.rs`, `tls.rs` (`TlsProfile`, cert verifier), `fingerprint/` (30 profiles: `shapes/`, `identity.rs`, `h2.rs`, `variant.rs`), `http.rs` (the h1/h2 sender with the fingerprint on it), `connector.rs` (`DpiTlsConnector`), `ja3.rs`/`ja4.rs`, `pq_kx.rs`, `quic.rs` (the RFC 9000 Initial: header protection, the `CRYPTO` stream, the RFC 9001 §5.2 keys), `cert_compression.rs`, `hpke.rs`, `follow_up.rs`, `bind.rs`, `sysinfo/`, `netinfo.rs`.
|
||||
- `crates/dpi-core/src/dns/` — `wire.rs` (RFC 1035), `udp.rs`, `doh.rs`, `dot.rs`, `socks.rs`, `resolve.rs`, `cymru.rs` (Team Cymru ASN/org over DoH TXT).
|
||||
- `crates/dpi-core/src/probe/` — one module per test: `dns_avail.rs` (1), `domains.rs` (2), `tcp16.rs` (3), `whitelist.rs` (4), `telegram.rs` (5), `burst.rs` (6), and nothing else — the transport those tests drive lives in `net/` and `dns/`.
|
||||
- `crates/dpi-core/src/probe/` — one module per test: `dns_avail.rs` (1), `domains.rs` (2), `tcp16.rs` (3), `whitelist.rs` (4), `telegram.rs` (5), `burst.rs` (6), and nothing else — the transport those tests drive lives in `net/` and `dns/`. `quic.rs` sits beside them as test 2's QUIC column: the probe's own Initial and the reply's classification, not a test of its own.
|
||||
- `crates/dpi-core/src/config.rs` — `AppConfig` (`config.yml` schema), `ConfigWarning`, embedded data lists.
|
||||
- `crates/dpi-detector/src/i18n/` — ONLY location for user-facing strings.
|
||||
- `crates/dpi-detector/src/tui/`, `views/` — terminal backend, width math, one renderer per test.
|
||||
- `vendor/` — patched upstream crates; each carries `README-PATCH.md` + `PATCH.diff`.
|
||||
- `docs/` — English. `CI.md` (CI, release, supply chain), `ADDING_A_PROFILE.md` (profile procedure), `REFACTORING.md` (invariants + phases), `OPTIMIZATIONS.md` (size log), `i18n-fa.tsv|csv` (dev-only worksheet, Russian header). `README.md` is the only Russian document: it is the user manual.
|
||||
- `tools/`, `scripts/` — `fingerprint/` (profile harness), `diag/dns-ca-report.ps1`, `bench-strategies.sh` (router benchmark).
|
||||
- `tools/`, `scripts/` — `fingerprint/` (profile harness), `diag/dns-ca-report.ps1`, `bench-strategies.sh` (router benchmark), `quic/` (the stand that validates test 2's QUIC column against aioquic — see `scripts/quic/README.md`; its client is `cargo run -p dpi-core --example quic_probe_once -- 127.0.0.1 <fingerprint>`).
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
|
|
|||
487
Cargo.lock
generated
487
Cargo.lock
generated
|
|
@ -158,6 +158,12 @@ dependencies = [
|
|||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
|
|
@ -180,6 +186,12 @@ version = "1.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
|
|
@ -191,6 +203,17 @@ dependencies = [
|
|||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
|
|
@ -198,7 +221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"chacha20 0.9.1",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
|
|
@ -259,6 +282,16 @@ version = "0.5.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "comfy-table"
|
||||
version = "7.2.2"
|
||||
|
|
@ -305,6 +338,22 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
|
|
@ -540,7 +589,8 @@ dependencies = [
|
|||
"ml-kem",
|
||||
"p256",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"quinn",
|
||||
"rand 0.8.8",
|
||||
"rustls",
|
||||
"rustls-rustcrypto",
|
||||
"ruzstd",
|
||||
|
|
@ -548,7 +598,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
|
|
@ -662,6 +712,18 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"libm",
|
||||
"portable-atomic",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
|
|
@ -690,6 +752,12 @@ version = "1.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -735,6 +803,7 @@ dependencies = [
|
|||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -755,8 +824,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1039,6 +1124,66 @@ version = "1.0.18"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.2.2"
|
||||
|
|
@ -1119,6 +1264,12 @@ version = "0.4.34"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
|
@ -1198,7 +1349,7 @@ dependencies = [
|
|||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.8",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -1259,6 +1410,12 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
|
|
@ -1398,6 +1555,12 @@ dependencies = [
|
|||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
|
|
@ -1440,6 +1603,64 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastbloom",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"slab",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
|
|
@ -1449,6 +1670,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
|
|
@ -1460,6 +1687,17 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20 0.10.2",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
|
|
@ -1476,7 +1714,7 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1485,6 +1723,15 @@ version = "0.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
|
|
@ -1512,7 +1759,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
|||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
|
|
@ -1539,6 +1786,12 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
|
|
@ -1580,21 +1833,62 @@ version = "0.23.45"
|
|||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-rustcrypto"
|
||||
version = "0.0.2-alpha"
|
||||
|
|
@ -1633,6 +1927,12 @@ dependencies = [
|
|||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.8.3"
|
||||
|
|
@ -1648,6 +1948,24 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1668,6 +1986,29 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -1816,6 +2157,28 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
|
|
@ -1915,7 +2278,16 @@ version = "1.0.69"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1929,6 +2301,17 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
|
|
@ -1969,6 +2352,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
|
|
@ -2065,6 +2454,7 @@ version = "0.1.44"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
|
@ -2214,6 +2604,16 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
|
@ -2229,6 +2629,70 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
|
|
@ -2254,6 +2718,15 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -132,7 +132,7 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
|
|||
### Номера доступных тестов:
|
||||
* `0` — **Информация о сети и системе** (внешний IP, провайдер, AS, страна, тип NAT)
|
||||
* `1` — **Доступность DNS-серверов** (проверка UDP 53, DoH Cloudflare/Google/Quad9/Yandex/AdGuard)
|
||||
* `2` — **Доступность сайтов** (HTTP, TLS 1.2, TLS 1.3, классификация блокировок DPI)
|
||||
* `2` — **Доступность сайтов** (HTTP, TLS 1.2, TLS 1.3, QUIC, классификация блокировок DPI)
|
||||
* `3` — **Доступность CDN и хостингов (тест 16 KB)** (детект сброса соединений после 14–34 КБ данных)
|
||||
* `4` — **Поиск белых SNI** (перебор рабочих SNI для заблокированных AS: цель отбирается по 16 KB-обрыву, TLS RST и TLS DROP)
|
||||
* `5` — **Доступность Telegram** (проверка доступности и скорости датацентров DC1–DC5)
|
||||
|
|
@ -149,6 +149,33 @@ curl -fsSL https://raw.githubusercontent.com/Runnin4ik/dpi-detector/rust/install
|
|||
|
||||
Что с этим делать: посмотреть, какой издатель у листа и какой корень у цепочки. Готовый отчёт для Windows собирает `tools/diag/dns-ca-report.ps1` — он печатает цепочку каждого DoH/DoT хоста, вердикт Windows, корни из «снятого с доверия» набора, корни-перехватчики в хранилище, резолв, `hosts` и NRPT, а также `results.dns_availability.failures` из `--json`. Если в цепочке виден корень антивируса, лечение одно: выключить проверку защищённых соединений (SSL/HTTPS-инспекцию) в его настройках или добавить `dpi-detector` в исключения — до тех пор любые замеры DoH/DoT на этой машине будут показывать чужой сертификат вместо сертификата сервера.
|
||||
|
||||
### Тест 2: колонка QUIC
|
||||
|
||||
Четвёртая колонка теста 2 отвечает на вопрос «работает ли QUIC-путь до этого хоста»: на тот же адрес, что и соседние колонки, уходит Initial на UDP 443, внутри `CRYPTO`-фрейма — ClientHello в форме выбранного отпечатка: только TLS 1.3, `h3` в ALPN, расширение `quic_transport_parameters`, которого требует любой эндпоинт, и **пустое** `legacy_session_id` (RFC 9001 §8.4: непустое поле сервер вправе считать нарушением протокола — Cloudflare на такое отвечает `illegal_parameter` вместо ServerHello). Ключи Initial выводятся из идентификатора соединения, который выбрала сама проба (RFC 9001 §5.2), поэтому ответ читается без всякого TLS-состояния:
|
||||
|
||||
| вердикт | что значит |
|
||||
|---|---|
|
||||
| `OK` | эндпоинт ответил на рукопожатие: ServerHello (или HelloRetryRequest), либо Retry с верным integrity-тегом. UDP-путь до 443 работает, ничего не отфильтровало Initial, HTTP/3-стек эндпоинта жив |
|
||||
| `CLOSED` | ответил защищённым `CONNECTION_CLOSE` или stateless reset: путь работает, рукопожатие — нет. В детали код transport error десятичным числом (как и токен `quic_close_<код>` в `--json`); коды 256…511 — это `CRYPTO_ERROR`, и тогда TLS-алерт равен коду минус 256, то есть отказ на уровне рукопожатия, а не сети. Отдельные случаи в деталях: `quic_unreadable_reply` — ответ пришёл, но не открывается ни одним ключом соединения (так эдж отвечает на первый Initial: пакет выглядит как Initial, но его DCID пуст, хотя RFC 9000 §7.2 требует в первом ответе SCID клиента, и под ключами из нашего DCID (RFC 9001 §5.2) он не открывается ни в пробнике, ни в Wireshark; настоящий клиент такой пакет молча отбрасывает — RFC 9001 §5.4 — и пробник делает то же); `quic_answered_without_handshake` — ответ открылся, но данных рукопожатия в нём не было вовсе |
|
||||
| `VN` | эндпоинт не поддерживает v1 и перечислил версии, которые умеет |
|
||||
| `SPOOF` | Retry с неверным integrity-тегом: посчитать его может только тот, кто видел наш Initial, поэтому пакет пришёл не от эндпоинта |
|
||||
| `DROP` | на Initial не ответили вовсе — та же тишина, что `SYN DROP` для TCP. Пробник повторяет полёт по таймауту потери, как это делает настоящий клиент (RFC 9002 §6.2: 0.67 с, затем вдвое), поэтому «тишина» здесь означает, что эндпоинт не ответил ни на один из четырёх полётов за окно |
|
||||
| `REFUSED` | ICMP «на UDP-порту никто не слушает»: путь работает, а QUIC-слушателя у эндпоинта нет вовсе — это не блокировка (блокировка выглядит как `DROP`) |
|
||||
|
||||
Чего колонка **не** доказывает: что сайт отвечает по HTTP/3 со статусом `:status 200`. Для этого нужен весь обмен рукопожатия и запрос, а у провайдера в дереве нет QUIC-поддержки (`quic: None` у всех суит — `docs/ADDING_A_PROFILE.md`, §6). Колонка говорит про путь: UDP 443 не фильтруется, эндпоинт жив и разобрал именно этот ClientHello. Адрес берётся тот же, что у соседних колонок, — A/AAAA хоста, а не адрес из `Alt-Svc`, которым пользуется браузер, поэтому `DROP` может означать «H3 у этого хоста на другом адресе», а не блокировку.
|
||||
|
||||
Transport parameters у нашего hello — **браузерные, а не минимальные**, и это не косметика. RFC 9114 §6.2.1 требует от H3-клиента дать серверу возможность открыть свои служебные потоки (control и QPACK), а клиент, который не прислал параметров, оставляет все лимиты на нулевых значениях по умолчанию (RFC 9000 §18.2). Замерено на стоковом клиенте с обнулёнными лимитами: Cloudflare закрывает такое соединение с `Error opening control stream` (transport error 258, TLS-алерт 2), а Google не отвечает вовсе. Поэтому набор взят из захвата настоящего Chrome (скрипт `scripts/quic/tp_dump.py`): `max_idle_timeout` 30000, `max_udp_payload_size` 1472, `initial_max_data` 15728640, три stream-data по 6291456, `initial_max_streams_bidi` 100, `initial_max_streams_uni` 103, `active_connection_id_limit` 2 — плюс `initial_source_connection_id`, который обязан совпадать с SCID нашего же заголовка (RFC 9000 §7.3). До этой правки колонка читала `CLOSED` с кодом 10 на `www.google.com` и `www.youtube.com`, где браузер получает `HTTP/3 200`. Тем же сравнением с живым Chrome нашлась вторая неточность формы: расширение application settings (`0x44cd`) у нас несло `h2` — верное для TCP, но в QUIC Chrome объявляет `h3` (`0003026833`), и QUIC-hello теперь правит это тело, причём только у форм, которые это расширение вообще шлют. Третья: `status_request` (0x0005) и SCT (0x0012) Chrome шлёт в TCP-hello, но **не** в QUIC-hello — QUIC-вариант их теперь снимает (и снимает безусловно: `status_request` у rustls типизированное расширение, а не запись формы, поэтому «снимать только у форм, которые его шлют» оставляло его на проводе — это видно в захвате).
|
||||
|
||||
Четвёртая неточность, найденная тем же способом (захват каждого клиента + `tp_dump.py --extensions`): в QUIC-hello Chrome список `signature_algorithms` на два байта длиннее, чем в TCP — он добавляет `rsa_pkcs1_sha1` (`0201`), бесполезный в TLS 1.3 и потому выброшенный из TCP-формы; у quinn, который эти эндпоинты тоже принимают, тело такое же двадцатибайтное. QUIC-hello теперь добавляет его. Оговорка честности: `www.apkmirror.com` отвечает нестабильно — шесть одиночных прогонов после правки дали один `OK` и пять `CLOSED`, — поэтому считать эту правку доказанным исправлением apkmirror нельзя. Пятая и последняя: пробник **завершал обмен на первом же ответе**. Захват показал, что Meta шлёт короткий (short-header) пакет **до** читаемого Initial со своим ServerHello, а Cloudflare отвечает на первый полёт одним подтверждением и отдаёт ServerHello только на **повтор**; пробник на коротком пакете бросал цикл, а на пустом Initial прекращал повторы. Теперь и то и другое запоминается, но не завершает обмен (`Verdict::settled`), и повтор идёт, пока не пришёл handshake, закрытие, Retry или version negotiation. Замерено: `www.facebook.com`, `www.instagram.com`, `www.messenger.com` и `www.apkmirror.com` дают `OK` — двенадцать прогонов из двенадцати, а полный свип по 34 хостам согласуется с обоими внешними тестерами: все строки, кроме `OK`, — это ровно те 14 хостов, которые тестеры называют «нет HTTP/3». Остаются строки, где закрытие (296/336) читает и пробник, и стоковый клиент, а проходит только форсированный браузер в обход обнаружения: `aws.amazon.com`, `soundcloud.com`, `www.currenttime.tv`, `www.svoboda.org`, `www.coursera.org`.
|
||||
|
||||
Заодно измерением снят ложный след: GREASE у нас был везде (шифр, два расширения, группа, версия), а у обоих работающих клиентов его нет вовсе — ни у Chrome в QUIC, ни у quinn в его минимальном 288-байтном hello. Снятие GREASE (`HelloVariant::NoGrease`, теперь применяется в QUIC-hello) не изменило **ничего** на apkmirror, facebook и instagram; вариант оставлен потому, что так шлёт браузер, а не потому, что он что-то починил.
|
||||
|
||||
Hello браузера не влезает в одну датаграмму: одна только постквантовая доля ключа — 1216 байт (`X25519MLKEM768`), поэтому первый полёт разбивается на столько Initial по 1200 байт, сколько нужно, а `CRYPTO`-фрейм в следующих пакетах продолжает поток по своему смещению (RFC 9000 §14.1 и §19.6). Урезать hello ради одного пакета значило бы отправлять не ту форму, которую отправляет клиент.
|
||||
|
||||
Полёт повторяется, если ответа нет: клиент обязан так делать при потере (RFC 9002 §6.2 — первый таймаут равен удвоенной начальной оценке RTT, 333 мс, дальше вдвое), и это не формальность. Замерено: эдж Cloudflare на **первый** Initial отвечает пакетом, который не открывается ни одним ключом соединения (см. `quic_unreadable_reply` выше), а читаемый ServerHello посылает только на повторный полёт — стоковый клиент получает `HTTP/3 200` там, где пробник без повторов видел тишину. Отсюда и окно: `QUIC_TIMEOUT` в `config.yml` по умолчанию 8 с, потому что повтор уходит на ~0.7, 2 и 4.7 с, а ответ на него приходит ещё позже; четыре полёта за окно и один ответ — это то, что делает настоящий клиент. Число одновременных проб — общий `MAX_CONCURRENT`. В `--json` колонка видна как `results.domain_inspection[].quic` (токен `DpiStatus`) и `quic_detail` (код `Detail`).
|
||||
|
||||
Когда отпечаток прогона — базовый `rustls`, колонка берёт форму браузера: у базового hello нет профиля, к которому можно прицепить транспортные параметры, и без них любой эндпоинт ответил бы `TRANSPORT_PARAMETER_ERROR` — колонка читалась бы как `CLOSED` на всех хостах, которые H3 отдают.
|
||||
|
||||
---
|
||||
|
||||
### Блок о перехвате: печатается перед тестами
|
||||
|
|
|
|||
|
|
@ -347,6 +347,12 @@ TELEGRAM_DCS:
|
|||
- ["149.154.167.91", "DC4"]
|
||||
- ["91.108.56.130", "DC5"]
|
||||
|
||||
# === Тест 2: колонка QUIC (HTTP/3) ===
|
||||
# Цели — тот же список, что у остальных колонок теста 2 (domains.txt или -d),
|
||||
# проверяются те же адреса, что у TCP-колонок, на UDP 443.
|
||||
QUIC_TIMEOUT: 8.0 # сек ожидания ответа на Initial: эдж отвечает
|
||||
# и на повторный полёт, а он уходит по PTO ~0.7/2/4.7 с
|
||||
|
||||
# === Кэш DNS-пининга ===
|
||||
PIN_CACHE_MAX: 256 # максимум записей
|
||||
PIN_CACHE_TTL: 3600.0 # время жизни записи, сек
|
||||
|
|
|
|||
|
|
@ -20,12 +20,23 @@ publish = false
|
|||
# `cargo test -p dpi-core --features live-network`. No dependency rides on it.
|
||||
live-network = []
|
||||
|
||||
# Experiment: run the QUIC column through `quinn` instead of the hand-written
|
||||
# Initial. Not a shipping candidate — `quinn` needs a QUIC-capable crypto
|
||||
# provider, and the only one reachable here is `ring` (assembly, no MIPS target,
|
||||
# Rule 1) — so this feature exists to measure that cost, not to be enabled.
|
||||
# `probe/quic_quinn.rs` has the details; off by default (Rule 2).
|
||||
quinn-probe = ["dep:quinn"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
tokio-rustls = { workspace = true }
|
||||
webpki-roots = { workspace = true }
|
||||
rustls-rustcrypto = { workspace = true }
|
||||
# Only for the `quinn-probe` experiment; see `[features]` above and
|
||||
# `probe/quic_quinn.rs`. Deliberately not in `[workspace.dependencies]`: the
|
||||
# point of the measurement is that this dependency must not reach the workspace.
|
||||
quinn = { version = "0.11", optional = true }
|
||||
hyper = { workspace = true }
|
||||
http2 = { workspace = true }
|
||||
hyper-util = { workspace = true }
|
||||
|
|
|
|||
31
crates/dpi-core/examples/quic_probe_once.rs
Normal file
31
crates/dpi-core/examples/quic_probe_once.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! One QUIC Initial against a chosen address and fingerprint: the stand's client.
|
||||
//!
|
||||
//! The detector's own CLI never probes a loopback address (`127.0.0.1` is
|
||||
//! classified as a local IP), so this is how the probe is pointed at the lab's
|
||||
//! UDP tap (`tools/fingerprint/utls/lab --quic-tap-port 443 --quic-upstream
|
||||
//! <host>:443`) or at a local endpoint under test.
|
||||
//!
|
||||
//! `cargo run -p dpi-core --example quic_probe_once -- 127.0.0.1 [fingerprint]`
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use dpi_core::config::AppConfig;
|
||||
use dpi_core::net::fingerprint::TlsFingerprint;
|
||||
use dpi_core::probe::quic::check_domain_quic;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let arg = std::env::args().nth(1).unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let target: IpAddr = arg.parse().expect("an IP address");
|
||||
let mut cfg = AppConfig::default();
|
||||
if let Some(name) = std::env::args().nth(2) {
|
||||
let fingerprint = TlsFingerprint::parse(&name).expect("a known fingerprint");
|
||||
cfg.tls_fingerprint = fingerprint.code().to_string();
|
||||
}
|
||||
println!("fingerprint: {}", cfg.fingerprint().code());
|
||||
let check = check_domain_quic("localhost", target, &cfg).await;
|
||||
println!("status: {:?}", check.status);
|
||||
println!("token: {}", check.status.as_str());
|
||||
println!("detail: {:?}", check.detail);
|
||||
println!("elapsed: {:.2}s", check.elapsed);
|
||||
}
|
||||
|
|
@ -103,6 +103,46 @@ pub enum Detail {
|
|||
UnknownConnectionFailure,
|
||||
Ipv6Unsupported,
|
||||
|
||||
// ─── QUIC Initial (test 2) ───
|
||||
/// The endpoint answered the QUIC handshake: a ServerHello, or the
|
||||
/// HelloRetryRequest that asks for another key share.
|
||||
QuicServerHello,
|
||||
/// The endpoint answered with a Retry, and its integrity tag checked out
|
||||
/// (RFC 9001 §5.8) — so the packet came from the endpoint and not from a
|
||||
/// router that decided to imitate one.
|
||||
QuicRetry,
|
||||
/// A Retry whose integrity tag does not match: only a party that saw the
|
||||
/// Initial can compute that tag, so this packet was made by something else
|
||||
/// on the path.
|
||||
QuicForgedRetry,
|
||||
/// The endpoint closed the connection at the QUIC layer. `error_code` is the
|
||||
/// transport error of RFC 9000 §20.1 (`8` is
|
||||
/// `TRANSPORT_PARAMETER_ERROR`, which is what a server answers a ClientHello
|
||||
/// that is not a QUIC one with) or the application's own code in the
|
||||
/// application-close form.
|
||||
QuicClose { error_code: u64 },
|
||||
/// A stateless reset (RFC 9000 §10.3): something answered with no state for
|
||||
/// this connection, which a fresh Initial should never meet.
|
||||
QuicReset,
|
||||
/// The endpoint does not speak v1 and listed the versions it does.
|
||||
QuicVersionNegotiation,
|
||||
/// Nothing answered the Initial inside the window.
|
||||
QuicTimeout,
|
||||
/// Something answered the Initial and the reply did not open under the
|
||||
/// connection's own Initial keys: measured against Cloudflare's edge, the
|
||||
/// packet a first flight meets looks like an Initial (long header, a source
|
||||
/// connection ID, a length field) and decrypts under no key the client can
|
||||
/// derive — a stock client ignores it and waits for the next flight, and so
|
||||
/// does this probe. The path works; the handshake does not start.
|
||||
QuicUnreadableReply,
|
||||
/// The reply opened, and it carried no handshake data at all — an
|
||||
/// acknowledgement and padding, no `CRYPTO` frame — for the whole window,
|
||||
/// repeats included. The path works and the endpoint answered; it did not
|
||||
/// answer the handshake.
|
||||
QuicAnsweredWithoutHandshake,
|
||||
/// ICMP said nothing listens on the UDP port.
|
||||
QuicPortUnreachable,
|
||||
|
||||
// ─── Composed ───
|
||||
/// `<head> at <n>KB`: how far the transfer got — the whole kilobytes the
|
||||
/// 16–20 KB test counts, the measured ones test 2 reports.
|
||||
|
|
@ -320,6 +360,16 @@ impl Detail {
|
|||
IcmpAdminProhibited => Cow::Borrowed("icmp_admin_prohibited"),
|
||||
UnknownConnectionFailure => Cow::Borrowed("unknown_connection_failure"),
|
||||
Ipv6Unsupported => Cow::Borrowed("ipv6_unsupported"),
|
||||
QuicServerHello => Cow::Borrowed("quic_server_hello"),
|
||||
QuicRetry => Cow::Borrowed("quic_retry"),
|
||||
QuicForgedRetry => Cow::Borrowed("quic_forged_retry"),
|
||||
QuicClose { error_code } => Cow::Owned(format!("quic_close_{error_code}")),
|
||||
QuicReset => Cow::Borrowed("quic_stateless_reset"),
|
||||
QuicVersionNegotiation => Cow::Borrowed("quic_version_negotiation"),
|
||||
QuicTimeout => Cow::Borrowed("quic_timeout"),
|
||||
QuicUnreadableReply => Cow::Borrowed("quic_unreadable_reply"),
|
||||
QuicAnsweredWithoutHandshake => Cow::Borrowed("quic_answered_without_handshake"),
|
||||
QuicPortUnreachable => Cow::Borrowed("quic_port_unreachable"),
|
||||
AtKb { head, kb } => Cow::Owned(format!("{}_at_{}", head.code(), kb_token(*kb))),
|
||||
TimeoutStage { stage } => Cow::Owned(format!("timeout_{}", stage)),
|
||||
IspBlockpage { .. } => Cow::Borrowed("isp_blockpage"),
|
||||
|
|
|
|||
|
|
@ -224,6 +224,25 @@ pub enum DpiStatus {
|
|||
/// `nxdomain` — not the derived `nx_domain`.
|
||||
#[serde(rename = "nxdomain")]
|
||||
NxDomain,
|
||||
/// The QUIC endpoint answered the Initial and the handshake can proceed: a
|
||||
/// ServerHello, a HelloRetryRequest, or a Retry whose integrity tag checked
|
||||
/// out. It says the UDP path to port 443 works and the endpoint's QUIC stack
|
||||
/// is running — not that the site answered HTTP/3, which needs a request.
|
||||
QuicOk,
|
||||
/// The endpoint answered with a protected `CONNECTION_CLOSE`, or with a
|
||||
/// stateless reset: something on the other side holds the Initial keys (or
|
||||
/// has no state for this connection), so the path is not filtered, but the
|
||||
/// handshake will not run.
|
||||
QuicClosed,
|
||||
/// Version negotiation: the endpoint does not speak v1, and lists what it
|
||||
/// does. The path works; the probe's version does not.
|
||||
QuicVn,
|
||||
/// A Retry whose integrity tag does not match (RFC 9001 §5.8): the packet
|
||||
/// cannot have been computed by a party that saw the Initial, so something
|
||||
/// on the path answered in the endpoint's place.
|
||||
QuicSpoof,
|
||||
/// Nothing came back within the window: the silence `SYN DROP` is for TCP.
|
||||
QuicDrop,
|
||||
Err,
|
||||
Unknown,
|
||||
}
|
||||
|
|
@ -266,6 +285,11 @@ impl DpiStatus {
|
|||
DpiStatus::DnsFail,
|
||||
DpiStatus::DnsFake,
|
||||
DpiStatus::NxDomain,
|
||||
DpiStatus::QuicOk,
|
||||
DpiStatus::QuicClosed,
|
||||
DpiStatus::QuicVn,
|
||||
DpiStatus::QuicSpoof,
|
||||
DpiStatus::QuicDrop,
|
||||
DpiStatus::Err,
|
||||
DpiStatus::Unknown,
|
||||
];
|
||||
|
|
@ -303,6 +327,11 @@ impl DpiStatus {
|
|||
Self::DnsFail => "dns_fail",
|
||||
Self::DnsFake => "dns_fake",
|
||||
Self::NxDomain => "nxdomain",
|
||||
Self::QuicOk => "quic_ok",
|
||||
Self::QuicClosed => "quic_closed",
|
||||
Self::QuicVn => "quic_vn",
|
||||
Self::QuicSpoof => "quic_spoof",
|
||||
Self::QuicDrop => "quic_drop",
|
||||
Self::Err => "err",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
|
|
@ -341,6 +370,11 @@ impl DpiStatus {
|
|||
Self::DnsFail => "DNS FAIL",
|
||||
Self::DnsFake => "DNS FAKE",
|
||||
Self::NxDomain => "NXDOMAIN",
|
||||
Self::QuicOk => "OK",
|
||||
Self::QuicClosed => "CLOSED",
|
||||
Self::QuicVn => "VN",
|
||||
Self::QuicSpoof => "SPOOF",
|
||||
Self::QuicDrop => "DROP",
|
||||
Self::Err => "ERR",
|
||||
Self::Unknown => "UNKNOWN",
|
||||
}
|
||||
|
|
@ -372,6 +406,10 @@ impl DpiStatus {
|
|||
| Self::DnsFake
|
||||
| Self::SynDropped
|
||||
| Self::TlsDropped
|
||||
// Silence on the QUIC Initial is the same class of verdict as
|
||||
// silence on a SYN: nothing answered, which is what a filter and
|
||||
// a dead endpoint both look like from here.
|
||||
| Self::QuicDrop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ fn d_telegram_dc_port() -> u16 { 443 }
|
|||
fn d_domains_file() -> String { "domains.txt".to_string() }
|
||||
fn d_tcp16_file() -> String { "tcp16.json".to_string() }
|
||||
fn d_whitelist_sni_file() -> String { "whitelist_sni.txt".to_string() }
|
||||
fn d_quic_timeout() -> f64 { 8.0 }
|
||||
|
||||
fn d_dns_check_domains() -> Vec<String> {
|
||||
vec![
|
||||
|
|
@ -446,6 +447,11 @@ pub struct AppConfig {
|
|||
pub tcp16_file: String,
|
||||
#[serde(default = "d_whitelist_sni_file")]
|
||||
pub whitelist_sni_file: String,
|
||||
/// Test 2's QUIC column: seconds one target's Initial waits for a reply. A
|
||||
/// ServerHello needs one round trip, so the default is generous for a router
|
||||
/// link and short enough that a silent target does not hold the run.
|
||||
#[serde(default = "d_quic_timeout")]
|
||||
pub quic_timeout: f64,
|
||||
#[serde(default)]
|
||||
pub debug: bool,
|
||||
#[serde(skip)]
|
||||
|
|
@ -532,6 +538,7 @@ impl Default for AppConfig {
|
|||
domains_file: d_domains_file(),
|
||||
tcp16_file: d_tcp16_file(),
|
||||
whitelist_sni_file: d_whitelist_sni_file(),
|
||||
quic_timeout: d_quic_timeout(),
|
||||
debug: false,
|
||||
config_load_error: None,
|
||||
config_warnings: Vec::new(),
|
||||
|
|
@ -556,7 +563,7 @@ const KNOWN_KEYS: &[&str] = &[
|
|||
"PIN_CACHE_TTL", "CONCURRENCY_PRESETS", "BYPASS_TOOLS", "DNS_KNOWN_RESOLVER_NAMES",
|
||||
"DNS_HIJACK_EXEMPT_RESOLVERS",
|
||||
"DNS_STUB_THRESHOLD", "DEBUG", "CYMRU_DOH_SERVERS", "IP4_LOOKUP_URLS",
|
||||
"IP6_LOOKUP_URLS", "IP_LOOKUP_URLS",
|
||||
"IP6_LOOKUP_URLS", "IP_LOOKUP_URLS", "QUIC_TIMEOUT",
|
||||
];
|
||||
|
||||
/// True when `value` can be an HTTP header value: `http` refuses every control
|
||||
|
|
@ -621,7 +628,7 @@ fn sanitize_mapping(mapping: &mut serde_yaml::Mapping, warnings: &mut Vec<Config
|
|||
| "DNS_CHECK_TIMEOUT" | "DNS_AVAILABILITY_TIMEOUT" | "FAT_CONNECT_TIMEOUT"
|
||||
| "FAT_READ_TIMEOUT" | "FAT_CHUNK_DELAY" | "TELEGRAM_MEDIA_SIZE_MB"
|
||||
| "TELEGRAM_UPLOAD_SIZE_MB" | "TELEGRAM_STALL_TIMEOUT" | "TELEGRAM_TOTAL_TIMEOUT"
|
||||
| "TELEGRAM_DC_PING_TIMEOUT" => {
|
||||
| "TELEGRAM_DC_PING_TIMEOUT" | "QUIC_TIMEOUT" => {
|
||||
if !is_num(v) || v.as_f64().is_none_or(|f| f <= 0.0) {
|
||||
false
|
||||
} else {
|
||||
|
|
@ -1408,6 +1415,7 @@ mod tests {
|
|||
assert!(cfg.dns_known_resolver_names.contains(&"google".to_string()));
|
||||
assert!(cfg.dns_known_resolver_names.contains(&"yandex".to_string()));
|
||||
assert_eq!(cfg.dns_hijack_exempt_resolvers, vec!["MSK-IX", "НСДИ"]);
|
||||
assert_eq!(cfg.quic_timeout, 8.0);
|
||||
}
|
||||
|
||||
/// No-file fallbacks carry the same lists (embedded use without config.yml).
|
||||
|
|
@ -1426,6 +1434,7 @@ mod tests {
|
|||
assert_eq!(def.dns_known_resolver_names, from_yml.dns_known_resolver_names);
|
||||
assert_eq!(def.dns_hijack_exempt_resolvers, from_yml.dns_hijack_exempt_resolvers);
|
||||
assert_eq!(def.concurrency_presets, from_yml.concurrency_presets);
|
||||
assert_eq!(def.quic_timeout, from_yml.quic_timeout);
|
||||
}
|
||||
|
||||
/// Embedded fallbacks parse to the same lists as the shipped files.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ pub enum PhaseId {
|
|||
DomainTls12,
|
||||
/// Phase 3: Plain HTTP request probes
|
||||
DomainHttp,
|
||||
/// Phase 4: QUIC Initial probes (UDP 443)
|
||||
DomainQuic,
|
||||
/// TCP 16–20 KB window throttling test
|
||||
Tcp16,
|
||||
/// Phase 1/2 of whitelist discovery: base check
|
||||
|
|
@ -61,6 +63,8 @@ pub enum ProgressBlock {
|
|||
DomainTls12,
|
||||
/// Test 2 stage 3: plain HTTP requests
|
||||
DomainHttp,
|
||||
/// Test 2 stage 4: QUIC Initials on UDP 443
|
||||
DomainQuic,
|
||||
}
|
||||
|
||||
impl ProgressBlock {
|
||||
|
|
@ -74,6 +78,7 @@ impl ProgressBlock {
|
|||
ProgressBlock::DomainTls13 => "TLS 1.3",
|
||||
ProgressBlock::DomainTls12 => "TLS 1.2",
|
||||
ProgressBlock::DomainHttp => "HTTP",
|
||||
ProgressBlock::DomainQuic => "QUIC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,8 +140,13 @@ pub use variant::{HelloVariant, HelloVariantError};
|
|||
pub(crate) use variant::install_variant;
|
||||
|
||||
pub(crate) use shapes::{
|
||||
EXT_APPLICATION_SETTINGS, EXT_APPLICATION_SETTINGS_NEW, EXT_FAKE_CHANNEL_ID,
|
||||
EXT_APPLICATION_SETTINGS, EXT_APPLICATION_SETTINGS_NEW, EXT_FAKE_CHANNEL_ID, EXT_SCT,
|
||||
EXT_SIGNATURE_ALGORITHMS, EXT_STATUS_REQUEST,
|
||||
};
|
||||
// The two list-carrying extensions the QUIC hello's GREASE test walks; nothing in
|
||||
// the library itself needs them by name.
|
||||
#[cfg(test)]
|
||||
pub(crate) use shapes::{EXT_SUPPORTED_GROUPS, EXT_SUPPORTED_VERSIONS};
|
||||
use shapes::{TlsShape, SHAPES};
|
||||
|
||||
/// Which ClientHello shape the probes present.
|
||||
|
|
@ -238,6 +243,20 @@ impl TlsFingerprint {
|
|||
&SHAPES[shape_index(self)]
|
||||
}
|
||||
|
||||
/// Whether this shape's ClientHello carries `id` verbatim. The QUIC column
|
||||
/// asks before it edits an extension a shape may not send at all: adding one
|
||||
/// to Firefox would be a shape no client sends.
|
||||
pub(crate) fn sends_extension(self, id: u16) -> bool {
|
||||
self.spec().raw_exts.iter().any(|(ext, _)| *ext == id)
|
||||
}
|
||||
|
||||
/// The body the shape sends for an extension, if it sends it at all — the
|
||||
/// counterpart of [`Self::sends_extension`] for the edits that have to start
|
||||
/// from the shape's own bytes rather than a fresh body.
|
||||
pub(crate) fn extension_body(self, id: u16) -> Option<&'static [u8]> {
|
||||
self.spec().raw_exts.iter().find(|(ext, _)| *ext == id).map(|(_, body)| *body)
|
||||
}
|
||||
|
||||
/// Canonical uppercase token for tables and logs. Never translated (rule 4).
|
||||
pub fn token(self) -> &'static str {
|
||||
self.spec().token
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ pub(crate) const CHROME_TLS_RAW_EXTS: &[(u16, &[u8])] = &[
|
|||
(EXT_SCT, &[]),
|
||||
// session_ticket: empty in a fresh session. rustls drops the extension from
|
||||
// a TLS 1.3-only hello, Chrome sends it in both, so the profile supplies it
|
||||
// — without this the TLS 1.3 column and test 7 sent a Chrome hello with one
|
||||
// — without this the TLS 1.3 and QUIC columns sent a Chrome hello with one
|
||||
// extension less than Chrome's.
|
||||
(EXT_SESSION_TICKET, &[]),
|
||||
// ALPS: one protocol, h2.
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ impl TlsShape {
|
|||
cert_compression: Some(self.cert_compression.to_vec()),
|
||||
padding_to: self.padding_to,
|
||||
legacy_versions: self.legacy_versions.to_vec(),
|
||||
quic: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ pub enum HelloVariant {
|
|||
SigalgSwap,
|
||||
/// Open the extension list with a GREASE slot, as Chrome does.
|
||||
GreaseExtra,
|
||||
/// Drop every GREASE value (RFC 8701): the cipher, the supported group, the
|
||||
/// version and the extension slots. A browser greases over TCP and does not
|
||||
/// over QUIC — measured on a live Chrome's QUIC hello, which carries none —
|
||||
/// so the QUIC column applies this, while a shape that greases keeps doing so
|
||||
/// on TCP.
|
||||
NoGrease,
|
||||
/// Append an extension, sent as an empty body.
|
||||
AddExtension(u16),
|
||||
/// Replace an extension's body, keeping its code point and its place in the
|
||||
|
|
@ -163,6 +169,7 @@ impl HelloVariant {
|
|||
match name {
|
||||
"sigalg-swap" => Ok(Self::SigalgSwap),
|
||||
"+grease" => Ok(Self::GreaseExtra),
|
||||
"-grease" => Ok(Self::NoGrease),
|
||||
"+ext" => one().map(Self::AddExtension),
|
||||
"ext-body" => body(),
|
||||
"-ext" => one().map(Self::DropExtension),
|
||||
|
|
@ -185,6 +192,7 @@ impl HelloVariant {
|
|||
match self {
|
||||
Self::SigalgSwap => "sigalg-swap".into(),
|
||||
Self::GreaseExtra => "+grease".into(),
|
||||
Self::NoGrease => "-grease".into(),
|
||||
Self::AddExtension(id) => format!("+ext:{id}"),
|
||||
Self::ExtBody(id, body) => {
|
||||
let hex: String = body.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
|
|
@ -226,6 +234,15 @@ impl HelloVariant {
|
|||
}
|
||||
hello.grease = true;
|
||||
}
|
||||
Self::NoGrease => {
|
||||
// The slots go with the flag: the hook draws the cipher, the
|
||||
// group and the version from `grease`, and pushes the extension
|
||||
// bodies from the markers in the order.
|
||||
if let Some(order) = hello.extension_order.as_mut() {
|
||||
order.retain(|ext| *ext != GREASE_EXTENSION_MARKER);
|
||||
}
|
||||
hello.grease = false;
|
||||
}
|
||||
Self::AddExtension(id) => {
|
||||
if let Some(order) = hello.extension_order.as_mut() {
|
||||
order.push(*id);
|
||||
|
|
@ -454,6 +471,7 @@ mod tests {
|
|||
for text in [
|
||||
"sigalg-swap",
|
||||
"+grease",
|
||||
"-grease",
|
||||
"+ext:65037",
|
||||
"ext-body:17513:0003026832",
|
||||
"-ext:17513",
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@ pub mod netinfo;
|
|||
pub mod public_ip;
|
||||
pub mod sysinfo;
|
||||
pub mod tcp;
|
||||
pub mod quic;
|
||||
pub mod bind;
|
||||
pub(crate) mod icmp_err;
|
||||
|
|
|
|||
1170
crates/dpi-core/src/net/quic.rs
Normal file
1170
crates/dpi-core/src/net/quic.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -101,6 +101,12 @@ pub struct TlsProfile {
|
|||
/// never read: the bundle is what ships, so the answer is the same on every
|
||||
/// platform and nothing a machine has installed changes it.
|
||||
pub verify: bool,
|
||||
/// Present the hello a QUIC client sends — an empty `legacy_session_id`
|
||||
/// (RFC 9001 §8.4) — instead of the TCP compatibility-mode one. Only a probe
|
||||
/// that carries the hello in a QUIC `CRYPTO` frame wants this: over TCP the
|
||||
/// field is what every browser sends, and a server on the other side of a
|
||||
/// TCP connection expects it.
|
||||
pub quic: bool,
|
||||
}
|
||||
|
||||
impl TlsProfile {
|
||||
|
|
@ -481,6 +487,13 @@ fn apply_profile(config: &mut ClientConfig, profile: &TlsProfile) {
|
|||
config.cert_decompressors = crate::net::cert_compression::decompressors();
|
||||
}
|
||||
crate::net::fingerprint::apply(config, profile.fingerprint);
|
||||
if profile.quic {
|
||||
// Same copy-on-write rule as the ALPN override below: the shared shape
|
||||
// is cloned for the caller that asked for a QUIC hello.
|
||||
if let Some(profile) = config.hello_profile.as_mut() {
|
||||
Arc::make_mut(profile).quic = true;
|
||||
}
|
||||
}
|
||||
match &profile.alpn {
|
||||
Some(alpn) => {
|
||||
config.alpn_protocols = alpn.clone();
|
||||
|
|
|
|||
|
|
@ -382,6 +382,8 @@ pub struct DomainEntry {
|
|||
pub t13: TlsCheck,
|
||||
pub t12: TlsCheck,
|
||||
pub http: HttpCheck,
|
||||
/// The QUIC column (test 2's fourth protocol, UDP 443).
|
||||
pub quic: crate::probe::quic::QuicCheck,
|
||||
}
|
||||
|
||||
impl DomainEntry {
|
||||
|
|
@ -394,6 +396,7 @@ impl DomainEntry {
|
|||
t13: dash.clone(),
|
||||
t12: dash,
|
||||
http: HttpCheck { status: DpiStatus::Unknown, detail: Detail::None },
|
||||
quic: crate::probe::quic::QuicCheck::pending(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -680,12 +683,20 @@ fn is_timeout_detail(detail: &Detail) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
/// Builds the (http, t12, t13, details) cells: each failing protocol contributes a
|
||||
/// detail (timeouts dropped), and two passing TLS columns collapse to the best time.
|
||||
pub fn build_domain_row(e: &DomainEntry) -> (DpiStatus, DpiStatus, DpiStatus, Vec<DetailLine>) {
|
||||
/// Builds the (http, t12, t13, quic, details) cells: each failing protocol
|
||||
/// contributes a detail (timeouts dropped), and two passing TLS columns collapse
|
||||
/// to the best time.
|
||||
///
|
||||
/// The QUIC column joins them on the same terms as the others: a verdict that is
|
||||
/// not `OK` names its reason, and only a row where every protocol passed
|
||||
/// collapses to the elapsed time.
|
||||
pub fn build_domain_row(e: &DomainEntry) -> (DpiStatus, DpiStatus, DpiStatus, DpiStatus, Vec<DetailLine>) {
|
||||
let http_ok = col_ok(e.http.status);
|
||||
let t12_ok = col_ok_t12(e.t12.status);
|
||||
let t13_ok = col_ok_t12(e.t13.status);
|
||||
// `Unknown` is "not measured", not a failure: a row whose QUIC probe did not
|
||||
// run must not hide the elapsed time of the columns that did.
|
||||
let quic_failed = !matches!(e.quic.status, DpiStatus::Unknown | DpiStatus::QuicOk);
|
||||
|
||||
let mut problems: Vec<(&'static str, Detail)> = Vec::new();
|
||||
if !http_ok
|
||||
|
|
@ -701,6 +712,11 @@ pub fn build_domain_row(e: &DomainEntry) -> (DpiStatus, DpiStatus, DpiStatus, Ve
|
|||
if !t13_ok && e.t13.detail != Detail::None && !is_timeout_detail(&e.t13.detail) {
|
||||
problems.push(("T1.3", e.t13.detail.clone()));
|
||||
}
|
||||
// `DROP` keeps its line: unlike the TLS timeouts, the badge does not say
|
||||
// why nothing came back, and "no reply to the Initial" is the whole finding.
|
||||
if quic_failed && e.quic.detail != Detail::None {
|
||||
problems.push(("QUIC", e.quic.detail.clone()));
|
||||
}
|
||||
|
||||
let mut details: Vec<DetailLine> = Vec::new();
|
||||
if problems.len() == 1 {
|
||||
|
|
@ -721,7 +737,7 @@ pub fn build_domain_row(e: &DomainEntry) -> (DpiStatus, DpiStatus, DpiStatus, Ve
|
|||
}
|
||||
}
|
||||
|
||||
if t12_ok && t13_ok {
|
||||
if t12_ok && t13_ok && !quic_failed {
|
||||
let mut times: Vec<f64> = Vec::new();
|
||||
if e.t12.elapsed > 0.0 {
|
||||
times.push(e.t12.elapsed);
|
||||
|
|
@ -735,7 +751,7 @@ pub fn build_domain_row(e: &DomainEntry) -> (DpiStatus, DpiStatus, DpiStatus, Ve
|
|||
}
|
||||
}
|
||||
|
||||
(e.http.status, e.t12.status, e.t13.status, details)
|
||||
(e.http.status, e.t12.status, e.t13.status, e.quic.status, details)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -756,7 +772,7 @@ mod tests {
|
|||
entry.t12 = TlsCheck { status: DpiStatus::Ok, detail: Detail::None, elapsed: 0.35 };
|
||||
entry.t13 = TlsCheck { status: DpiStatus::Ok, detail: Detail::None, elapsed: 0.28 };
|
||||
|
||||
let (http_s, t12_s, t13_s, details) = build_domain_row(&entry);
|
||||
let (http_s, t12_s, t13_s, _quic_s, details) = build_domain_row(&entry);
|
||||
assert_eq!(http_s, DpiStatus::ReadTimeout);
|
||||
assert_eq!(http_s.display_label(), "TIMEOUT");
|
||||
assert_eq!(t12_s, DpiStatus::Ok);
|
||||
|
|
@ -771,7 +787,7 @@ mod tests {
|
|||
entry.t12 = TlsCheck { status: DpiStatus::TlsRst, detail: Detail::RstHello, elapsed: 0.1 };
|
||||
entry.t13 = TlsCheck { status: DpiStatus::TlsRst, detail: Detail::RstHello, elapsed: 0.1 };
|
||||
|
||||
let (http_s, t12_s, t13_s, details) = build_domain_row(&entry);
|
||||
let (http_s, t12_s, t13_s, _quic_s, details) = build_domain_row(&entry);
|
||||
assert_eq!(http_s, DpiStatus::ReadTimeout);
|
||||
assert_eq!(http_s.display_label(), "TIMEOUT");
|
||||
assert_eq!(t12_s, DpiStatus::TlsRst);
|
||||
|
|
@ -787,7 +803,7 @@ mod tests {
|
|||
entry.t12 = TlsCheck { status: DpiStatus::TlsDropped, detail: Detail::TlsHandshakeTimeout, elapsed: 5.0 };
|
||||
entry.t13 = TlsCheck { status: DpiStatus::TlsDropped, detail: Detail::TlsHandshakeTimeout, elapsed: 5.0 };
|
||||
|
||||
let (http_s, t12_s, t13_s, details) = build_domain_row(&entry);
|
||||
let (http_s, t12_s, t13_s, _quic_s, details) = build_domain_row(&entry);
|
||||
assert_eq!(http_s, DpiStatus::ReadTimeout);
|
||||
assert_eq!(http_s.display_label(), "TIMEOUT");
|
||||
assert_eq!(t12_s, DpiStatus::TlsDropped);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@ pub mod dns_avail;
|
|||
pub mod tcp16;
|
||||
pub mod domains;
|
||||
pub mod burst;
|
||||
pub mod quic;
|
||||
#[cfg(feature = "quinn-probe")]
|
||||
pub mod quic_quinn;
|
||||
pub mod telegram;
|
||||
pub mod whitelist;
|
||||
pub use quic::{check_quic_all, QuicCheck};
|
||||
pub use burst::{burst_targets, BurstAttempt, BurstProfileReport, BurstReport, BurstSettings, BurstTarget};
|
||||
pub use tcp16::check_tcp_16_20;
|
||||
pub use telegram::{probe_telegram_all_dcs, probe_telegram_dc, run_download, run_telegram_full, run_telegram_test, run_upload, TelegramDcResult, TelegramFullReport, TelegramReport, TransferStats, TransferStatus};
|
||||
|
|
|
|||
913
crates/dpi-core/src/probe/quic.rs
Normal file
913
crates/dpi-core/src/probe/quic.rs
Normal file
|
|
@ -0,0 +1,913 @@
|
|||
//! Test 2's QUIC column: one padded Initial per domain, and what answers it.
|
||||
//!
|
||||
//! The column asks the same host the TLS columns ask, on UDP 443, with one
|
||||
//! 1200-byte Initial whose `CRYPTO` frame carries a browser-shaped ClientHello:
|
||||
//! TLS 1.3 only, `h3` in ALPN, and the `quic_transport_parameters` extension
|
||||
//! every endpoint requires (RFC 9000 §7.3) — without it a server answers
|
||||
//! `TRANSPORT_PARAMETER_ERROR` and never starts the handshake. The shape is the
|
||||
//! run's own fingerprint, so what goes out is the hello the other columns send,
|
||||
//! minus the TCP record header.
|
||||
//!
|
||||
//! The Initial keys are derived from the connection ID this probe chose
|
||||
//! (RFC 9001 §5.2), so the reply is readable without any TLS state. What the
|
||||
//! verdict means:
|
||||
//!
|
||||
//! * `OK` — the endpoint decrypted this Initial and answered the handshake
|
||||
//! (a ServerHello, or a Retry whose integrity tag checked out). The UDP path
|
||||
//! to port 443 works, nothing filtered the Initial, and the endpoint's QUIC
|
||||
//! stack is running.
|
||||
//! * `CLOSED` — it answered with a protected `CONNECTION_CLOSE` or a
|
||||
//! stateless reset: the path works, the handshake will not run.
|
||||
//! * `VN` — version negotiation: the endpoint does not speak v1.
|
||||
//! * `SPOOF` — a Retry whose integrity tag does not match, i.e. a packet
|
||||
//! that did not come from the endpoint.
|
||||
//! * `DROP` — nothing came back, the silence `SYN DROP` is for TCP.
|
||||
//!
|
||||
//! What it does *not* say: that the site answers HTTP/3 with `:status 200`. That
|
||||
//! needs the whole handshake and a request, which needs a QUIC-capable provider
|
||||
//! (`docs/ADDING_A_PROFILE.md` §6) — and a domain whose QUIC endpoint is simply
|
||||
//! absent is the ordinary case, not a finding: the column is read against the
|
||||
//! TLS columns beside it.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::classify::{classify_connect_error_icmp, Detail, DpiStatus, ProbeStage};
|
||||
use crate::config::AppConfig;
|
||||
use crate::net::bind::udp_socket;
|
||||
use crate::net::fingerprint::{
|
||||
HelloVariant, TlsFingerprint, EXT_APPLICATION_SETTINGS, EXT_APPLICATION_SETTINGS_NEW, EXT_SCT,
|
||||
EXT_SIGNATURE_ALGORITHMS, EXT_STATUS_REQUEST,
|
||||
};
|
||||
use crate::net::quic::{self, CryptoStream, Frame, ServerReply, SERVER_HELLO};
|
||||
use crate::net::tls::{hello_record_for, TlsProfile, TlsVersion};
|
||||
use crate::probe::domains::DomainEntry;
|
||||
use crate::probe::permit;
|
||||
use crate::{PhaseId, PhaseProgress};
|
||||
|
||||
/// The QUIC transport-parameters extension (RFC 9000 §7.4.1 / RFC 9001 §8.2).
|
||||
/// rustls emits it only on a QUIC connection, so it is injected as a raw
|
||||
/// extension body — which is what the ClientHello-profile hook is for.
|
||||
const EXT_TRANSPORT_PARAMETERS: u16 = 0x0039;
|
||||
|
||||
/// `initial_source_connection_id` (RFC 9000 §18.2): the one parameter an
|
||||
/// endpoint must see, or it aborts with `TRANSPORT_PARAMETER_ERROR`.
|
||||
const TP_INITIAL_SOURCE_CID: u64 = 0x0f;
|
||||
|
||||
/// The rest of the set, by RFC 9000 §18.2 identifier.
|
||||
const TP_MAX_IDLE_TIMEOUT: u64 = 0x01;
|
||||
const TP_MAX_UDP_PAYLOAD_SIZE: u64 = 0x03;
|
||||
const TP_INITIAL_MAX_DATA: u64 = 0x04;
|
||||
const TP_INITIAL_MAX_STREAM_DATA_BIDI_LOCAL: u64 = 0x05;
|
||||
const TP_INITIAL_MAX_STREAM_DATA_BIDI_REMOTE: u64 = 0x06;
|
||||
const TP_INITIAL_MAX_STREAM_DATA_UNI: u64 = 0x07;
|
||||
const TP_INITIAL_MAX_STREAMS_BIDI: u64 = 0x08;
|
||||
const TP_INITIAL_MAX_STREAMS_UNI: u64 = 0x09;
|
||||
const TP_ACTIVE_CONNECTION_ID_LIMIT: u64 = 0x0e;
|
||||
|
||||
/// What a browser's QUIC ClientHello advertises, measured from a Chrome capture
|
||||
/// of `www.google.com` and `cloudflare.com` (`scripts/quic/tp_dump.py`):
|
||||
/// idle timeout 30 s, 1472-byte datagrams, 15 MiB of connection credit, 6 MiB
|
||||
/// per stream, 100 bidirectional and 103 unidirectional streams, and the RFC's
|
||||
/// own default connection-ID limit of 2.
|
||||
///
|
||||
/// They are not decoration. RFC 9114 §6.2.1 requires an HTTP/3 client to let the
|
||||
/// server open its control and QPACK streams, and a client that omits the set
|
||||
/// leaves every limit at the RFC's zero default — measured against a stock
|
||||
/// client with the limits zeroed: Cloudflare closes such a connection with
|
||||
/// `Error opening control stream` (transport error 258, TLS alert 2) and Google
|
||||
/// answers nothing at all.
|
||||
const BROWSER_PARAMETERS: &[(u64, u64)] = &[
|
||||
(TP_MAX_IDLE_TIMEOUT, 30_000),
|
||||
(TP_MAX_UDP_PAYLOAD_SIZE, 1472),
|
||||
(TP_INITIAL_MAX_DATA, 15_728_640),
|
||||
(TP_INITIAL_MAX_STREAM_DATA_BIDI_LOCAL, 6_291_456),
|
||||
(TP_INITIAL_MAX_STREAM_DATA_BIDI_REMOTE, 6_291_456),
|
||||
(TP_INITIAL_MAX_STREAM_DATA_UNI, 6_291_456),
|
||||
(TP_INITIAL_MAX_STREAMS_BIDI, 100),
|
||||
(TP_INITIAL_MAX_STREAMS_UNI, 103),
|
||||
(TP_ACTIVE_CONNECTION_ID_LIMIT, 2),
|
||||
];
|
||||
|
||||
/// Where a QUIC endpoint listens.
|
||||
const QUIC_PORT: u16 = 443;
|
||||
|
||||
/// How many bytes a connection ID this probe generates has. Eight is what
|
||||
/// browsers use, and it keeps the reply's header small.
|
||||
const CID_LEN: usize = 8;
|
||||
|
||||
/// The largest reply this probe will read. A server's Initial is 1200 bytes at
|
||||
/// most; the slack is for a coalesced Handshake packet in the same datagram.
|
||||
const READ_BUF: usize = 2048;
|
||||
|
||||
/// How long the probe waits for an answer before repeating its Initial, and how
|
||||
/// many times it repeats. RFC 9002 §6.2 sets the first timeout at twice the
|
||||
/// initial round-trip estimate (333 ms, §6.2.1) while no sample exists and
|
||||
/// doubles it after that — `0.67 s, 1.33 s, 2.67 s`, which is what aioquic's
|
||||
/// `get_probe_timeout` computes and what a stock client was measured to send.
|
||||
/// Three repeats inside the window is what the measured endpoints needed: an
|
||||
/// edge that answers the first flight with a packet no key opens sent its
|
||||
/// ServerHello only to a later one.
|
||||
const PTO_FIRST: Duration = Duration::from_millis(666);
|
||||
const MAX_RETRANSMITS: u32 = 3;
|
||||
|
||||
/// The QUIC column of one domain row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuicCheck {
|
||||
pub status: DpiStatus,
|
||||
pub detail: Detail,
|
||||
pub elapsed: f64,
|
||||
}
|
||||
|
||||
impl QuicCheck {
|
||||
/// The column as it stands before the probe ran, and what a row that is
|
||||
/// skipped (fake-IP, DNS failure) keeps.
|
||||
pub fn pending() -> Self {
|
||||
Self { status: DpiStatus::Unknown, detail: Detail::None, elapsed: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The serialized transport parameters of RFC 9000 §18.2: the browser set above,
|
||||
/// plus the client's own source connection ID, which an endpoint requires.
|
||||
fn transport_parameters(scid: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(64);
|
||||
for &(id, value) in BROWSER_PARAMETERS {
|
||||
quic::write_varint(&mut out, id);
|
||||
quic::write_varint(&mut out, quic::varint_len(value) as u64);
|
||||
quic::write_varint(&mut out, value);
|
||||
}
|
||||
quic::write_varint(&mut out, TP_INITIAL_SOURCE_CID);
|
||||
quic::write_varint(&mut out, scid.len() as u64);
|
||||
out.extend_from_slice(scid);
|
||||
out
|
||||
}
|
||||
|
||||
/// The shape the column sends when the run's fingerprint is the rustls baseline.
|
||||
///
|
||||
/// The baseline hello has no profile for the ClientHello patch to edit, so the
|
||||
/// transport parameters cannot be attached to it — and without them an endpoint
|
||||
/// answers `TRANSPORT_PARAMETER_ERROR`, which would make the column report
|
||||
/// `CLOSED` for every host that serves HTTP/3. The column is about the
|
||||
/// browser's QUIC path, so a run on the baseline gets the newest Chrome's shape.
|
||||
const BASELINE_SHAPE: TlsFingerprint = TlsFingerprint::Chrome146;
|
||||
|
||||
/// The ClientHello a QUIC endpoint can take: the run's shape, TLS 1.3 only,
|
||||
/// `h3` in ALPN, and the transport parameters above.
|
||||
///
|
||||
/// The TCP record header is dropped: a QUIC `CRYPTO` frame carries the handshake
|
||||
/// message itself (RFC 9001 §4.1.1), and the five bytes of TLS record framing
|
||||
/// are not part of it.
|
||||
fn quic_hello(fingerprint: TlsFingerprint, sni: &str, scid: &[u8]) -> Vec<u8> {
|
||||
let fingerprint = if fingerprint == TlsFingerprint::Rustls { BASELINE_SHAPE } else { fingerprint };
|
||||
let profile = TlsProfile {
|
||||
version: TlsVersion::Tls13,
|
||||
alpn: Some(vec![b"h3".to_vec()]),
|
||||
quic: true,
|
||||
..TlsProfile::insecure(fingerprint)
|
||||
};
|
||||
let mut edits = vec![
|
||||
// A browser greases over TCP and not over QUIC (measured on a live
|
||||
// Chrome's QUIC hello), and the endpoints this column got wrong answer a
|
||||
// hello without GREASE and not this probe's.
|
||||
HelloVariant::NoGrease,
|
||||
HelloVariant::ExtBody(EXT_TRANSPORT_PARAMETERS, transport_parameters(scid)),
|
||||
];
|
||||
// The shape is the TCP one, and two of its bodies are not what the same
|
||||
// browser sends over QUIC: `signature_algorithms` loses its trailing
|
||||
// `rsa_pkcs1_sha1` in the TCP hello, and application settings say `h2` there
|
||||
// and `h3` here.
|
||||
if let Some(sig_algs) = signature_algorithms_for_quic(fingerprint) {
|
||||
edits.push(sig_algs);
|
||||
}
|
||||
if let Some(alps) = application_settings_for_quic(fingerprint) {
|
||||
edits.push(alps);
|
||||
}
|
||||
// Unconditional: `status_request` is a typed rustls extension and is not in
|
||||
// the shape's raw list, while `DropExtension` suppresses exactly that kind
|
||||
// too — gating on `sends_extension` let it through, and the capture showed
|
||||
// the extension still on the wire.
|
||||
for id in TCP_ONLY_FOR_QUIC {
|
||||
edits.push(HelloVariant::DropExtension(*id));
|
||||
}
|
||||
let record = hello_record_for(&profile, Some(&HelloVariant::Chain(edits)), sni);
|
||||
record.get(5..).map(<[u8]>::to_vec).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extensions a Chrome **TCP** hello carries and its QUIC hello does not,
|
||||
/// measured on a capture of a live Chrome's QUIC ClientHello
|
||||
/// (`scripts/quic/tp_dump.py --extensions`): the status request (0x0005) and the
|
||||
/// signed certificate timestamp (0x0012) are absent there, while the TCP shape in
|
||||
/// this repository sends both.
|
||||
///
|
||||
/// Measured why it matters: `www.apkmirror.com` answers a Chrome hello — the same
|
||||
/// two-datagram split, 1258-byte Initials — with the unreadable first packet and
|
||||
/// then a readable ServerHello, and this probe with only the unreadable one. The
|
||||
/// difference left in the hello was this pair.
|
||||
const TCP_ONLY_FOR_QUIC: &[u16] = &[EXT_STATUS_REQUEST, EXT_SCT];
|
||||
|
||||
/// The application-settings body a browser sends **over QUIC**: `h3`, where the
|
||||
/// same shape over TCP sends `h2`.
|
||||
///
|
||||
/// Measured against a live Chrome (`scripts/quic/tp_dump.py` on a capture of its
|
||||
/// QUIC ClientHello): extension `0x44cd` carries `0003026833` — `h3` — while the
|
||||
/// TCP shape in this repository carries `0003026832`. A QUIC hello that
|
||||
/// advertises `h2` while negotiating `h3` is a shape no browser sends, and it is
|
||||
/// the kind of lie an endpoint that parses the extension (Google's own) may
|
||||
/// answer with a close.
|
||||
///
|
||||
/// Only a shape that already sends the extension is edited: Firefox, Safari and
|
||||
/// Tor send none, and `ExtBody` would add one to them.
|
||||
fn application_settings_for_quic(fingerprint: TlsFingerprint) -> Option<HelloVariant> {
|
||||
let id = [EXT_APPLICATION_SETTINGS, EXT_APPLICATION_SETTINGS_NEW]
|
||||
.into_iter()
|
||||
.find(|id| fingerprint.sends_extension(*id))?;
|
||||
Some(HelloVariant::ExtBody(id, vec![0x00, 0x03, 0x02, b'h', b'3']))
|
||||
}
|
||||
|
||||
/// The `signature_algorithms` body a browser sends **over QUIC**: the shape's own
|
||||
/// list with the trailing `rsa_pkcs1_sha1` its TCP hello drops.
|
||||
///
|
||||
/// Measured on a live Chrome's QUIC ClientHello (`tp_dump.py --extensions`):
|
||||
/// `0012 0403 0804 0401 0503 0805 0501 0806 0601 0201` — nine algorithms, where
|
||||
/// the same shape over TCP sends those eight and stops. quinn's own hello, which
|
||||
/// these endpoints do answer, carries the twenty-byte body as well, so this is a
|
||||
/// list both working clients agree on and this probe did not.
|
||||
///
|
||||
/// Measured for Chrome; Firefox's QUIC hello has not been captured, so the edit
|
||||
/// adds the value to whatever list the shape carries rather than pinning one.
|
||||
fn signature_algorithms_for_quic(fingerprint: TlsFingerprint) -> Option<HelloVariant> {
|
||||
if !fingerprint.sends_extension(EXT_SIGNATURE_ALGORITHMS) {
|
||||
return None;
|
||||
}
|
||||
let body = fingerprint.extension_body(EXT_SIGNATURE_ALGORITHMS)?;
|
||||
// The body is a `u16` list length and then the code points.
|
||||
let mut out = body.to_vec();
|
||||
if out.len() < 2 || out[2..].as_chunks::<2>().0.contains(&[0x02, 0x01]) {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&[0x02, 0x01]);
|
||||
let list_len = (out.len() - 2) as u16;
|
||||
out[..2].copy_from_slice(&list_len.to_be_bytes());
|
||||
Some(HelloVariant::ExtBody(EXT_SIGNATURE_ALGORITHMS, out))
|
||||
}
|
||||
|
||||
/// A fresh connection ID.
|
||||
fn random_cid() -> [u8; CID_LEN] {
|
||||
let mut cid = [0u8; CID_LEN];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut cid);
|
||||
cid
|
||||
}
|
||||
|
||||
/// The verdict a domain's datagrams add up to.
|
||||
///
|
||||
/// Kept apart from the socket so the ladder is testable on synthetic replies:
|
||||
/// what a ServerHello, a close, a Retry or silence means does not depend on how
|
||||
/// the bytes arrived.
|
||||
#[derive(Debug, Default)]
|
||||
struct Verdict {
|
||||
stream: CryptoStream,
|
||||
close: Option<u64>,
|
||||
retry: Option<bool>,
|
||||
reset: bool,
|
||||
negotiated: bool,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
/// Folds one reply in, returning true when nothing better can arrive.
|
||||
fn absorb(&mut self, reply: ServerReply) -> bool {
|
||||
match reply {
|
||||
ServerReply::Initial(frames) => {
|
||||
self.answered = true;
|
||||
for frame in frames {
|
||||
match frame {
|
||||
Frame::Crypto { offset, data } => self.stream.push(offset, &data),
|
||||
Frame::ConnectionClose { error_code, .. } => {
|
||||
self.close = Some(error_code);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.stream.handshake_type().is_some() || self.close.is_some()
|
||||
}
|
||||
ServerReply::Retry { authenticated, .. } => {
|
||||
self.retry = Some(authenticated);
|
||||
true
|
||||
}
|
||||
ServerReply::VersionNegotiation { .. } => {
|
||||
self.negotiated = true;
|
||||
true
|
||||
}
|
||||
ServerReply::ShortHeader => {
|
||||
// Remembered, but not final: measured on `www.instagram.com`,
|
||||
// `www.facebook.com` and `www.messenger.com`, the endpoint sends
|
||||
// a short-header packet *before* the readable Initial that
|
||||
// carries its ServerHello — stopping here reported a stateless
|
||||
// reset for an endpoint that answers every client that waits.
|
||||
self.reset = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the question is answered: a Retry, a close, a version
|
||||
/// negotiation or a handshake message. An Initial that carries neither — a
|
||||
/// bare acknowledgement — and a short-header packet do **not** settle it, and
|
||||
/// that is measured: Cloudflare's edge answers the first flight with an
|
||||
/// acknowledgement alone and sends the ServerHello only to a repeat, so a
|
||||
/// probe that stops repeating when it sees an answer never sees the
|
||||
/// ServerHello (`www.apkmirror.com`), and Meta's edge sends a short-header
|
||||
/// packet before its readable Initial (`www.instagram.com`,
|
||||
/// `www.facebook.com`, `www.messenger.com`).
|
||||
fn settled(&self) -> bool {
|
||||
self.retry.is_some()
|
||||
|| self.close.is_some()
|
||||
|| self.negotiated
|
||||
|| self.stream.handshake_type().is_some()
|
||||
}
|
||||
|
||||
/// The verdict the collected replies add up to.
|
||||
fn finish(self) -> (DpiStatus, Detail) {
|
||||
// A Retry that does not authenticate is the one reply here that is a
|
||||
// finding rather than a state: only a party that saw the Initial can
|
||||
// compute the tag (RFC 9001 §5.8), so a bad one is an imitation.
|
||||
if let Some(authenticated) = self.retry {
|
||||
return if authenticated {
|
||||
(DpiStatus::QuicOk, Detail::QuicRetry)
|
||||
} else {
|
||||
(DpiStatus::QuicSpoof, Detail::QuicForgedRetry)
|
||||
};
|
||||
}
|
||||
if let Some(error_code) = self.close {
|
||||
return (DpiStatus::QuicClosed, Detail::QuicClose { error_code });
|
||||
}
|
||||
let hello = self.stream.assembled();
|
||||
if hello.first() == Some(&SERVER_HELLO) {
|
||||
// A HelloRetryRequest is a ServerHello whose random is a fixed
|
||||
// constant (RFC 8446 §4.1.4): still the endpoint answering the
|
||||
// handshake, only asking for another key share.
|
||||
return (DpiStatus::QuicOk, Detail::QuicServerHello);
|
||||
}
|
||||
if self.negotiated {
|
||||
return (DpiStatus::QuicVn, Detail::QuicVersionNegotiation);
|
||||
}
|
||||
if self.reset {
|
||||
return (DpiStatus::QuicClosed, Detail::QuicReset);
|
||||
}
|
||||
if self.answered {
|
||||
// Decrypted, and it carried neither a hello nor a close: rare enough
|
||||
// that the raw handshake type is more useful than a code of its own.
|
||||
let detail = match hello.first() {
|
||||
Some(kind) => Detail::Other(format!("quic: handshake message {kind:#04x}")),
|
||||
None => Detail::QuicAnsweredWithoutHandshake,
|
||||
};
|
||||
return (DpiStatus::QuicClosed, detail);
|
||||
}
|
||||
(DpiStatus::QuicDrop, Detail::QuicTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/// One domain: resolve-free (the caller already has the IP), one Initial, and
|
||||
/// the window the reply has to arrive in.
|
||||
pub async fn check_domain_quic(
|
||||
domain: &str,
|
||||
target: IpAddr,
|
||||
cfg: &AppConfig,
|
||||
) -> QuicCheck {
|
||||
let started = Instant::now();
|
||||
let timeout_dur = Duration::from_secs_f64(cfg.quic_timeout.max(0.1));
|
||||
let addr = SocketAddr::new(target, QUIC_PORT);
|
||||
let fingerprint = cfg.fingerprint();
|
||||
match probe_addr(addr, domain, fingerprint, timeout_dur).await {
|
||||
Ok((status, detail)) => QuicCheck { status, detail, elapsed: started.elapsed().as_secs_f64() },
|
||||
Err((status, detail)) => QuicCheck { status, detail, elapsed: started.elapsed().as_secs_f64() },
|
||||
}
|
||||
}
|
||||
|
||||
/// One address: the datagram, the window, and the verdict.
|
||||
///
|
||||
/// `Err` is a transport failure — the socket's own error — and `Ok` a verdict
|
||||
/// read off the wire. Silence is `Ok(QuicDrop)`: the two are told apart because
|
||||
/// a caller with another address to try may want to, and because the timeout
|
||||
/// window is what the two differ in.
|
||||
async fn probe_addr(
|
||||
addr: SocketAddr,
|
||||
sni: &str,
|
||||
fingerprint: TlsFingerprint,
|
||||
timeout_dur: Duration,
|
||||
) -> Result<(DpiStatus, Detail), (DpiStatus, Detail)> {
|
||||
let dcid = random_cid();
|
||||
let scid = random_cid();
|
||||
let keys = quic::InitialKeys::derive(&dcid)
|
||||
.map_err(|_| (DpiStatus::Err, Detail::Other("quic: key schedule".to_string())))?;
|
||||
let hello = quic_hello(fingerprint, sni, &scid);
|
||||
let mut flight = quic::client_initials(&keys, &dcid, &scid, &hello, 0)
|
||||
.map_err(|error| (DpiStatus::Err, Detail::Other(error.to_string())))?;
|
||||
|
||||
let socket = udp_socket(&addr)
|
||||
.await
|
||||
.map_err(|error| (DpiStatus::Err, Detail::Other(error.to_string())))?;
|
||||
// Connected, so the kernel hands the ICMP verdict of this destination to
|
||||
// this socket instead of to whoever reads next.
|
||||
socket
|
||||
.connect(addr)
|
||||
.await
|
||||
.map_err(|error| (DpiStatus::Err, Detail::Other(error.to_string())))?;
|
||||
// A browser hello is bigger than one datagram, so the flight is usually two
|
||||
// or three packets; they go out in order, as the CRYPTO offsets expect.
|
||||
for datagram in &flight {
|
||||
socket
|
||||
.send(datagram)
|
||||
.await
|
||||
.map_err(|error| (DpiStatus::Err, Detail::Other(error.to_string())))?;
|
||||
}
|
||||
|
||||
// The watcher is opened before the first packet can fail, the way `dial_tcp`
|
||||
// does it: a verdict that arrives with the failure is too late to catch.
|
||||
crate::net::icmp_err::ensure_started();
|
||||
|
||||
let deadline = Instant::now() + timeout_dur;
|
||||
let mut verdict = Verdict::default();
|
||||
let mut buf = vec![0u8; READ_BUF];
|
||||
let mut largest_pn = 0u64;
|
||||
// An endpoint is not obliged to answer the first Initial. Measured against
|
||||
// Cloudflare's edge, the first flight is met with a packet no key opens and
|
||||
// the ServerHello arrives only once the client repeats its Initial — which
|
||||
// every real client does on a loss timeout (RFC 9002 §6.2) and a probe that
|
||||
// sends once does not, so it reports silence for an endpoint that is
|
||||
// answering every browser on the same address.
|
||||
let mut next_pn = flight.len() as u64;
|
||||
let mut next_retransmit = Instant::now() + PTO_FIRST;
|
||||
let mut retransmits: u32 = 0;
|
||||
let mut unreadable: usize = 0;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
let left = deadline.saturating_duration_since(now);
|
||||
if left.is_zero() {
|
||||
break;
|
||||
}
|
||||
if retransmits < MAX_RETRANSMITS && !verdict.settled() && now >= next_retransmit {
|
||||
flight = quic::client_initials(&keys, &dcid, &scid, &hello, next_pn)
|
||||
.map_err(|error| (DpiStatus::Err, Detail::Other(error.to_string())))?;
|
||||
next_pn += flight.len() as u64;
|
||||
for datagram in &flight {
|
||||
// A send that fails mid-flight is not fatal: the window is still
|
||||
// open and the reply may already be on its way.
|
||||
let _ = socket.send(datagram).await;
|
||||
}
|
||||
retransmits += 1;
|
||||
next_retransmit = now + PTO_FIRST * (1 << retransmits);
|
||||
}
|
||||
let until_retransmit = next_retransmit.saturating_duration_since(Instant::now()).min(left);
|
||||
match tokio::time::timeout(until_retransmit, socket.recv(&mut buf)).await {
|
||||
// Either the window closed or it is time to repeat the flight; the
|
||||
// loop's own deadline check tells the two apart.
|
||||
Err(_) => continue,
|
||||
Ok(Ok(0)) => continue,
|
||||
Ok(Ok(len)) => {
|
||||
match quic::open_datagram(&keys, &dcid, &buf[..len], largest_pn) {
|
||||
Ok(reply) => {
|
||||
largest_pn = largest_pn.saturating_add(1);
|
||||
if verdict.absorb(reply) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Not ours to read — a stray datagram, or a packet the
|
||||
// endpoint sent under keys this probe cannot derive. Counted,
|
||||
// because "nothing came back" would be a false statement.
|
||||
Err(_) => unreadable += 1,
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
let icmp = crate::net::icmp_err::verdict_wait(addr.ip()).await;
|
||||
let (status, detail) =
|
||||
classify_connect_error_icmp(&error, icmp, 0, ProbeStage::TcpConnect);
|
||||
// A UDP socket reports "nothing listens there" through the same
|
||||
// errnos a TCP one uses for a refused or reset connect — on
|
||||
// Windows the ICMP port-unreachable arrives as `ConnectionReset`
|
||||
// — so the TCP wording would be wrong for all of them. Only a
|
||||
// port with no listener produces them on UDP.
|
||||
let detail = match status {
|
||||
DpiStatus::Refused | DpiStatus::TcpRst | DpiStatus::TcpAbort => {
|
||||
Detail::QuicPortUnreachable
|
||||
}
|
||||
_ => detail,
|
||||
};
|
||||
let status = match status {
|
||||
DpiStatus::Refused | DpiStatus::TcpRst | DpiStatus::TcpAbort => DpiStatus::Refused,
|
||||
other => other,
|
||||
};
|
||||
return Err((status, detail));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut status, mut detail) = verdict.finish();
|
||||
if status == DpiStatus::QuicDrop && unreadable > 0 {
|
||||
// The endpoint answered and the probe could not open the answer: the
|
||||
// path works, and "no reply to the Initial" would say the opposite.
|
||||
status = DpiStatus::QuicClosed;
|
||||
detail = Detail::QuicUnreadableReply;
|
||||
}
|
||||
if status == DpiStatus::QuicDrop {
|
||||
return Err((status, detail));
|
||||
}
|
||||
Ok((status, detail))
|
||||
}
|
||||
|
||||
/// Test 2's QUIC phase: one Initial per entry that resolved to a clean address,
|
||||
/// `concurrency` probes at a time, results written back into the rows.
|
||||
///
|
||||
/// The same filter the TLS phases use — a fake-IP or stub row has no real peer
|
||||
/// to ask — and the same resolved address, so the column describes the peer the
|
||||
/// TLS columns describe.
|
||||
pub async fn check_quic_all(
|
||||
entries: &mut [DomainEntry],
|
||||
cfg: &AppConfig,
|
||||
sem: &Arc<Semaphore>,
|
||||
phases: Option<PhaseProgress>,
|
||||
) {
|
||||
let total = entries.iter().filter(|e| e.dns_fake == Some(false)).count();
|
||||
let tick = phases.as_ref().map(|p| (p.on_phase)(PhaseId::DomainQuic, total));
|
||||
// One config for every task instead of one per domain: each deep clone
|
||||
// carries the whole DNS server list, and the clones pile up while the tasks
|
||||
// wait on the gate (`probe/whitelist.rs` shares it the same way).
|
||||
let cfg = Arc::new(cfg.clone());
|
||||
let mut handles = Vec::new();
|
||||
for (idx, e) in entries.iter().enumerate() {
|
||||
if e.dns_fake != Some(false) {
|
||||
continue;
|
||||
}
|
||||
let Some(target) = e.resolved else { continue };
|
||||
let domain = e.domain.clone();
|
||||
let cfg = Arc::clone(&cfg);
|
||||
let sem = Arc::clone(sem);
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit(&sem).await;
|
||||
// The experiment: the same column through `quinn`, which needs the
|
||||
// `ring` provider this tree refuses — `probe/quic_quinn.rs`.
|
||||
#[cfg(feature = "quinn-probe")]
|
||||
let check = super::quic_quinn::check(&domain, target, &cfg).await;
|
||||
#[cfg(not(feature = "quinn-probe"))]
|
||||
let check = check_domain_quic(&domain, target, &cfg).await;
|
||||
(idx, check)
|
||||
}));
|
||||
}
|
||||
for h in handles {
|
||||
let done = h.await;
|
||||
if let Some(t) = tick.as_ref() {
|
||||
t();
|
||||
}
|
||||
if let Ok((idx, check)) = done {
|
||||
entries[idx].quic = check;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::quic::HELLO_RETRY_RANDOM;
|
||||
|
||||
/// A ServerHello is `0x02`, its three-byte length, then the body: enough for
|
||||
/// the ladder, which reads the handshake type and the retry random.
|
||||
fn server_hello(random: [u8; 32]) -> Vec<u8> {
|
||||
let mut hello = vec![SERVER_HELLO, 0x00, 0x00, 0x2c, 0x03, 0x03];
|
||||
hello.extend_from_slice(&random);
|
||||
hello
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quic_hello_leaves_the_legacy_session_id_empty() {
|
||||
// RFC 9001 §8.4: the compatibility-mode session id is prohibited in
|
||||
// QUIC, and a server treats a non-empty one as PROTOCOL_VIOLATION —
|
||||
// Cloudflare answers `illegal_parameter` (`CRYPTO_ERROR 0x12f`) instead
|
||||
// of a ServerHello. The byte after the 32-byte random is its length.
|
||||
let hello = quic_hello(TlsFingerprint::Chrome146, "example.com", &[0x5a; 8]);
|
||||
assert_eq!(hello[0], 0x01, "a ClientHello message");
|
||||
assert_eq!(hello[38], 0, "the QUIC hello's session id is empty");
|
||||
|
||||
// The TCP shapes keep the 32-byte field, which is what a browser sends
|
||||
// over TCP and what every pinned byte test in `net::tls` expects.
|
||||
let record = hello_record_for(
|
||||
&TlsProfile::insecure(TlsFingerprint::Chrome146),
|
||||
None,
|
||||
"example.com",
|
||||
);
|
||||
assert_eq!(record[5 + 38], 32, "the TCP hello carries the compatibility id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transport_parameters_carry_the_source_connection_id() {
|
||||
let params = transport_parameters(&[0xaa; 8]);
|
||||
let parsed = parse_parameters(¶ms);
|
||||
assert_eq!(parsed.get(&TP_INITIAL_SOURCE_CID), Some(&vec![0xaa; 8]));
|
||||
}
|
||||
|
||||
/// RFC 9114 §6.2.1: an HTTP/3 client must let the server open its control and
|
||||
/// QPACK streams, which needs `initial_max_streams_uni` of at least three.
|
||||
/// Measured against a stock client with the limits zeroed: Cloudflare closes
|
||||
/// the connection with `Error opening control stream` and Google answers
|
||||
/// nothing, so a hello that omits the set is a broken hello.
|
||||
#[test]
|
||||
fn the_quic_hello_lets_the_server_open_its_control_stream() {
|
||||
let params = parse_parameters(&transport_parameters(&[0xaa; 8]));
|
||||
let uni = params.get(&TP_INITIAL_MAX_STREAMS_UNI).expect("the uni-stream credit");
|
||||
assert!(uni[0] >= 3, "an HTTP/3 server needs three unidirectional streams, got {}", uni[0]);
|
||||
assert!(params.contains_key(&TP_INITIAL_MAX_DATA), "no connection credit");
|
||||
assert!(params.contains_key(&TP_INITIAL_MAX_STREAM_DATA_UNI), "no stream credit");
|
||||
}
|
||||
|
||||
/// A browser greases over TCP and does not over QUIC: a live Chrome's QUIC
|
||||
/// hello carries no GREASE value at all (RFC 8701), and the endpoints this
|
||||
/// column got wrong — `www.apkmirror.com`, `www.facebook.com`,
|
||||
/// `www.instagram.com` — answer a Chrome hello while they do not answer this
|
||||
/// probe's. quinn's own hello, which those endpoints *do* answer, carries none
|
||||
/// either, and it is the one feature absent from both working clients and
|
||||
/// present in ours.
|
||||
///
|
||||
/// Measured with `scripts/quic/tp_dump.py --extensions` on a capture of each.
|
||||
#[test]
|
||||
fn the_quic_hello_carries_no_grease() {
|
||||
let hello = quic_hello(TlsFingerprint::Chrome146, "example.com", &[0x5a; 8]);
|
||||
let ciphers = crate::net::ja3::cipher_suites(&hello);
|
||||
assert!(!ciphers.is_empty(), "the hello must offer cipher suites");
|
||||
assert!(
|
||||
ciphers.iter().all(|suite| !crate::net::ja3::is_grease(*suite)),
|
||||
"a GREASE cipher suite over QUIC: {ciphers:04x?}"
|
||||
);
|
||||
let extensions = crate::net::ja3::extensions(&hello);
|
||||
assert!(
|
||||
extensions.iter().all(|(id, _)| !crate::net::ja3::is_grease(*id)),
|
||||
"a GREASE extension over QUIC: {extensions:04x?}"
|
||||
);
|
||||
// The two bodies that hold a *list* of code points, either of which a
|
||||
// GREASE value can hide in: `supported_groups` and `supported_versions`.
|
||||
for (id, body) in &extensions {
|
||||
let skip = match *id {
|
||||
crate::net::fingerprint::EXT_SUPPORTED_GROUPS => 2,
|
||||
crate::net::fingerprint::EXT_SUPPORTED_VERSIONS => 1,
|
||||
_ => continue,
|
||||
};
|
||||
let values: Vec<u16> = body
|
||||
.get(skip..)
|
||||
.unwrap_or_default()
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|pair| u16::from_be_bytes(*pair))
|
||||
.collect();
|
||||
assert!(
|
||||
values.iter().all(|value| !crate::net::ja3::is_grease(*value)),
|
||||
"a GREASE value inside extension {id:#06x}: {values:04x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A short-header packet is remembered, not final: measured on
|
||||
/// `www.instagram.com`, `www.facebook.com` and `www.messenger.com`, the
|
||||
/// endpoint sends one *before* the readable Initial that carries its
|
||||
/// ServerHello, so ending the exchange there reported a stateless reset for
|
||||
/// an endpoint that answers every client which waits.
|
||||
#[test]
|
||||
fn a_short_header_does_not_end_the_exchange() {
|
||||
let mut verdict = Verdict::default();
|
||||
assert!(!verdict.absorb(ServerReply::ShortHeader), "the exchange must keep going");
|
||||
assert_eq!(
|
||||
verdict.finish(),
|
||||
(DpiStatus::QuicClosed, Detail::QuicReset),
|
||||
"and the reset is still the verdict when nothing better arrives"
|
||||
);
|
||||
}
|
||||
|
||||
/// An Initial that carries no handshake message does not settle it either, so
|
||||
/// the flight keeps repeating: Cloudflare's edge answers the first Initial
|
||||
/// with an acknowledgement alone and sends the ServerHello only to a repeat.
|
||||
#[test]
|
||||
fn an_initial_without_a_handshake_does_not_settle_the_question() {
|
||||
let mut verdict = Verdict::default();
|
||||
assert!(!verdict.absorb(ServerReply::Initial(Vec::new())), "nothing settled yet");
|
||||
assert!(!verdict.settled(), "the repeats must continue");
|
||||
assert!(verdict.answered, "it did answer — that is what the column reports");
|
||||
}
|
||||
|
||||
/// The parameter list as `id -> value`, the way an endpoint reads it.
|
||||
fn parse_parameters(body: &[u8]) -> std::collections::HashMap<u64, Vec<u8>> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
let mut at = 0;
|
||||
while at < body.len() {
|
||||
let (id, next) = quic::read_varint(body, at).expect("an id");
|
||||
let (len, next) = quic::read_varint(body, next).expect("a length");
|
||||
out.insert(id, body[next..next + len as usize].to_vec());
|
||||
at = next + len as usize;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hello_is_a_tls13_h3_hello_carrying_the_transport_parameters() {
|
||||
// The whole trick in one assertion: the shape is the run's own, the ALPN
|
||||
// is h3, and extension 0x39 holds the parameter an endpoint requires.
|
||||
let scid = [0x5a; 8];
|
||||
let hello = quic_hello(TlsFingerprint::Chrome107, "example.com", &scid);
|
||||
assert_eq!(hello[0], 0x01, "a ClientHello message, with no record header");
|
||||
|
||||
let extensions = crate::net::ja3::extensions(&hello);
|
||||
let body = |id: u16| -> Vec<u8> {
|
||||
extensions
|
||||
.iter()
|
||||
.find(|(ext_type, _)| *ext_type == id)
|
||||
.map(|(_, body)| body.to_vec())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(body(EXT_TRANSPORT_PARAMETERS), transport_parameters(&scid));
|
||||
// ALPN: a one-protocol list (length 3), that protocol being "h3".
|
||||
assert_eq!(body(0x0010), vec![0x00, 0x03, 0x02, b'h', b'3']);
|
||||
|
||||
// Application settings over QUIC say h3, not the h2 the same shape sends
|
||||
// over TCP: a live Chrome's QUIC hello carries 0003026833 here, and the
|
||||
// capture of this probe's own hello carried 0003026832 before the edit.
|
||||
let chrome146 = quic_hello(TlsFingerprint::Chrome146, "example.com", &scid);
|
||||
let alps = crate::net::ja3::extensions(&chrome146)
|
||||
.iter()
|
||||
.find(|(id, _)| *id == EXT_APPLICATION_SETTINGS_NEW)
|
||||
.map(|(_, body)| body.to_vec())
|
||||
.expect("Chrome 146 sends the new application settings");
|
||||
assert_eq!(alps, vec![0x00, 0x03, 0x02, b'h', b'3'], "h2 over QUIC is a shape no browser sends");
|
||||
|
||||
// Firefox sends no application settings over either transport, and the
|
||||
// edit must not add one: that would be a shape no client sends either.
|
||||
let firefox = quic_hello(TlsFingerprint::Firefox133, "example.com", &scid);
|
||||
assert!(
|
||||
!crate::net::ja3::extensions(&firefox)
|
||||
.iter()
|
||||
.any(|(id, _)| *id == EXT_APPLICATION_SETTINGS || *id == EXT_APPLICATION_SETTINGS_NEW),
|
||||
"a shape without ALPS must not grow one over QUIC"
|
||||
);
|
||||
|
||||
// The TCP-only pair a live Chrome's QUIC hello does not carry: the status
|
||||
// request is a *typed* rustls extension, so a drop that only edits the raw
|
||||
// list leaves it on the wire (measured: it did).
|
||||
for id in TCP_ONLY_FOR_QUIC {
|
||||
assert!(
|
||||
!crate::net::ja3::extensions(&chrome146).iter().any(|(ext, _)| ext == id),
|
||||
"extension {id:#06x} belongs to the TCP shape, not the QUIC hello"
|
||||
);
|
||||
}
|
||||
|
||||
// The baseline shape has no profile to edit, so the column substitutes a
|
||||
// browser one: without the parameters an endpoint would answer
|
||||
// TRANSPORT_PARAMETER_ERROR for every host.
|
||||
let baseline = quic_hello(TlsFingerprint::Rustls, "example.com", &scid);
|
||||
let baseline_ext = crate::net::ja3::extensions(&baseline);
|
||||
assert!(
|
||||
baseline_ext.iter().any(|(id, body)| *id == EXT_TRANSPORT_PARAMETERS
|
||||
&& *body == transport_parameters(&scid).as_slice()),
|
||||
"the baseline falls back to a shape the parameters can be attached to"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_hello_is_ok_and_a_retry_request_still_is() {
|
||||
let mut verdict = Verdict::default();
|
||||
verdict.absorb(ServerReply::Initial(vec![Frame::Crypto {
|
||||
offset: 0,
|
||||
data: server_hello([0x11; 32]),
|
||||
}]));
|
||||
assert_eq!(verdict.finish(), (DpiStatus::QuicOk, Detail::QuicServerHello));
|
||||
|
||||
let mut retried = Verdict::default();
|
||||
retried.absorb(ServerReply::Initial(vec![Frame::Crypto {
|
||||
offset: 0,
|
||||
data: server_hello(HELLO_RETRY_RANDOM),
|
||||
}]));
|
||||
assert_eq!(retried.finish(), (DpiStatus::QuicOk, Detail::QuicServerHello));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_retry_is_ok_and_a_forged_one_is_a_spoof() {
|
||||
let mut good = Verdict::default();
|
||||
good.absorb(ServerReply::Retry { token: b"token".to_vec(), authenticated: true });
|
||||
assert_eq!(good.finish(), (DpiStatus::QuicOk, Detail::QuicRetry));
|
||||
|
||||
// A tag that does not match cannot have been computed by a party that
|
||||
// saw the Initial, so the packet is an imitation — the finding this test
|
||||
// exists for.
|
||||
let mut forged = Verdict::default();
|
||||
forged.absorb(ServerReply::Retry { token: b"token".to_vec(), authenticated: false });
|
||||
assert_eq!(forged.finish(), (DpiStatus::QuicSpoof, Detail::QuicForgedRetry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_keeps_its_error_code_and_silence_is_a_drop() {
|
||||
let mut closed = Verdict::default();
|
||||
closed.absorb(ServerReply::Initial(vec![Frame::ConnectionClose {
|
||||
error_code: 8,
|
||||
frame_type: Some(6),
|
||||
reason: "transport parameters required".to_string(),
|
||||
}]));
|
||||
assert_eq!(
|
||||
closed.finish(),
|
||||
(DpiStatus::QuicClosed, Detail::QuicClose { error_code: 8 })
|
||||
);
|
||||
|
||||
assert_eq!(Verdict::default().finish(), (DpiStatus::QuicDrop, Detail::QuicTimeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negotiation_and_a_stateless_reset_have_their_own_verdicts() {
|
||||
let mut negotiated = Verdict::default();
|
||||
negotiated.absorb(ServerReply::VersionNegotiation { versions: vec![0, 0x6b33_43cf] });
|
||||
assert_eq!(negotiated.finish(), (DpiStatus::QuicVn, Detail::QuicVersionNegotiation));
|
||||
|
||||
let mut reset = Verdict::default();
|
||||
reset.absorb(ServerReply::ShortHeader);
|
||||
assert_eq!(reset.finish(), (DpiStatus::QuicClosed, Detail::QuicReset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_outranks_a_server_hello_in_the_same_packet() {
|
||||
// A server may put a CRYPTO frame and a close in one Initial; the close
|
||||
// is the outcome.
|
||||
let mut verdict = Verdict::default();
|
||||
verdict.absorb(ServerReply::Initial(vec![
|
||||
Frame::Crypto { offset: 0, data: server_hello([0x22; 32]) },
|
||||
Frame::ConnectionClose { error_code: 0x0a, frame_type: None, reason: String::new() },
|
||||
]));
|
||||
assert_eq!(
|
||||
verdict.finish(),
|
||||
(DpiStatus::QuicClosed, Detail::QuicClose { error_code: 0x0a })
|
||||
);
|
||||
}
|
||||
|
||||
/// The socket path, without egress: a port with nothing on it answers with
|
||||
/// ICMP, and a port that answers nothing at all is the drop verdict.
|
||||
#[tokio::test]
|
||||
async fn a_closed_port_is_refused_and_a_silent_one_is_a_drop() {
|
||||
let timeout_dur = Duration::from_millis(400);
|
||||
|
||||
// A port nobody listens on: bind, learn the number, drop the socket.
|
||||
let closed = tokio::net::UdpSocket::bind("127.0.0.1:0").await.expect("a local socket");
|
||||
let closed_addr = closed.local_addr().expect("an address");
|
||||
drop(closed);
|
||||
let refused = probe_addr(closed_addr, "example.com", TlsFingerprint::Chrome107, timeout_dur).await;
|
||||
assert_eq!(
|
||||
refused,
|
||||
Err((DpiStatus::Refused, Detail::QuicPortUnreachable)),
|
||||
"ICMP port-unreachable is the refused verdict"
|
||||
);
|
||||
|
||||
// A port that is open and answers nothing: the drop verdict, and the
|
||||
// caller is told so it can try another address.
|
||||
let silent = tokio::net::UdpSocket::bind("127.0.0.1:0").await.expect("a local socket");
|
||||
let silent_addr = silent.local_addr().expect("an address");
|
||||
let dropped = probe_addr(silent_addr, "example.com", TlsFingerprint::Chrome107, timeout_dur).await;
|
||||
assert_eq!(dropped, Err((DpiStatus::QuicDrop, Detail::QuicTimeout)));
|
||||
}
|
||||
|
||||
/// The whole phase, on rows the caller already resolved: the report writes
|
||||
/// into the entries and skips the ones without a clean address.
|
||||
#[tokio::test]
|
||||
async fn the_phase_fills_clean_rows_and_skips_the_rest() {
|
||||
use crate::probe::domains::{DomainEntry, HttpCheck, TlsCheck};
|
||||
|
||||
let dash = TlsCheck { status: DpiStatus::Unknown, detail: Detail::None, elapsed: 0.0 };
|
||||
let mut entries = vec![
|
||||
DomainEntry {
|
||||
domain: "silent.example".to_string(),
|
||||
resolved: Some("127.0.0.1".parse().expect("a documentation address")),
|
||||
dns_fake: Some(false),
|
||||
t13: dash.clone(),
|
||||
t12: dash.clone(),
|
||||
http: HttpCheck { status: DpiStatus::Unknown, detail: Detail::None },
|
||||
quic: QuicCheck::pending(),
|
||||
},
|
||||
DomainEntry {
|
||||
domain: "stub.example".to_string(),
|
||||
resolved: Some("198.18.5.4".parse().expect("a fake-IP address")),
|
||||
dns_fake: Some(true),
|
||||
t13: dash.clone(),
|
||||
t12: dash.clone(),
|
||||
http: HttpCheck { status: DpiStatus::Unknown, detail: Detail::None },
|
||||
quic: QuicCheck::pending(),
|
||||
},
|
||||
];
|
||||
let cfg = AppConfig { quic_timeout: 0.4, ..AppConfig::default() };
|
||||
let sem = Arc::new(Semaphore::new(2));
|
||||
check_quic_all(&mut entries, &cfg, &sem, None).await;
|
||||
|
||||
// 127.0.0.1:443 has no QUIC endpoint in a test run: either nothing
|
||||
// answers (a drop) or the port is closed (a refusal). Both are verdicts;
|
||||
// what matters is that the row was probed at all.
|
||||
assert!(
|
||||
matches!(entries[0].quic.status, DpiStatus::QuicDrop | DpiStatus::Refused),
|
||||
"got {:?} / {:?}",
|
||||
entries[0].quic.status,
|
||||
entries[0].quic.detail.code()
|
||||
);
|
||||
assert_eq!(entries[1].quic.status, DpiStatus::Unknown, "a fake-IP row is not probed");
|
||||
}
|
||||
}
|
||||
159
crates/dpi-core/src/probe/quic_quinn.rs
Normal file
159
crates/dpi-core/src/probe/quic_quinn.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! The QUIC column run through `quinn` instead of the hand-written Initial.
|
||||
//!
|
||||
//! An experiment, not a candidate. `quinn` hands the handshake to `rustls` and
|
||||
//! requires a QUIC-capable crypto provider; this tree's provider
|
||||
//! (`rustls-rustcrypto`) declares `quic: None` and its QUIC functions are
|
||||
//! `todo!()`, so the client below is built on quinn's own default provider,
|
||||
//! `ring` — assembly, no MIPS target, outside Rule 1. The `quinn-probe` feature
|
||||
//! is what pulls it in, and enabling the feature *is* the measurement.
|
||||
//!
|
||||
//! The outcome mapping deliberately mirrors the hand-written probe's vocabulary
|
||||
//! (`QuicCheck`, `DpiStatus`, `Detail`), so the two implementations can be
|
||||
//! compared row by row on the same host list.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use quinn::{ClientConfig, ConnectionError, Endpoint, TransportConfig};
|
||||
|
||||
use super::QuicCheck;
|
||||
use crate::classify::{Detail, DpiStatus};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
/// The port the column dials, and the ALPN an HTTP/3 client offers.
|
||||
const QUIC_PORT: u16 = 443;
|
||||
const ALPN: &[u8] = b"h3";
|
||||
|
||||
/// Why the handshake did not complete.
|
||||
enum Outcome {
|
||||
/// The endpoint said something, and this is quinn's classification of it.
|
||||
Peer(ConnectionError),
|
||||
/// Our side failed before the wire (bind, configuration). Not a verdict
|
||||
/// about the endpoint, so it is reported the way the hand-written probe
|
||||
/// reports a transport failure of its own.
|
||||
Local(String),
|
||||
}
|
||||
|
||||
/// One domain: the same signature as `quic::check_domain_quic`, so the caller can
|
||||
/// swap one implementation for the other.
|
||||
pub async fn check(domain: &str, target: IpAddr, cfg: &AppConfig) -> QuicCheck {
|
||||
let started = Instant::now();
|
||||
let window = Duration::from_secs_f64(cfg.quic_timeout.max(0.1));
|
||||
let addr = SocketAddr::new(target, QUIC_PORT);
|
||||
let (status, detail) = match handshake(addr, domain, window).await {
|
||||
Ok(()) => (DpiStatus::QuicOk, Detail::QuicServerHello),
|
||||
Err(Outcome::Peer(error)) => classify(&error),
|
||||
Err(Outcome::Local(message)) => (DpiStatus::Err, Detail::Other(message)),
|
||||
};
|
||||
QuicCheck { status, detail, elapsed: started.elapsed().as_secs_f64() }
|
||||
}
|
||||
|
||||
/// quinn's outcome in the column's vocabulary. The close codes are the same
|
||||
/// numbers the hand-written probe reads off the wire (`quic_close_296` is a TLS
|
||||
/// `handshake_failure`), which is what makes the two comparable.
|
||||
fn classify(error: &ConnectionError) -> (DpiStatus, Detail) {
|
||||
match error {
|
||||
ConnectionError::Reset => (DpiStatus::QuicClosed, Detail::QuicReset),
|
||||
ConnectionError::TimedOut => (DpiStatus::QuicDrop, Detail::QuicTimeout),
|
||||
ConnectionError::VersionMismatch => (DpiStatus::QuicClosed, Detail::QuicVersionNegotiation),
|
||||
ConnectionError::ConnectionClosed(close) => close_code(u64::from(close.error_code)),
|
||||
ConnectionError::ApplicationClosed(close) => close_code(close.error_code.into_inner()),
|
||||
ConnectionError::TransportError(error) => close_code(u64::from(error.code)),
|
||||
// `LocallyClosed` and `CidsExhausted` are our own doing, not the
|
||||
// endpoint's: reported as such rather than as a verdict about the host.
|
||||
other => (DpiStatus::Err, Detail::Other(format!("quinn: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_code(code: u64) -> (DpiStatus, Detail) {
|
||||
(DpiStatus::QuicClosed, Detail::QuicClose { error_code: code })
|
||||
}
|
||||
|
||||
/// One address, one window, one verdict.
|
||||
async fn handshake(addr: SocketAddr, sni: &str, window: Duration) -> Result<(), Outcome> {
|
||||
let mut transport = TransportConfig::default();
|
||||
if let Ok(idle) = window.try_into() {
|
||||
transport.max_idle_timeout(Some(idle));
|
||||
}
|
||||
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(client_crypto()?)
|
||||
.map_err(|error| Outcome::Local(format!("quinn: {error}")))?;
|
||||
let mut client = ClientConfig::new(Arc::new(quic));
|
||||
client.transport_config(Arc::new(transport));
|
||||
|
||||
let endpoint = Endpoint::client("0.0.0.0:0".parse().expect("bind address"))
|
||||
.map_err(|error| Outcome::Local(format!("quinn: {error}")))?;
|
||||
let connecting = endpoint
|
||||
.connect_with(client, addr, sni)
|
||||
.map_err(|error| Outcome::Local(format!("quinn: {error}")))?;
|
||||
// Dropping the endpoint closes it; the connection's own result is what the
|
||||
// column reports.
|
||||
connecting.await.map(|_| ()).map_err(Outcome::Peer)
|
||||
}
|
||||
|
||||
fn client_crypto() -> Result<rustls::ClientConfig, Outcome> {
|
||||
// `ring`, explicitly: this tree's provider cannot do QUIC at all, so the
|
||||
// experiment cannot use it. Built per call rather than installed process
|
||||
// wide — a library has no business setting a global provider.
|
||||
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
||||
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.map_err(|error| Outcome::Local(format!("quinn: {error}")))?
|
||||
.with_root_certificates(rustls::RootCertStore::empty())
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = vec![ALPN.to_vec()];
|
||||
// The endpoint's chain is not the subject: the column is about the path, and
|
||||
// a browser reaches a verdict on a host whose chain it cannot verify too.
|
||||
config.dangerous().set_certificate_verifier(Arc::new(AcceptAnyCertificate));
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Accepts every chain, for the reason above.
|
||||
#[derive(Debug)]
|
||||
struct AcceptAnyCertificate;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for AcceptAnyCertificate {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
use rustls::SignatureScheme::*;
|
||||
vec![
|
||||
RSA_PSS_SHA256,
|
||||
RSA_PSS_SHA384,
|
||||
RSA_PSS_SHA512,
|
||||
RSA_PKCS1_SHA256,
|
||||
RSA_PKCS1_SHA384,
|
||||
RSA_PKCS1_SHA512,
|
||||
ECDSA_NISTP256_SHA256,
|
||||
ECDSA_NISTP384_SHA384,
|
||||
ED25519,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +108,20 @@ pub(crate) fn detail_text(detail: &Detail, lang: Language) -> String {
|
|||
Detail::UpgradeHttps { status: Some(code) } => format!("{code} → https"),
|
||||
Detail::UpgradeHttps { status: None } => "→ https".to_string(),
|
||||
Detail::Elapsed(secs) => format!("{:.1}s", secs),
|
||||
Detail::QuicServerHello => t4(lang, "Сервер ответил на QUIC-рукопожатие (ServerHello)", "The server answered the QUIC handshake (ServerHello)", "服务器回应了 QUIC 握手(ServerHello)", "Server be handshake-e QUIC pasokh dad (ServerHello)"),
|
||||
Detail::QuicRetry => t4(lang, "Сервер запросил Retry-токен (QUIC-путь работает)", "The server asked for a Retry token (the QUIC path works)", "服务器要求 Retry 令牌(QUIC 路径可用)", "Server token-e Retry khast (masir-e QUIC kar mikonad)"),
|
||||
Detail::QuicForgedRetry => t4(lang, "Retry с неверным integrity-тегом — пакет не от сервера", "Retry with a bad integrity tag — the packet is not the server's", "Retry 的完整性标签错误 — 报文不是服务器发出的", "Retry ba integrity tag-e ghalat — packet az server nist"),
|
||||
Detail::QuicClose { error_code } => format!(
|
||||
"{} ({})",
|
||||
t4(lang, "Сервер закрыл QUIC-соединение", "The server closed the QUIC connection", "服务器关闭了 QUIC 连接", "Server ettesal-e QUIC ra bast"),
|
||||
error_code
|
||||
),
|
||||
Detail::QuicReset => t4(lang, "Stateless reset: ответил тот, у кого нет состояния этого соединения", "Stateless reset: something answered that has no state for this connection", "无状态重置:回应方没有此连接的状态", "Stateless reset: kasani pasokh dad ke hich state-i baraye in ettesal nadarad"),
|
||||
Detail::QuicVersionNegotiation => t4(lang, "Сервер не поддерживает QUIC v1 (version negotiation)", "The server does not speak QUIC v1 (version negotiation)", "服务器不支持 QUIC v1(version negotiation)", "Server version-e QUIC v1 ra nadarad (version negotiation)"),
|
||||
Detail::QuicTimeout => t4(lang, "Ответа на Initial нет (UDP 443 молчал всё окно)", "No reply to the Initial (UDP 443 stayed silent for the whole window)", "Initial 没有回应(UDP 443 在整个窗口内没有响应)", "Be Initial pasokhi nayamad (UDP 443 dar tamam-e window sokut kard)"),
|
||||
Detail::QuicUnreadableReply => t4(lang, "Ответ пришёл, но не открывается ни одним ключом соединения (так отвечает эдж на первый Initial)", "Something answered, and no key of this connection opens it (what an edge sends to a first Initial)", "有回应,但本连接的任何密钥都无法解开(边缘对首个 Initial 的回应方式)", "Pasokh amad, vali ba hich kelid-e in ettesal baz nemishavad (pasokh-e edge be avvalin Initial)"),
|
||||
Detail::QuicAnsweredWithoutHandshake => t4(lang, "Эндпоинт ответил, но данных рукопожатия не прислал (только подтверждение)", "The endpoint answered, and sent no handshake data at all (an acknowledgement, nothing else)", "端点有回应,但完全没有握手数据(只有确认)", "Endpoint pasokh dad, vali hich dade-ye handshake nafrestad (faghat acknowledgement)"),
|
||||
Detail::QuicPortUnreachable => t4(lang, "ICMP: на UDP-порту никто не слушает", "ICMP says nothing listens on the UDP port", "ICMP 表示该 UDP 端口无人监听", "ICMP migooyad hich kas ru-ye in port-e UDP gush nemidahad"),
|
||||
// Free text from the OS or the TLS stack: appended as it came.
|
||||
Detail::Other(text) => text.clone(),
|
||||
}
|
||||
|
|
@ -153,6 +167,17 @@ mod tests {
|
|||
assert_eq!(detail_text(&Detail::UpgradeHttps { status: Some(301) }, Ru), "301 → https");
|
||||
assert_eq!(detail_text(&Detail::UpgradeHttps { status: None }, Ru), "→ https");
|
||||
assert_eq!(detail_text(&Detail::Elapsed(0.3), Ru), "0.3s");
|
||||
// A transport error is a number from the peer: it is composed into the
|
||||
// label, so the label must not carry the bracket itself (the message
|
||||
// once ended in "(transport error" and the number hung outside it).
|
||||
assert_eq!(
|
||||
detail_text(&Detail::QuicClose { error_code: 10 }, En),
|
||||
"The server closed the QUIC connection (10)"
|
||||
);
|
||||
assert_eq!(
|
||||
detail_text(&Detail::QuicClose { error_code: 0x12f }, Zh),
|
||||
"服务器关闭了 QUIC 连接 (303)"
|
||||
);
|
||||
assert_eq!(detail_text(&Detail::None, Ru), "");
|
||||
assert_eq!(detail_text(&Detail::Other("→ https".into()), En), "→ https");
|
||||
}
|
||||
|
|
@ -244,6 +269,16 @@ mod tests {
|
|||
Detail::IcmpAdminProhibited,
|
||||
Detail::UnknownConnectionFailure,
|
||||
Detail::Ipv6Unsupported,
|
||||
Detail::QuicServerHello,
|
||||
Detail::QuicRetry,
|
||||
Detail::QuicForgedRetry,
|
||||
Detail::QuicClose { error_code: 8 },
|
||||
Detail::QuicReset,
|
||||
Detail::QuicVersionNegotiation,
|
||||
Detail::QuicTimeout,
|
||||
Detail::QuicUnreadableReply,
|
||||
Detail::QuicAnsweredWithoutHandshake,
|
||||
Detail::QuicPortUnreachable,
|
||||
Detail::at_kb(Detail::TimeoutWord, 24.0),
|
||||
Detail::TimeoutStage { stage: "reading_data".into() },
|
||||
Detail::IspBlockpage { arrow: true, ip: "192.0.2.1".into() },
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ pub(crate) fn messages() -> Messages {
|
|||
http: "HTTP",
|
||||
tls12: "TLS1.2",
|
||||
tls13: "TLS1.3",
|
||||
quic_col: "QUIC",
|
||||
dns_info_title: "[i] DNS RESOLUTION INFO:",
|
||||
traffic_fakeip: "Traffic intercepted by Fake-IP: for {} domains",
|
||||
dns_isp_stub: "DNS returned ISP blockpage IP ({}): for {} domains",
|
||||
|
|
@ -326,6 +327,14 @@ pub(crate) fn legend_sections_en() -> Vec<(&'static str, Vec<(&'static str, &'st
|
|||
("DETECTED", "Send-side cut inside the same 14–36 KB window — the same 16KB DROP, for the 16 KB test, which sends instead of reading"),
|
||||
("OK", "All 10 requests (up to 40 KB) passed without a break"),
|
||||
]),
|
||||
("— QUIC —", vec![
|
||||
("OK", "The QUIC endpoint answered the Initial: the UDP path to 443 works and its HTTP/3 stack is running (a ServerHello, or a Retry whose integrity tag checked out)"),
|
||||
("CLOSED", "The endpoint answered with a protected CONNECTION_CLOSE or a stateless reset: the path works, the handshake does not run — the detail names the transport error"),
|
||||
("VN", "The endpoint does not speak QUIC v1 and listed the versions it does"),
|
||||
("SPOOF", "A Retry whose integrity tag does not match: the packet did not come from the endpoint, since only a party that saw the Initial can compute that tag"),
|
||||
("DROP", "No reply to the Initial: UDP 443 is filtered or the endpoint is down — the silence SYN DROP is for TCP"),
|
||||
("REFUSED", "ICMP says nothing listens on UDP 443: the path works and the endpoint runs no QUIC at all — not a block (a block is DROP)"),
|
||||
]),
|
||||
("— Other —", vec![
|
||||
("OK", "Site reachable (200–4xx with no block signs)"),
|
||||
("UNKNOWN", "Unknown error (exception type in parentheses)"),
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ pub(crate) fn messages() -> Messages {
|
|||
http: "HTTP",
|
||||
tls12: "TLS1.2",
|
||||
tls13: "TLS1.3",
|
||||
quic_col: "QUIC",
|
||||
dns_info_title: "[i] Ettela'ate tahlil-e DNS:",
|
||||
traffic_fakeip: "Traffic tavasot-e Fake-IP intercept mishavad: baraye {} domain",
|
||||
dns_isp_stub: "DNS IP-e blockpage-e ISP ra bargardand ({}): baraye {} domain",
|
||||
|
|
@ -322,6 +323,14 @@ pub(crate) fn legend_sections_fa() -> Vec<(&'static str, Vec<(&'static str, &'st
|
|||
("DETECTED", "Ghat' dar ersal dar hamaan panjere-ye 14 ta 36 kilobyte - hamaan 16KB DROP, baraye azmun-e 16 KB ke ersal mikonad na khanesh"),
|
||||
("OK", "Har 10 darkhast (ta 40 kilobyte) bedun-e ghat'i anjam shodand"),
|
||||
]),
|
||||
("- QUIC -", vec![
|
||||
("OK", "Endpoint-e QUIC be Initial pasokh dad: masir-e UDP ta 443 kar mikonad va stack-e HTTP/3 zende ast (ServerHello, ya Retry ba integrity tag-e dorost)"),
|
||||
("CLOSED", "Endpoint ba CONNECTION_CLOSE-e mahfuz ya stateless reset pasokh dad: masir kar mikonad, handshake na - detail code-e transport error ra migooyad"),
|
||||
("VN", "Endpoint version-e QUIC v1 ra nadarad va version-hayi ke darad ra fehrest kard"),
|
||||
("SPOOF", "Retry ba integrity tag-e ghalat: packet az endpoint nayamade, chon tag ra faghat kasani ke Initial ra dideand mitavanand hesab konand"),
|
||||
("DROP", "Be Initial pasokhi nayamad: UDP 443 filter shode ya endpoint kharab ast (haman sokuti ke SYN DROP baraye TCP ast)"),
|
||||
("REFUSED", "ICMP: ru-ye UDP-port hich kas goosh nemidahad - masir kar mikonad va endpoint aslan QUIC nadarad; in block nist (block = DROP)"),
|
||||
]),
|
||||
("- Sayer -", vec![
|
||||
("OK", "Site dar dastras ast (kode 200-4xx bedun-e alayem-e filtering)"),
|
||||
("UNKNOWN", "Khatay-e nashenakhte (no'-e khata dar parantez)"),
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ pub(crate) struct Messages {
|
|||
pub http: &'static str,
|
||||
pub tls12: &'static str,
|
||||
pub tls13: &'static str,
|
||||
/// Test 2's QUIC column header: a protocol token, Latin in every language.
|
||||
pub quic_col: &'static str,
|
||||
pub dns_info_title: &'static str,
|
||||
pub traffic_fakeip: &'static str,
|
||||
pub dns_isp_stub: &'static str,
|
||||
|
|
@ -357,6 +359,7 @@ impl Messages {
|
|||
dpi_core::PhaseId::DomainTls13 => dpi_core::ProgressBlock::DomainTls13.token().to_string(),
|
||||
dpi_core::PhaseId::DomainTls12 => dpi_core::ProgressBlock::DomainTls12.token().to_string(),
|
||||
dpi_core::PhaseId::DomainHttp => dpi_core::ProgressBlock::DomainHttp.token().to_string(),
|
||||
dpi_core::PhaseId::DomainQuic => dpi_core::ProgressBlock::DomainQuic.token().to_string(),
|
||||
dpi_core::PhaseId::Tcp16 => self.tcp16_check_title.to_string(),
|
||||
dpi_core::PhaseId::SniBase => self.phase_sni_base.to_string(),
|
||||
dpi_core::PhaseId::SniParallel { detected_as, batch, top_n } => {
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ pub(crate) fn messages() -> Messages {
|
|||
http: "HTTP",
|
||||
tls12: "TLS1.2",
|
||||
tls13: "TLS1.3",
|
||||
quic_col: "QUIC",
|
||||
dns_info_title: "[i] ИНФОРМАЦИЯ О DNS РЕЗОЛВЕ:",
|
||||
traffic_fakeip: "Трафик перехватывается Fake-IP: у {} доменов",
|
||||
dns_isp_stub: "DNS вернул IP заглушки провайдера ({}): у {} доменов",
|
||||
|
|
@ -325,6 +326,14 @@ pub(crate) fn legend_sections() -> Vec<(&'static str, Vec<(&'static str, &'stati
|
|||
("DETECTED", "Обрыв при отправке в том же окне 14–36 KB — тот же 16KB DROP, но для теста 16 KB, где мы отправляем, а не читаем"),
|
||||
("OK", "Все 10 запросов (до 40 КБ) прошли без обрыва"),
|
||||
]),
|
||||
("— QUIC —", vec![
|
||||
("OK", "QUIC-эндпоинт ответил на Initial: UDP-путь до 443 работает, HTTP/3-стек жив (ServerHello, либо Retry с верным integrity-тегом)"),
|
||||
("CLOSED", "Эндпоинт ответил защищённым CONNECTION_CLOSE или stateless reset: путь работает, рукопожатие — нет; в деталях — код transport error"),
|
||||
("VN", "Эндпоинт не поддерживает QUIC v1 и перечислил версии, которые умеет"),
|
||||
("SPOOF", "Retry с неверным integrity-тегом: пакет пришёл не от эндпоинта — тег может посчитать только тот, кто видел Initial"),
|
||||
("DROP", "Ответа на Initial нет: UDP 443 фильтруется или эндпоинт недоступен (тишина, как SYN DROP для TCP)"),
|
||||
("REFUSED", "ICMP: на UDP-порту никто не слушает — путь работает, QUIC-слушателя у эндпоинта нет вовсе; это не блокировка (блокировка — DROP)"),
|
||||
]),
|
||||
("— Прочее —", vec![
|
||||
("OK", "Сайт доступен (200–4xx без признаков блокировки)"),
|
||||
("UNKNOWN", "Неизвестная ошибка (в скобках — тип исключения)"),
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ pub(crate) fn messages() -> Messages {
|
|||
http: "HTTP",
|
||||
tls12: "TLS1.2",
|
||||
tls13: "TLS1.3",
|
||||
quic_col: "QUIC",
|
||||
dns_info_title: "[i] DNS 解析信息:",
|
||||
traffic_fakeip: "流量被 Fake-IP 拦截: 共 {} 个域名",
|
||||
dns_isp_stub: "DNS 返回了 ISP 封锁页面 IP ({}): 共 {} 个域名",
|
||||
|
|
@ -322,6 +323,14 @@ pub(crate) fn legend_sections_zh() -> Vec<(&'static str, Vec<(&'static str, &'st
|
|||
("DETECTED", "同一 14–36 KB 窗口内的发送被切断 — 即 16 KB 测试的 16KB DROP (该测试发送而非读取)"),
|
||||
("OK", "所有 10 次请求 (最高 40 KB) 均正常传输无阻断"),
|
||||
]),
|
||||
("— QUIC —", vec![
|
||||
("OK", "QUIC 端点回应了 Initial:到 443 的 UDP 路径可用,HTTP/3 栈在运行(ServerHello,或完整性标签正确的 Retry)"),
|
||||
("CLOSED", "端点以受保护的 CONNECTION_CLOSE 或无状态重置回应:路径可用,握手不会进行;详情给出 transport error 代码"),
|
||||
("VN", "端点不支持 QUIC v1,并列出了它支持的版本"),
|
||||
("SPOOF", "Retry 的完整性标签不匹配:该报文并非来自端点(只有看到 Initial 的一方能计算该标签)"),
|
||||
("DROP", "Initial 没有回应:UDP 443 被过滤或端点不可用(TCP 中的对应物是 SYN DROP)"),
|
||||
("REFUSED", "ICMP:UDP 端口无人监听——路径可用,端点根本没有 QUIC 监听器;这不是封锁(封锁是 DROP)"),
|
||||
]),
|
||||
("— 其他 —", vec![
|
||||
("OK", "站点可正常访问 (状态码 200–4xx 无阻断特征)"),
|
||||
("UNKNOWN", "未知异常 (括号内为具体异常类型)"),
|
||||
|
|
|
|||
|
|
@ -122,6 +122,9 @@ pub(crate) struct DomainRow {
|
|||
pub tls12_detail: Detail,
|
||||
pub tls13: &'static str,
|
||||
pub tls13_detail: Detail,
|
||||
/// The QUIC column: the `DpiStatus` token of the Initial probe on UDP 443.
|
||||
pub quic: &'static str,
|
||||
pub quic_detail: Detail,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use dpi_core::probe::domains::{
|
|||
};
|
||||
use dpi_core::probe::telegram::run_telegram_full;
|
||||
use dpi_core::probe::whitelist::run_whitelist_sni;
|
||||
use dpi_core::probe::{check_tcp_16_20, domains};
|
||||
use dpi_core::probe::{check_quic_all, check_tcp_16_20, domains};
|
||||
use dpi_core::profile::RegionProfile;
|
||||
use dpi_core::{PhaseProgress, ProgressTick};
|
||||
use tokio::sync::Semaphore;
|
||||
|
|
@ -874,6 +874,7 @@ pub(crate) async fn run_test_suite(
|
|||
(dpi_core::ProgressBlock::DomainTls13, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainTls12, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainHttp, domains.len()),
|
||||
(dpi_core::ProgressBlock::DomainQuic, domains.len()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -881,6 +882,7 @@ pub(crate) async fn run_test_suite(
|
|||
check_tls_all(&mut entries, false, cfg, &sem, phases.clone()).await;
|
||||
check_tls_all(&mut entries, true, cfg, &sem, phases.clone()).await;
|
||||
check_http_all(&mut entries, cfg, &stub_ips, &sem, phases.clone()).await;
|
||||
check_quic_all(&mut entries, cfg, &sem, phases.clone()).await;
|
||||
live.finish();
|
||||
|
||||
let stats = domain_stats(&entries);
|
||||
|
|
@ -900,6 +902,8 @@ pub(crate) async fn run_test_suite(
|
|||
tls12_detail: e.t12.detail.clone(),
|
||||
tls13: e.t13.status.as_str(),
|
||||
tls13_detail: e.t13.detail.clone(),
|
||||
quic: e.quic.status.as_str(),
|
||||
quic_detail: e.quic.detail.clone(),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -154,6 +154,19 @@ pub(crate) fn status_color(s: DpiStatus) -> Color {
|
|||
DpiStatus::NoTls13 | DpiStatus::NoCa => Color::Yellow,
|
||||
DpiStatus::LocalIp | DpiStatus::DnsFail | DpiStatus::NxDomain => Color::Yellow,
|
||||
DpiStatus::Err => Color::DarkGrey,
|
||||
// QUIC: green only for an endpoint that answered the handshake. A close
|
||||
// or a version negotiation is the path working and the protocol not —
|
||||
// yellow, the same "read the detail" colour the TLS columns use for
|
||||
// `NoTls13`. Silence and an imitation are the red ones.
|
||||
DpiStatus::QuicOk => Color::Green,
|
||||
DpiStatus::QuicClosed | DpiStatus::QuicVn => Color::Yellow,
|
||||
DpiStatus::QuicSpoof | DpiStatus::QuicDrop => Color::Red,
|
||||
// A refusal is the peer *answering*: ECONNREFUSED on TCP is the
|
||||
// endpoint's own RST, and on the QUIC column it is the kernel handing up
|
||||
// an ICMP "nothing listens on that port". Both say the path works and the
|
||||
// service is not there, which is what yellow says — a block looks like
|
||||
// `TcpRst` or `DROP`, not like a refusal.
|
||||
DpiStatus::Refused => Color::Yellow,
|
||||
DpiStatus::RedirSuspect
|
||||
| DpiStatus::Blocked
|
||||
| DpiStatus::IspPage
|
||||
|
|
@ -174,7 +187,6 @@ pub(crate) fn status_color(s: DpiStatus) -> Color {
|
|||
| DpiStatus::TlsEof
|
||||
| DpiStatus::Tcp16Range
|
||||
| DpiStatus::SynDropped
|
||||
| DpiStatus::Refused
|
||||
| DpiStatus::NetUnreach
|
||||
| DpiStatus::HostUnreach
|
||||
| DpiStatus::OsErr
|
||||
|
|
|
|||
|
|
@ -18,17 +18,19 @@ pub(crate) fn render_domain_table(entries: &[DomainEntry], msg: &Messages) -> St
|
|||
Cell::new(msg.http),
|
||||
Cell::new(msg.tls12),
|
||||
Cell::new(msg.tls13),
|
||||
Cell::new(msg.quic_col),
|
||||
Cell::new(format_bidi(msg.detail, msg.lang)),
|
||||
]);
|
||||
|
||||
for e in entries {
|
||||
let (http_s, t12_s, t13_s, raw_details) = dpi_core::probe::domains::build_domain_row(e);
|
||||
let (http_s, t12_s, t13_s, quic_s, raw_details) = dpi_core::probe::domains::build_domain_row(e);
|
||||
let details = row_details(&raw_details, msg.lang);
|
||||
table.add_row(vec![
|
||||
Cell::new(cell_color(&e.domain, Color::Cyan)),
|
||||
Cell::new(cell_color(http_s.display_label(), status_color(http_s))),
|
||||
Cell::new(cell_color(t12_s.display_label(), status_color(t12_s))),
|
||||
Cell::new(cell_color(t13_s.display_label(), status_color(t13_s))),
|
||||
Cell::new(cell_color(quic_s.display_label(), status_color(quic_s))),
|
||||
Cell::new(details),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,10 @@ as Chrome 133–146 in §1, not a divergence from the source.
|
|||
|
||||
* **QUIC/HTTP-3.** In `vendor/rustls-rustcrypto/src/quic.rs` there are stubs, and
|
||||
every TLS 1.3 suite has `quic: None`. As long as that is so, no h3 profile is
|
||||
possible, regardless of `quinn`/`h3`. The order of work, if one takes this on:
|
||||
possible, regardless of `quinn`/`h3`. A record's `quic` flag
|
||||
(`ClientHelloProfile::quic`, the patched hook) only empties `legacy_session_id`
|
||||
the way RFC 9001 §8.4 requires, which is what test 2's QUIC column needs — the
|
||||
handshake still does not run over QUIC. The order of work, if one takes this on:
|
||||
header protection per RFC 9001 §5.4 with the §A.2 test vectors, `quinn` without the
|
||||
default `rustls-ring`, a transport parameters patch, and an `http3` feature
|
||||
(off by default — Rule 2).
|
||||
|
|
|
|||
160
scripts/quic/bracket.py
Normal file
160
scripts/quic/bracket.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Bracketed control: is a host's silence ours, or the network's?
|
||||
|
||||
For the whole host list in one window: a stock client to every host, then the
|
||||
detector once for all of them, then the stock client again. If the stock client
|
||||
answers on both sides while the detector sees silence, the endpoint is reacting
|
||||
to the detector's own ClientHello. If it goes quiet too, the path changed between
|
||||
runs and no verdict can be compared across them.
|
||||
|
||||
The detector runs as **one process for all hosts** (it is concurrent inside, and
|
||||
per-host invocations paid its startup once per row), and the stock-client checks
|
||||
run in a bounded pool: a 34-host sweep is about a minute rather than eight, with
|
||||
the bracket still spanning the detector's own window.
|
||||
|
||||
Run: python scripts/quic/bracket.py [host ...]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aioquic.asyncio.client import connect
|
||||
from aioquic.asyncio.protocol import QuicConnectionProtocol
|
||||
from aioquic.h3.connection import H3_ALPN, H3Connection
|
||||
from aioquic.h3.events import HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ConnectionTerminated
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
EXE = ROOT / "target" / "release-local" / "dpi-detector.exe"
|
||||
# 8 s, not 4: the probe's own measured window (`QUIC_TIMEOUT` in `config.yml`).
|
||||
# The endpoint's answer to a *retransmitted* Initial arrives after the first PTO
|
||||
# (~0.7 s) and sometimes later still, so a 4-second client gives up before it —
|
||||
# which is how `www.dw.com`, `www.euronews.com` and `www.intel.com` read as
|
||||
# "no answer" in one pass and `HTTP/3 200`/`403` in the next.
|
||||
TIMEOUT = 8.0
|
||||
JOBS = 8
|
||||
|
||||
|
||||
class H3Probe(QuicConnectionProtocol):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.http = H3Connection(self._quic)
|
||||
self.status = None
|
||||
self.terminated = None
|
||||
self.done = asyncio.Event()
|
||||
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, ConnectionTerminated):
|
||||
self.terminated = (event.error_code, event.reason_phrase)
|
||||
self.done.set()
|
||||
for http_event in self.http.handle_event(event):
|
||||
if isinstance(http_event, HeadersReceived):
|
||||
self.status = dict(http_event.headers).get(b":status")
|
||||
self.done.set()
|
||||
|
||||
|
||||
async def stock(host, ip):
|
||||
cfg = QuicConfiguration(
|
||||
is_client=True,
|
||||
alpn_protocols=H3_ALPN,
|
||||
verify_mode=ssl.CERT_NONE,
|
||||
server_name=host,
|
||||
idle_timeout=TIMEOUT,
|
||||
)
|
||||
try:
|
||||
async with connect(ip, 443, configuration=cfg, create_protocol=H3Probe, wait_connected=False) as client:
|
||||
stream_id = client._quic.get_next_available_stream_id()
|
||||
client.http.send_headers(
|
||||
stream_id,
|
||||
[(b":method", b"GET"), (b":scheme", b"https"), (b":authority", host.encode()), (b":path", b"/")],
|
||||
end_stream=True,
|
||||
)
|
||||
client.transmit()
|
||||
try:
|
||||
await asyncio.wait_for(client.done.wait(), timeout=TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
return "no answer"
|
||||
if client.status is not None:
|
||||
return f"HTTP/3 {client.status.decode()}"
|
||||
code, reason = client.terminated
|
||||
if reason and "idle timeout" in reason.lower():
|
||||
return "no answer (our idle timeout)"
|
||||
return f"closed {code} ({reason})" if reason else f"closed {code}"
|
||||
except Exception as error: # noqa: BLE001
|
||||
return f"{type(error).__name__}: {error}"
|
||||
|
||||
|
||||
def detector_rows(hosts):
|
||||
"""One detector process for the whole list, keyed by the host as asked."""
|
||||
args = [str(EXE), "--tests", "2", "--json", "--lang", "en"]
|
||||
for host in hosts:
|
||||
args += ["-d", host]
|
||||
out = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", cwd=ROOT, timeout=900)
|
||||
payload = json.loads(out.stdout[out.stdout.index("{"):])
|
||||
rows = {}
|
||||
for row in payload["results"]["domain_inspection"]:
|
||||
rows[row["domain"]] = (row["resolved"], f"{row['quic']}({row['quic_detail']})")
|
||||
return rows
|
||||
|
||||
|
||||
def resolve(host):
|
||||
for attempt in range(3):
|
||||
try:
|
||||
return socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_DGRAM)[0][4][0]
|
||||
except OSError:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
return None
|
||||
|
||||
|
||||
async def pooled(jobs, work, items):
|
||||
"""Run `work` over `items` with at most `jobs` in flight, keeping the order."""
|
||||
gate = asyncio.Semaphore(jobs)
|
||||
|
||||
async def run(item):
|
||||
async with gate:
|
||||
return await work(item)
|
||||
|
||||
return await asyncio.gather(*(run(item) for item in items))
|
||||
|
||||
|
||||
async def main(hosts):
|
||||
addresses = await pooled(JOBS, lambda host: asyncio.to_thread(resolve, host), hosts)
|
||||
resolved = {host: ip for host, ip in zip(hosts, addresses) if ip is not None}
|
||||
for host, ip in zip(hosts, addresses):
|
||||
if ip is None:
|
||||
print(f"{host:20} resolve failed (DNS), skipped")
|
||||
print(f"stock client, first pass: {len(resolved)} hosts", flush=True)
|
||||
before = await pooled(JOBS, lambda host: stock(host, resolved[host]), list(resolved))
|
||||
print("detector, one process for the whole list", flush=True)
|
||||
mine = await asyncio.to_thread(detector_rows, list(resolved))
|
||||
print("stock client, second pass", flush=True)
|
||||
after = await pooled(
|
||||
JOBS,
|
||||
lambda host: stock(host, mine.get(host, (resolved[host], ""))[0] or resolved[host]),
|
||||
list(resolved),
|
||||
)
|
||||
for host, first, second in zip(list(resolved), before, after):
|
||||
ip, verdict = mine.get(host, (resolved[host], "no row in --json"))
|
||||
print(f"{host:20} stock@{resolved[host]:16} {first:16} | detector@{ip:16} {verdict:34} | stock again {second}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# No arguments: the same host list `validate.py` and `crosscheck.py` use,
|
||||
# read from the file next to this script (a bare default of one host made a
|
||||
# "full sweep" a one-row run).
|
||||
hosts = sys.argv[1:]
|
||||
if not hosts:
|
||||
listing = pathlib.Path(__file__).with_name("hosts.txt")
|
||||
hosts = [
|
||||
line.strip()
|
||||
for line in listing.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.startswith("#")
|
||||
]
|
||||
asyncio.run(main(hosts))
|
||||
86
scripts/quic/browser_check.py
Normal file
86
scripts/quic/browser_check.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Does a real browser complete HTTP/3 with this host?
|
||||
|
||||
The column claims to measure *the browser's* QUIC path, so the reference for its
|
||||
verdicts is a browser, not a minimal Python client: measured, `www.apkmirror.com`
|
||||
answers a browser-shaped (split, 1740-byte) hello with an Initial carrying no
|
||||
CRYPTO while a stock client's single-datagram hello gets `HTTP/3 200` - the two
|
||||
clients are not the same experiment.
|
||||
|
||||
This drives a headless Chrome with QUIC forced for one origin, captures the
|
||||
wire, and reports what the endpoint did with it: a `Handshake` packet and a
|
||||
`SETTINGS` frame mean the H3 handshake completed, an Initial that opens into
|
||||
nothing means it did not.
|
||||
|
||||
Run: python scripts/quic/browser_check.py www.apkmirror.com [interface]
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSHARK = r"C:\Program Files\Wireshark\tshark.exe"
|
||||
DUMPCAP = r"C:\Program Files\Wireshark\dumpcap.exe"
|
||||
CHROME = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
DEFAULT_INTERFACE = r"\Device\NPF_{2D03CDCB-E549-4703-89C3-A8BFD9BC8A5F}"
|
||||
|
||||
|
||||
def capture(host, interface, seconds, out):
|
||||
cap = subprocess.Popen(
|
||||
[DUMPCAP, "-i", interface, "-f", "udp port 443", "-w", str(out), "-a", f"duration:{seconds}"],
|
||||
# Not PIPE: nobody reads it, the buffer fills, and dumpcap blocks before
|
||||
# its own duration expires (measured - the first version hung here).
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
time.sleep(2.5)
|
||||
profile = ROOT / "target" / f"chrome-{host.replace('.', '-')}"
|
||||
subprocess.run(
|
||||
[
|
||||
CHROME,
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
f"--user-data-dir={profile}",
|
||||
f"--origin-to-force-quic-on={host}:443",
|
||||
"--dump-dom",
|
||||
f"https://{host}/",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
cap.wait(timeout=seconds + 30)
|
||||
|
||||
|
||||
def report(path, host):
|
||||
out = subprocess.run(
|
||||
[TSHARK, "-r", str(path), "-Y", "quic", "-T", "fields", "-e", "frame.number", "-e", "ip.src", "-e", "udp.length", "-e", "_ws.col.Info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
server_frames = [line for line in out.stdout.splitlines() if line.strip() and not line.split("\t")[1].startswith("192.168.")]
|
||||
print(f"{len(server_frames)} server QUIC packets in the capture")
|
||||
for line in server_frames[:14]:
|
||||
print(" ", line.replace("\t", " "))
|
||||
# A `Handshake` packet, not merely a short-header one: a stateless reset is
|
||||
# also a short header and reads as `Protected Payload`, so the weaker test
|
||||
# called a reset "completed" (measured on a capture where the endpoint
|
||||
# answered a browser with a reset and nothing else).
|
||||
completed = any("Handshake" in line for line in server_frames)
|
||||
print(f"\n{host}: a real browser {'completed' if completed else 'did NOT complete'} the HTTP/3 handshake")
|
||||
|
||||
|
||||
def main(host, interface):
|
||||
out = ROOT / "target" / "validation" / f"browser-{host.replace('.', '-')}.pcapng"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
capture(host, interface, 35, out)
|
||||
report(out, host)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(
|
||||
sys.argv[1] if len(sys.argv) > 1 else "www.apkmirror.com",
|
||||
sys.argv[2] if len(sys.argv) > 2 else DEFAULT_INTERFACE,
|
||||
)
|
||||
58
scripts/quic/capture-run.ps1
Normal file
58
scripts/quic/capture-run.ps1
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Capture while one client runs, in one step.
|
||||
#
|
||||
# Started by hand ten times in a row, `dumpcap` in the background is a coin flip:
|
||||
# its stdout is a pipe nobody drains, so it blocks before its own duration fires
|
||||
# and the next capture finds the interface busy. This runs the capture, then the
|
||||
# client, then waits for the capture to finish by itself.
|
||||
#
|
||||
# pwsh -NoProfile -File scripts/quic/capture-run.ps1 `
|
||||
# -Out target/validation/hello-ours.pcapng -Exe ./target/release-local/dpi-detector.exe `
|
||||
# -ExeArgs '--tests 2 -d www.apkmirror.com --lang en'
|
||||
#
|
||||
# `-Filter` defaults to the QUIC column's traffic; `tcp port 443` captures the TLS
|
||||
# columns instead, which is what a TLS handshake that never completes needs.
|
||||
#
|
||||
# `-ExeArgs` is one string split on whitespace: bound as an array from `-File`,
|
||||
# PowerShell joins it back into a single argument and the client sees
|
||||
# `--tests,2,-d,...`.
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Out,
|
||||
[Parameter(Mandatory = $true)][string]$Exe,
|
||||
[string]$ExeArgs = '',
|
||||
[int]$Seconds = 30,
|
||||
[string]$Filter = 'udp port 443',
|
||||
[string]$Interface = '\Device\NPF_{2D03CDCB-E549-4703-89C3-A8BFD9BC8A5F}'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dumpcap = 'C:\Program Files\Wireshark\dumpcap.exe'
|
||||
$tshark = 'C:\Program Files\Wireshark\tshark.exe'
|
||||
$full = Join-Path (Get-Location) $Out
|
||||
$log = "$full.dumpcap.log"
|
||||
|
||||
# Any leftover capture holds the interface; nothing here should outlive its own
|
||||
# duration, so clearing them is safe.
|
||||
Get-Process dumpcap -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
# `-WindowStyle Hidden` with nobody reading its output hid a capture that never
|
||||
# opened the adapter: the file came out empty and the script still said
|
||||
# "capture: ...". The log below is what makes that failure visible.
|
||||
$capture = Start-Process -FilePath $dumpcap `
|
||||
-ArgumentList "-i `"$Interface`" -f `"$Filter`" -w `"$full`" -a duration:$Seconds" `
|
||||
-PassThru -WindowStyle Hidden -RedirectStandardError $log
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
$arguments = @($ExeArgs -split '\s+' | Where-Object { $_ -ne '' })
|
||||
Write-Output "running: $Exe $($arguments -join ' ')"
|
||||
& $Exe @arguments | Out-Null
|
||||
|
||||
$capture.WaitForExit()
|
||||
|
||||
$packets = (& $tshark -r $full -T fields -e frame.number 2>$null | Measure-Object -Line).Lines
|
||||
if ($packets -eq 0) {
|
||||
Write-Output "capture: $Out — NOTHING CAPTURED"
|
||||
if (Test-Path $log) { Get-Content $log | Write-Output }
|
||||
exit 1
|
||||
}
|
||||
Write-Output "capture: $Out ($packets packets)"
|
||||
175
scripts/quic/crosscheck.py
Normal file
175
scripts/quic/crosscheck.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""Cross-check the detector's QUIC column at the address the detector itself used.
|
||||
|
||||
Reads the detector's `--json` (`resolved` per domain), then points aioquic at
|
||||
that same IP - otherwise a "DROP" could just be a different anycast node than
|
||||
the one Python's getaddrinfo picked, and the comparison would prove nothing.
|
||||
|
||||
Run: python target/quic-crosscheck.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aioquic.asyncio.client import connect
|
||||
from aioquic.asyncio.protocol import QuicConnectionProtocol
|
||||
from aioquic.h3.connection import H3_ALPN, H3Connection
|
||||
from aioquic.h3.events import HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ConnectionTerminated, HandshakeCompleted
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
EXE = ROOT / "target" / "release-local" / "dpi-detector.exe"
|
||||
HOSTS = [h.strip() for h in (Path(__file__).resolve().parent / "hosts.txt").read_text().split() if h.strip()]
|
||||
TIMEOUT = 8.0 # the probe's own window (`QUIC_TIMEOUT`); see bracket.py's note
|
||||
SEM = asyncio.Semaphore(8)
|
||||
|
||||
|
||||
class H3Probe(QuicConnectionProtocol):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.http = H3Connection(self._quic)
|
||||
self.handshook = False
|
||||
self.status = None
|
||||
self.terminated = None
|
||||
self.done = asyncio.Event()
|
||||
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, HandshakeCompleted):
|
||||
self.handshook = True
|
||||
if isinstance(event, ConnectionTerminated):
|
||||
self.terminated = (event.error_code, event.frame_type, event.reason_phrase)
|
||||
self.done.set()
|
||||
for http_event in self.http.handle_event(event):
|
||||
if isinstance(http_event, HeadersReceived):
|
||||
self.status = dict(http_event.headers).get(b":status")
|
||||
self.done.set()
|
||||
|
||||
|
||||
async def probe(host, ip):
|
||||
cfg = QuicConfiguration(
|
||||
is_client=True,
|
||||
alpn_protocols=H3_ALPN,
|
||||
verify_mode=ssl.CERT_NONE,
|
||||
server_name=host,
|
||||
idle_timeout=TIMEOUT,
|
||||
)
|
||||
try:
|
||||
async with connect(ip, 443, configuration=cfg, create_protocol=H3Probe, wait_connected=False) as client:
|
||||
stream_id = client._quic.get_next_available_stream_id()
|
||||
client.http.send_headers(
|
||||
stream_id,
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", host.encode()),
|
||||
(b":path", b"/"),
|
||||
(b"user-agent", b"quic-crosscheck"),
|
||||
],
|
||||
end_stream=True,
|
||||
)
|
||||
client.transmit()
|
||||
try:
|
||||
await asyncio.wait_for(client.done.wait(), timeout=TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
if client.status is not None:
|
||||
return f"HTTP/3 {client.status.decode()}"
|
||||
return "handshake ok, no response" if client.handshook else "timeout: no reply to our Initial"
|
||||
if client.status is not None:
|
||||
return f"HTTP/3 {client.status.decode()}"
|
||||
code, frame, reason = client.terminated
|
||||
if code is None:
|
||||
return "closed without a code"
|
||||
# aioquic's own idle timeout arrives as INTERNAL_ERROR with this
|
||||
# reason: the endpoint never answered, so it is not a close.
|
||||
if reason and "idle timeout" in reason.lower():
|
||||
return "timeout: no reply to our Initial"
|
||||
if 0x100 <= code <= 0x1FF:
|
||||
return f"closed {code} = CRYPTO_ERROR, TLS alert {code - 0x100}"
|
||||
return f"closed, transport error {code}"
|
||||
except ConnectionRefusedError:
|
||||
return "refused (ICMP port unreachable)"
|
||||
except Exception as error: # noqa: BLE001
|
||||
return f"{type(error).__name__}: {error}"
|
||||
|
||||
|
||||
def alt_svc(host):
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
with socket.create_connection((host, 443), timeout=6) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=host) as tls:
|
||||
tls.sendall(f"HEAD / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: x\r\nConnection: close\r\n\r\n".encode())
|
||||
data = b""
|
||||
while b"\r\n\r\n" not in data and len(data) < 65536:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except Exception as error: # noqa: BLE001
|
||||
return f"- ({type(error).__name__})"
|
||||
for line in data.split(b"\r\n"):
|
||||
if line.lower().startswith(b"alt-svc:"):
|
||||
return line.split(b":", 1)[1].strip().decode("latin-1")
|
||||
return "absent"
|
||||
|
||||
|
||||
def detector_rows():
|
||||
args = [str(EXE), "--tests", "2", "--json", "--lang", "en"]
|
||||
for host in HOSTS:
|
||||
args += ["-d", host]
|
||||
out = subprocess.run(args, capture_output=True, text=True, encoding="utf-8", cwd=ROOT, timeout=900)
|
||||
payload = json.loads(out.stdout[out.stdout.index("{"):])
|
||||
rows = {}
|
||||
for row in payload["results"]["domain_inspection"]:
|
||||
rows[row["domain"]] = row
|
||||
return rows
|
||||
|
||||
|
||||
def verdict(mine, theirs):
|
||||
if theirs.startswith("HTTP/3"):
|
||||
return "NO" if mine != "quic_ok" else "yes"
|
||||
if theirs.startswith("timeout"):
|
||||
return "yes" if mine == "quic_drop" else "NO"
|
||||
if theirs.startswith("refused"):
|
||||
return "yes" if mine == "refused" else "NO"
|
||||
if theirs.startswith("closed"):
|
||||
return "yes" if mine == "quic_closed" else "NO"
|
||||
return "?"
|
||||
|
||||
|
||||
async def main():
|
||||
rows = await asyncio.to_thread(detector_rows)
|
||||
lines = []
|
||||
lines.append(f"{'host':23} {'detector':30} {'detector IP':16} {'aioquic at THAT IP':44} agree")
|
||||
lines.append("-" * 130)
|
||||
agree = 0
|
||||
for host in HOSTS:
|
||||
row = rows.get(host)
|
||||
if row is None:
|
||||
lines.append(f"{host:23} {'(no row)':30}")
|
||||
continue
|
||||
ip = row.get("resolved") or "?"
|
||||
detail = row.get("quic_detail") or ""
|
||||
mine = row["quic"]
|
||||
theirs = await probe(host, ip) if ip != "?" else "no address"
|
||||
ok = verdict(mine, theirs)
|
||||
agree += ok == "yes"
|
||||
tag = mine + (f"({detail})" if detail else "")
|
||||
lines.append(f"{host:23} {tag:30} {ip:16} {theirs:44} {ok}")
|
||||
lines.append(f"\nсогласие на адресах детектора: {agree}/{len(HOSTS)}")
|
||||
text = "\n".join(lines)
|
||||
print(text)
|
||||
out = ROOT / "target" / "validation" / "crosscheck-latest.txt"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(text, encoding="utf-8")
|
||||
print(f"\n(таблица записана в {out})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not EXE.exists():
|
||||
sys.exit(f"no detector at {EXE}")
|
||||
asyncio.run(main())
|
||||
206
scripts/quic/decrypt.py
Normal file
206
scripts/quic/decrypt.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Decrypt the QUIC Initial packets of a capture by hand (RFC 9001 §5.2/§5.4).
|
||||
|
||||
Neither the probe nor tshark could open one class of endpoint reply, so this
|
||||
derives the Initial keys itself - from the client's DCID, its SCID, the empty
|
||||
DCID and the server's own SCID - and reports which one opens the packet and what
|
||||
the plaintext says. That is the difference between "the endpoint sent nothing
|
||||
readable" and "the endpoint sent a packet under keys the client does not derive".
|
||||
|
||||
Run: python scripts/quic/decrypt.py target/validation/quic-compare.pcapng
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.hmac import HMAC
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF, HKDFExpand
|
||||
|
||||
TSHARK = r"C:\Program Files\Wireshark\tshark.exe"
|
||||
SALT = bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a")
|
||||
V1 = 0x00000001
|
||||
|
||||
|
||||
def expand_label(secret, label, length):
|
||||
full = b"tls13 " + label
|
||||
info = length.to_bytes(2, "big") + bytes([len(full)]) + full + b"\x00"
|
||||
return HKDFExpand(algorithm=hashes.SHA256(), length=length, info=info).derive(secret)
|
||||
|
||||
|
||||
def keys_for(dcid):
|
||||
# RFC 9001 §5.2: extract with the salt, then HKDF-Expand-Label "client in"
|
||||
# and "server in" - the labels are expand labels, not plain HKDF info.
|
||||
extract = HMAC(SALT, hashes.SHA256())
|
||||
extract.update(dcid)
|
||||
initial = extract.finalize()
|
||||
out = {}
|
||||
for side in (b"client in", b"server in"):
|
||||
secret = expand_label(initial, side, 32)
|
||||
out[side] = (
|
||||
expand_label(secret, b"quic key", 16),
|
||||
expand_label(secret, b"quic iv", 12),
|
||||
expand_label(secret, b"quic hp", 16),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def read_varint(buf, at):
|
||||
prefix = buf[at] >> 6
|
||||
length = 1 << prefix
|
||||
value = buf[at] & 0x3F
|
||||
for i in range(1, length):
|
||||
value = (value << 8) | buf[at + i]
|
||||
return value, at + length
|
||||
|
||||
|
||||
def parse_long_header(packet):
|
||||
version = int.from_bytes(packet[1:5], "big")
|
||||
at = 5
|
||||
dcid_len = packet[at]
|
||||
at += 1
|
||||
dcid = packet[at:at + dcid_len]
|
||||
at += dcid_len
|
||||
scid_len = packet[at]
|
||||
at += 1
|
||||
scid = packet[at:at + scid_len]
|
||||
at += scid_len
|
||||
token_len, at = read_varint(packet, at)
|
||||
token = packet[at:at + token_len]
|
||||
at += token_len
|
||||
length, pn_offset = read_varint(packet, at)
|
||||
return version, dcid, scid, token, length, pn_offset
|
||||
|
||||
|
||||
def try_open(packet, dcid, label, side=b"server in"):
|
||||
version, pdcid, scid, token, length, pn_offset = parse_long_header(packet)
|
||||
if version != V1 or (packet[0] & 0x30) >> 4 != 0:
|
||||
print(f" [{label}] not a v1 Initial (version={version:#010x} type={(packet[0] & 0x30) >> 4})")
|
||||
return False
|
||||
key, iv, hp = keys_for(dcid)[side]
|
||||
sample = packet[pn_offset + 4:pn_offset + 20]
|
||||
if len(sample) < 16:
|
||||
print(f" [{label}] no room for a header-protection sample")
|
||||
return False
|
||||
mask = Cipher(algorithms.AES(hp), modes.ECB()).encryptor().update(sample)
|
||||
first = packet[0] ^ (mask[0] & 0x0F)
|
||||
pn_len = (first & 0x03) + 1
|
||||
pn_bytes = bytes(b ^ m for b, m in zip(packet[pn_offset:pn_offset + pn_len], mask[1:1 + pn_len]))
|
||||
pn = int.from_bytes(pn_bytes, "big")
|
||||
aad = bytes([first]) + packet[1:pn_offset] + pn_bytes
|
||||
payload = packet[pn_offset + pn_len:pn_offset + length]
|
||||
nonce = bytes(a ^ b for a, b in zip(iv, pn.to_bytes(12, "big")))
|
||||
try:
|
||||
plain = AESGCM(key).decrypt(nonce, payload, aad)
|
||||
except Exception as error: # noqa: BLE001
|
||||
print(
|
||||
f" [{label}] failed: first={first:#04x} pn_len={pn_len} pn={pn} "
|
||||
f"aad={len(aad)} payload={len(payload)} ({type(error).__name__})"
|
||||
)
|
||||
return False
|
||||
print(f" [{label}] OPENED: pn={pn} plaintext={plain[:80].hex()}")
|
||||
describe(plain)
|
||||
return True
|
||||
|
||||
|
||||
def describe(plain):
|
||||
at = 0
|
||||
while at < len(plain):
|
||||
frame_type = plain[at]
|
||||
if frame_type == 0x00:
|
||||
at += 1
|
||||
continue
|
||||
if frame_type == 0x06:
|
||||
offset, at = read_varint(plain, at + 1)
|
||||
length, at = read_varint(plain, at)
|
||||
kind = plain[at] if length else None
|
||||
print(f" CRYPTO offset={offset} len={length} handshake_type={kind:#04x}" if kind else f" CRYPTO offset={offset} len={length}")
|
||||
at += length
|
||||
continue
|
||||
if frame_type in (0x1C, 0x1D):
|
||||
at += 1
|
||||
code, at = read_varint(plain, at)
|
||||
alert = f" = TLS alert {code - 0x100}" if 0x100 <= code <= 0x1FF else ""
|
||||
print(f" CONNECTION_CLOSE code={code}{alert}")
|
||||
return
|
||||
if frame_type == 0x02:
|
||||
print(" ACK")
|
||||
return
|
||||
print(f" frame type {frame_type:#04x}")
|
||||
return
|
||||
|
||||
|
||||
def self_test():
|
||||
"""RFC 9001 A.1: the key schedule must reproduce the published values."""
|
||||
dcid = bytes.fromhex("8394c8f03e515708")
|
||||
key, iv, hp = keys_for(dcid)[b"client in"]
|
||||
expected = {
|
||||
"key": "1f369613dd76d5467730efcbe3b1a22d",
|
||||
"iv": "fa044b2f42a3fd3b46fb255c",
|
||||
"hp": "9f50449e04a0e810283a1e9933adedd2",
|
||||
}
|
||||
got = {"key": key.hex(), "iv": iv.hex(), "hp": hp.hex()}
|
||||
for name, want in expected.items():
|
||||
if got[name] != want:
|
||||
print(f"self-test FAILED {name}: got {got[name]} want {want}")
|
||||
return False
|
||||
print("self-test: the client Initial keys match RFC 9001 A.1")
|
||||
return True
|
||||
|
||||
|
||||
def main(path):
|
||||
if not self_test():
|
||||
return
|
||||
out = subprocess.run(
|
||||
[TSHARK, "-r", path, "-Y", "quic", "-T", "fields", "-e", "frame.number", "-e", "ip.src", "-e", "udp.payload"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
rows = [line.split("\t") for line in out.stdout.splitlines() if line.strip()]
|
||||
packets = []
|
||||
for number, src, payload in rows:
|
||||
packet = bytes.fromhex(payload)
|
||||
if len(packet) < 20 or packet[0] & 0x80 == 0:
|
||||
continue
|
||||
packets.append((number, src, packet))
|
||||
|
||||
# The client's own CIDs come from its first Initial: the DCID it chose (the
|
||||
# key schedule's input) and the SCID it wants answers addressed to.
|
||||
client_dcid = client_scid = None
|
||||
for number, src, packet in packets:
|
||||
if not src.startswith("192.168."):
|
||||
continue
|
||||
_, dcid, scid, _, _, _ = parse_long_header(packet)
|
||||
if client_dcid is None:
|
||||
client_dcid, client_scid = dcid, scid
|
||||
print(f"client DCID={dcid.hex()} SCID={scid.hex()}")
|
||||
break
|
||||
|
||||
candidates = [("client DCID", client_dcid), ("client SCID", client_scid), ("empty DCID", b"")]
|
||||
for number, src, packet in packets:
|
||||
try:
|
||||
_, dcid, scid, token, length, pn_offset = parse_long_header(packet)
|
||||
except IndexError:
|
||||
continue
|
||||
if src.startswith("192.168."):
|
||||
# The client's own Initial is the control: its keys are the ones the
|
||||
# RFC vector just verified, so if this fails the decryptor is wrong
|
||||
# and nothing can be said about the endpoint's packets.
|
||||
try_open(packet, client_dcid, f"client frame {number} (control)", side=b"client in")
|
||||
continue
|
||||
print(
|
||||
f"frame {number} from {src}: reply dcid={dcid.hex() or '(empty)'} scid={scid.hex()} "
|
||||
f"token={len(token)} length={length} datagram={len(packet)}"
|
||||
)
|
||||
for label, candidate in candidates + [("server SCID", scid)]:
|
||||
if candidate is None:
|
||||
continue
|
||||
if try_open(packet, candidate, label):
|
||||
break
|
||||
else:
|
||||
print(" no candidate key opened it")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "target/validation/quic-compare.pcapng")
|
||||
121
scripts/quic/online_h3.py
Normal file
121
scripts/quic/online_h3.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""What does a site support, independent of our own network?
|
||||
|
||||
The column measures the *path from here*; this asks the other question — does the
|
||||
host serve HTTP/3 at all — from two third-party testers on networks that are not
|
||||
ours, so a disagreement separates "the site has no HTTP/3" from "our path to it
|
||||
does not work". Measured: `gateway.discord.gg` and `hub.docker.com` answer a
|
||||
handshake from the testers' networks and close ours with a TLS `handshake_failure`
|
||||
(296), which is exactly the difference a censored user needs to see.
|
||||
|
||||
Two testers, because one is one implementation:
|
||||
* `intodns.ai/api/web/http3` — JSON: Alt-Svc, the RFC 9460 HTTPS record, and a
|
||||
QUIC probe from their host.
|
||||
* `http3check.net` — LiteSpeed's, which runs the handshake itself and reports
|
||||
whether QUIC and HTTP/3 are supported.
|
||||
|
||||
Writes `target/validation/online-h3.txt` and prints the same table.
|
||||
|
||||
Run: python scripts/quic/online_h3.py [host ...]
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
OUT = ROOT / "target" / "validation" / "online-h3.txt"
|
||||
INTODNS = "https://intodns.ai/api/web/http3?domain={}"
|
||||
HTTP3CHECK = "https://http3check.net/?host={}"
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) dpi-detector-quic-stand"
|
||||
|
||||
|
||||
def fetch(url, timeout=30):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def retrying(url, attempts=4, pause=8.0):
|
||||
"""Third-party APIs rate-limit: back off on 429 rather than reporting a gap."""
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
return fetch(url)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 429 and attempt + 1 < attempts:
|
||||
time.sleep(pause * (attempt + 1))
|
||||
continue
|
||||
return f"ERROR {error.code}"
|
||||
except Exception as error: # noqa: BLE001
|
||||
return f"ERROR {type(error).__name__}"
|
||||
return "ERROR retries exhausted"
|
||||
|
||||
|
||||
def intodns(host):
|
||||
body = retrying(INTODNS.format(host))
|
||||
if body.startswith("ERROR"):
|
||||
return body, "-", "-", "-"
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except ValueError:
|
||||
return "ERROR parse", "-", "-", "-"
|
||||
record = payload.get("httpsRecord") or {}
|
||||
probe = payload.get("quicProbe") or {}
|
||||
methods = payload.get("detectionMethods") or {}
|
||||
probe_state = "ok" if probe.get("success") else ("inconclusive" if probe.get("inconclusive") else "fail")
|
||||
alpn = ",".join(record.get("alpn") or []) or "-"
|
||||
return (
|
||||
"yes" if payload.get("http3Supported") else "no",
|
||||
f"alt-svc={methods.get('altSvc')}",
|
||||
f"alpn={alpn}",
|
||||
f"quic={probe_state}",
|
||||
)
|
||||
|
||||
|
||||
def http3check(host):
|
||||
body = retrying(HTTP3CHECK.format(host))
|
||||
if body.startswith("ERROR"):
|
||||
return body, "-"
|
||||
if "HTTP/3 is supported" in body and "HTTP/3 is not supported" not in body:
|
||||
supported = "yes"
|
||||
elif "HTTP/3 is not supported" in body or "does not advertise any alternative services" in body:
|
||||
# The wording a host without HTTP/3 gets: "HTTP/3 Check could not get the
|
||||
# server's advertised QUIC versions ... Server does not advertise any
|
||||
# alternative services."
|
||||
supported = "no"
|
||||
else:
|
||||
supported = "unknown"
|
||||
quic = "yes" if re.search(r"QUIC is supported", body) else ("no" if "QUIC is not supported" in body else "-")
|
||||
versions = "-"
|
||||
match = re.search(r"QUIC Versions.{0,4000}?h3[-0-9, ]*", body, re.S)
|
||||
if match:
|
||||
versions = " ".join(sorted(set(re.findall(r"\bh3(?:-\d+)?\b", match.group(0))))) or "-"
|
||||
return supported, f"quic={quic} versions={versions}"
|
||||
|
||||
|
||||
def main(hosts):
|
||||
rows = [f"{'host':24} {'intodns':8} {'http3check':10} detail"]
|
||||
for host in hosts:
|
||||
first, alt_svc, alpn, probe = intodns(host)
|
||||
time.sleep(1.0)
|
||||
second, versions = http3check(host)
|
||||
rows.append(f"{host:24} {first:8} {second:10} {alt_svc} {alpn} {probe} | {versions}")
|
||||
print(rows[-1], flush=True)
|
||||
time.sleep(1.0)
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text("\n".join(rows) + "\n", encoding="utf-8")
|
||||
print(f"\nwritten to {OUT.relative_to(ROOT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
listed = sys.argv[1:]
|
||||
if not listed:
|
||||
listed = [
|
||||
line.strip()
|
||||
for line in (pathlib.Path(__file__).with_name("hosts.txt")).read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.startswith("#")
|
||||
]
|
||||
main(listed)
|
||||
239
scripts/quic/replay.py
Normal file
239
scripts/quic/replay.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Replay a captured Initial flight, with and without a retransmission.
|
||||
|
||||
The capture shows the endpoint answering a stock client only after it repeats its
|
||||
Initial (new packet number, same CRYPTO), while the detector - which sends its
|
||||
flight once - gets a packet no key opens and then silence. This replays the
|
||||
detector's own captured hello, first as it was sent, then again as a real client
|
||||
retransmits it, and prints what comes back each time.
|
||||
|
||||
Run: python scripts/quic/replay.py target/validation/quic-discord.pcapng
|
||||
"""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
|
||||
from decrypt import TSHARK, keys_for, parse_long_header, read_varint # noqa: E402
|
||||
|
||||
V1 = 0x00000001
|
||||
PADDING = 1200
|
||||
|
||||
|
||||
def open_client_packet(packet, dcid):
|
||||
"""The CRYPTO frame of one client Initial, with the client's own keys."""
|
||||
key, iv, hp = keys_for(dcid)[b"client in"]
|
||||
_, _, _, _, length, pn_offset = parse_long_header(packet)
|
||||
sample = packet[pn_offset + 4:pn_offset + 20]
|
||||
mask = Cipher(algorithms.AES(hp), modes.ECB()).encryptor().update(sample)
|
||||
first = packet[0] ^ (mask[0] & 0x0F)
|
||||
pn_len = (first & 0x03) + 1
|
||||
pn_bytes = bytes(b ^ m for b, m in zip(packet[pn_offset:pn_offset + pn_len], mask[1:1 + pn_len]))
|
||||
pn = int.from_bytes(pn_bytes, "big")
|
||||
aad = bytes([first]) + packet[1:pn_offset] + pn_bytes
|
||||
payload = packet[pn_offset + pn_len:pn_offset + length]
|
||||
nonce = bytes(a ^ b for a, b in zip(iv, pn.to_bytes(12, "big")))
|
||||
plain = AESGCM(key).decrypt(nonce, payload, aad)
|
||||
at = 0
|
||||
while at < len(plain):
|
||||
if plain[at] == 0x06:
|
||||
offset, at = read_varint(plain, at + 1)
|
||||
chunk, at = read_varint(plain, at)
|
||||
return offset, plain[at:at + chunk]
|
||||
at += 1
|
||||
raise SystemExit("no CRYPTO frame in the captured packet")
|
||||
|
||||
|
||||
def build_initial(dcid, scid, pn, offset, chunk):
|
||||
"""One client Initial carrying `chunk` at `offset`, sealed and protected."""
|
||||
key, iv, hp = keys_for(dcid)[b"client in"]
|
||||
body = bytearray()
|
||||
body.append(0x06)
|
||||
body += varint(offset)
|
||||
body += varint(len(chunk))
|
||||
body += chunk
|
||||
header = bytearray([0xC1]) # long, fixed, Initial, 2-byte packet number (low bits = 1)
|
||||
header += V1.to_bytes(4, "big")
|
||||
header.append(len(dcid))
|
||||
header += dcid
|
||||
header.append(len(scid))
|
||||
header += scid
|
||||
header.append(0) # token length
|
||||
pn_len = 2
|
||||
length = pn_len + len(body) + 16
|
||||
header += varint(length)
|
||||
pn_offset = len(header)
|
||||
header += pn.to_bytes(pn_len, "big")
|
||||
packet = bytes(header) + AESGCM(key).encrypt(
|
||||
bytes(a ^ b for a, b in zip(iv, pn.to_bytes(12, "big"))), bytes(body), bytes(header)
|
||||
)
|
||||
packet = bytearray(packet)
|
||||
mask = Cipher(algorithms.AES(hp), modes.ECB()).encryptor().update(packet[pn_offset + 4:pn_offset + 20])
|
||||
packet[0] ^= mask[0] & 0x0F
|
||||
for i in range(pn_len):
|
||||
packet[pn_offset + i] ^= mask[1 + i]
|
||||
if len(packet) < PADDING:
|
||||
packet += b"\x00" * (PADDING - len(packet))
|
||||
return bytes(packet)
|
||||
|
||||
|
||||
def varint(value):
|
||||
if value < 0x40:
|
||||
return bytes([value])
|
||||
if value < 0x4000:
|
||||
return (value | 0x4000).to_bytes(2, "big")
|
||||
return (value | 0x80000000).to_bytes(4, "big")
|
||||
|
||||
|
||||
def captured_flight(path):
|
||||
out = subprocess.run(
|
||||
[TSHARK, "-r", path, "-Y", "quic", "-T", "fields", "-e", "frame.number", "-e", "ip.src", "-e", "udp.payload"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
client_packets = []
|
||||
server_ip = None
|
||||
for line in out.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
_, src, payload = line.split("\t")
|
||||
packet = bytes.fromhex(payload)
|
||||
if len(packet) < 20 or packet[0] & 0x80 == 0:
|
||||
continue
|
||||
_, dcid, scid, _, _, _ = parse_long_header(packet)
|
||||
if src.startswith("192.168."):
|
||||
client_packets.append((packet, dcid, scid))
|
||||
else:
|
||||
server_ip = src
|
||||
packet, dcid, scid = client_packets[0]
|
||||
chunks = [open_client_packet(p, dcid) for p, _, _ in client_packets]
|
||||
return dcid, scid, server_ip, chunks
|
||||
|
||||
|
||||
def describe_reply(packet, dcid):
|
||||
if len(packet) < 20:
|
||||
return f"{len(packet)} bytes (too short)"
|
||||
if packet[0] & 0x80 == 0:
|
||||
return f"short header, {len(packet)} bytes (1-RTT or a stateless reset)"
|
||||
_, pdcid, scid, token, length, pn_offset = parse_long_header(packet)
|
||||
shape = f"Initial dcid={pdcid.hex() or '(empty)'} scid={scid.hex()} length={length}"
|
||||
key, iv, hp = keys_for(dcid)[b"server in"]
|
||||
sample = packet[pn_offset + 4:pn_offset + 20]
|
||||
if len(sample) < 16:
|
||||
return shape + " (no room for a sample)"
|
||||
mask = Cipher(algorithms.AES(hp), modes.ECB()).encryptor().update(sample)
|
||||
first = packet[0] ^ (mask[0] & 0x0F)
|
||||
pn_len = (first & 0x03) + 1
|
||||
pn_bytes = bytes(b ^ m for b, m in zip(packet[pn_offset:pn_offset + pn_len], mask[1:1 + pn_len]))
|
||||
aad = bytes([first]) + packet[1:pn_offset] + pn_bytes
|
||||
payload = packet[pn_offset + pn_len:pn_offset + length]
|
||||
nonce = bytes(a ^ b for a, b in zip(iv, int.from_bytes(pn_bytes, "big").to_bytes(12, "big")))
|
||||
try:
|
||||
plain = AESGCM(key).decrypt(nonce, payload, aad)
|
||||
except Exception: # noqa: BLE001
|
||||
return shape + " -> DOES NOT OPEN with the connection's keys"
|
||||
kind = "CRYPTO" if plain[:1] == b"\x06" else ("ACK" if plain[:1] == b"\x02" else f"frame {plain[0]:#04x}")
|
||||
handshake = f" handshake_type={plain[5]:#04x}" if plain[:1] == b"\x06" else ""
|
||||
return shape + f" -> OPENED ({kind}{handshake})"
|
||||
|
||||
|
||||
def sweep(path, host, delays):
|
||||
"""Which delay between the flight and its repeat makes the endpoint answer?"""
|
||||
import os
|
||||
import socket as socket_module
|
||||
import time as time_module
|
||||
|
||||
_, scid, _, chunks = captured_flight(path)
|
||||
server_ip = socket_module.getaddrinfo(host, 443, socket_module.AF_INET, socket_module.SOCK_DGRAM)[0][4][0]
|
||||
for delay in delays:
|
||||
dcid = os.urandom(8)
|
||||
sock = socket_module.socket(socket_module.AF_INET, socket_module.SOCK_DGRAM)
|
||||
sock.connect((server_ip, 443))
|
||||
sock.settimeout(0.5)
|
||||
for pn, (offset, chunk) in enumerate(chunks):
|
||||
sock.send(build_initial(dcid, scid, pn, offset, chunk))
|
||||
replies = []
|
||||
end = time_module.time() + delay
|
||||
while time_module.time() < end:
|
||||
try:
|
||||
replies.append(("first flight", sock.recvfrom(4096)[0]))
|
||||
except socket_module.timeout:
|
||||
continue
|
||||
for i, (offset, chunk) in enumerate(chunks):
|
||||
sock.send(build_initial(dcid, scid, len(chunks) + i, offset, chunk))
|
||||
end = time_module.time() + 3.0
|
||||
while time_module.time() < end:
|
||||
try:
|
||||
replies.append(("retransmit", sock.recvfrom(4096)[0]))
|
||||
except socket_module.timeout:
|
||||
continue
|
||||
sock.close()
|
||||
opened = [f"{stage}: {describe_reply(data, dcid)}" for stage, data in replies]
|
||||
readable = [line for line in opened if "OPENED" in line]
|
||||
print(f" delay {delay:4.1f}s: {len(replies)} replies, readable: {readable if readable else 'none'}")
|
||||
for line in opened:
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
def main(path, host):
|
||||
dcid, scid, server_ip, chunks = captured_flight(path)
|
||||
# A fresh connection and a fresh address: the captured DCID belongs to a
|
||||
# handshake that ended minutes ago (an endpoint has no state for it), and an
|
||||
# anycast node the capture saw is not necessarily the one that answers now.
|
||||
import os
|
||||
import socket as socket_module
|
||||
|
||||
dcid = os.urandom(8)
|
||||
server_ip = socket_module.getaddrinfo(host, 443, socket_module.AF_INET, socket_module.SOCK_DGRAM)[0][4][0]
|
||||
print(f"replaying the captured hello on a fresh connection: host={host} dcid={dcid.hex()} scid={scid.hex()} server={server_ip}")
|
||||
print(f" chunks={[(o, len(c)) for o, c in chunks]}")
|
||||
|
||||
# Self-check: the rebuilt packet must decrypt back to the same CRYPTO frame.
|
||||
rebuilt = build_initial(dcid, scid, 0, *chunks[0])
|
||||
offset, chunk = open_client_packet(rebuilt, dcid)
|
||||
print(f" self-check: rebuilt packet decrypts to CRYPTO offset={offset} len={len(chunk)} "
|
||||
f"({'matches' if chunk == chunks[0][1] else 'MISMATCH'})")
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.connect((server_ip, 443))
|
||||
sock.settimeout(1.0)
|
||||
|
||||
def drain(seconds, label):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
try:
|
||||
data, _ = sock.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
print(f" [{label}] <- {describe_reply(data, dcid)}")
|
||||
|
||||
print("first send (what the detector does: the flight once)")
|
||||
for pn, (offset, chunk) in enumerate(chunks):
|
||||
sock.send(build_initial(dcid, scid, pn, offset, chunk))
|
||||
drain(4.0, "once")
|
||||
|
||||
print("retransmission (what a real client does on PTO: same CRYPTO, new numbers)")
|
||||
base = len(chunks)
|
||||
for round_number in range(2):
|
||||
for i, (offset, chunk) in enumerate(chunks):
|
||||
sock.send(build_initial(dcid, scid, base + round_number * len(chunks) + i, offset, chunk))
|
||||
drain(1.0, f"retransmit {round_number + 1}")
|
||||
drain(4.0, "after retransmissions")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--sweep":
|
||||
sweep(
|
||||
sys.argv[2] if len(sys.argv) > 2 else "target/validation/quic-discord.pcapng",
|
||||
sys.argv[3] if len(sys.argv) > 3 else "discord.com",
|
||||
[0.4, 1.0, 2.0, 4.0],
|
||||
)
|
||||
else:
|
||||
main(
|
||||
sys.argv[1] if len(sys.argv) > 1 else "target/validation/quic-discord.pcapng",
|
||||
sys.argv[2] if len(sys.argv) > 2 else "discord.com",
|
||||
)
|
||||
248
scripts/quic/tp_dump.py
Normal file
248
scripts/quic/tp_dump.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Dump the transport parameters a client's QUIC ClientHello carries.
|
||||
|
||||
The probe's hello is legal but unlike a browser's in a way that matters: RFC 9114
|
||||
§6.2.1 requires an H3 client to let the server open its unidirectional streams
|
||||
(`initial_max_streams_uni` of at least 3), and a client that sends no parameters
|
||||
at all leaves every limit at the RFC's zero default - measured, Cloudflare closes
|
||||
such a connection with `Error opening control stream`. This reads the real set out
|
||||
of a capture, so a profile's numbers can come from a client that works.
|
||||
|
||||
Run: python scripts/quic/tp_dump.py target/validation/chrome-quic.pcapng
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from decrypt import TSHARK, keys_for, parse_long_header, read_varint # noqa: E402
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes # noqa: E402
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # noqa: E402
|
||||
|
||||
VERBOSE = "--extensions" in sys.argv
|
||||
|
||||
NAMES = {
|
||||
0x00: "original_destination_connection_id",
|
||||
0x01: "max_idle_timeout",
|
||||
0x02: "stateless_reset_token",
|
||||
0x03: "max_udp_payload_size",
|
||||
0x04: "initial_max_data",
|
||||
0x05: "initial_max_stream_data_bidi_local",
|
||||
0x06: "initial_max_stream_data_bidi_remote",
|
||||
0x07: "initial_max_stream_data_uni",
|
||||
0x08: "initial_max_streams_bidi",
|
||||
0x09: "initial_max_streams_uni",
|
||||
0x0A: "ack_delay_exponent",
|
||||
0x0B: "max_ack_delay",
|
||||
0x0C: "disable_active_migration",
|
||||
0x0D: "preferred_address",
|
||||
0x0E: "active_connection_id_limit",
|
||||
0x0F: "initial_source_connection_id",
|
||||
0x10: "retry_source_connection_id",
|
||||
}
|
||||
|
||||
|
||||
def open_client(packet, dcid):
|
||||
key, iv, hp = keys_for(dcid)[b"client in"]
|
||||
_, _, _, _, length, pn_offset = parse_long_header(packet)
|
||||
sample = packet[pn_offset + 4:pn_offset + 20]
|
||||
mask = Cipher(algorithms.AES(hp), modes.ECB()).encryptor().update(sample)
|
||||
first = packet[0] ^ (mask[0] & 0x0F)
|
||||
pn_len = (first & 0x03) + 1
|
||||
pn_bytes = bytes(b ^ m for b, m in zip(packet[pn_offset:pn_offset + pn_len], mask[1:1 + pn_len]))
|
||||
aad = bytes([first]) + packet[1:pn_offset] + pn_bytes
|
||||
nonce = bytes(a ^ b for a, b in zip(iv, int.from_bytes(pn_bytes, "big").to_bytes(12, "big")))
|
||||
return AESGCM(key).decrypt(nonce, packet[pn_offset + pn_len:pn_offset + length], aad)
|
||||
|
||||
|
||||
def crypto_of(plain):
|
||||
"""Every CRYPTO frame of one packet, as (offset, data).
|
||||
|
||||
A browser's Initial interleaves PING and PADDING frames with the CRYPTO
|
||||
chunks, so the walk has to know each frame's length instead of stopping at
|
||||
the first type it does not expect (RFC 9000 §19).
|
||||
"""
|
||||
at = 0
|
||||
out = []
|
||||
while at < len(plain):
|
||||
kind = plain[at]
|
||||
if kind == 0x00: # PADDING
|
||||
at += 1
|
||||
continue
|
||||
if kind == 0x01: # PING
|
||||
at += 1
|
||||
continue
|
||||
if kind in (0x02, 0x03): # ACK, ACK+ECN
|
||||
at += 1
|
||||
_, at = read_varint(plain, at) # largest acknowledged
|
||||
_, at = read_varint(plain, at) # ack delay
|
||||
ranges, at = read_varint(plain, at)
|
||||
_, at = read_varint(plain, at) # first range
|
||||
for _ in range(ranges):
|
||||
_, at = read_varint(plain, at)
|
||||
_, at = read_varint(plain, at)
|
||||
if kind == 0x03:
|
||||
_, at = read_varint(plain, at)
|
||||
_, at = read_varint(plain, at)
|
||||
_, at = read_varint(plain, at)
|
||||
continue
|
||||
if kind == 0x06: # CRYPTO
|
||||
offset, at = read_varint(plain, at + 1)
|
||||
chunk, at = read_varint(plain, at)
|
||||
out.append((offset, plain[at:at + chunk]))
|
||||
at += chunk
|
||||
continue
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def assemble(chunks):
|
||||
"""The stream from offset 0 to the first gap, the way a server reads it."""
|
||||
out = b""
|
||||
at = 0
|
||||
for offset, data in sorted(chunks):
|
||||
if offset > at:
|
||||
break
|
||||
skip = at - offset
|
||||
if skip < len(data):
|
||||
out += data[skip:]
|
||||
at += len(data) - skip
|
||||
return out
|
||||
|
||||
|
||||
def extensions(hello):
|
||||
"""The extension list of a ClientHello, as (type, body) pairs."""
|
||||
if len(hello) < 40 or hello[0] != 0x01:
|
||||
return []
|
||||
at = 4 + 2 + 32
|
||||
session_id_len = hello[at]
|
||||
at += 1 + session_id_len
|
||||
cipher_len = int.from_bytes(hello[at:at + 2], "big")
|
||||
at += 2 + cipher_len
|
||||
compression_len = hello[at]
|
||||
at += 1 + compression_len
|
||||
total = int.from_bytes(hello[at:at + 2], "big")
|
||||
at += 2
|
||||
end = at + total
|
||||
out = []
|
||||
while at + 4 <= end:
|
||||
kind = int.from_bytes(hello[at:at + 2], "big")
|
||||
ln = int.from_bytes(hello[at + 2:at + 4], "big")
|
||||
out.append((kind, hello[at + 4:at + 4 + ln]))
|
||||
at += 4 + ln
|
||||
return out
|
||||
|
||||
|
||||
def transport_parameters(body):
|
||||
"""(id, shown value) for each parameter, values decoded as RFC 9000 varints."""
|
||||
at = 0
|
||||
out = []
|
||||
while at < len(body):
|
||||
ident, at = read_varint(body, at)
|
||||
ln, at = read_varint(body, at)
|
||||
value = body[at:at + ln]
|
||||
at += ln
|
||||
if ident in (0x00, 0x0F, 0x10, 0x02):
|
||||
shown = value.hex() or "(empty)"
|
||||
elif value:
|
||||
number, consumed = read_varint(value, 0)
|
||||
# A value the walk cannot consume whole is not a varint at all: show
|
||||
# the bytes rather than a number invented from a prefix.
|
||||
shown = str(number) if consumed == len(value) else value.hex()
|
||||
else:
|
||||
shown = "(empty)"
|
||||
out.append((ident, shown))
|
||||
return out
|
||||
|
||||
|
||||
def hello_fingerprint(hello):
|
||||
"""Cipher suites, compression, and the extension list in wire order."""
|
||||
if len(hello) < 40:
|
||||
return None
|
||||
at = 4 + 2 + 32
|
||||
session_id_len = hello[at]
|
||||
at += 1 + session_id_len
|
||||
cipher_len = int.from_bytes(hello[at:at + 2], "big")
|
||||
ciphers = [int.from_bytes(hello[at + 2 + i:at + 4 + i], "big") for i in range(0, cipher_len, 2)]
|
||||
at += 2 + cipher_len
|
||||
compression_len = hello[at]
|
||||
compression = list(hello[at + 1:at + 1 + compression_len])
|
||||
at += 1 + compression_len
|
||||
total = int.from_bytes(hello[at:at + 2], "big")
|
||||
at += 2
|
||||
end = at + total
|
||||
exts = []
|
||||
while at + 4 <= end:
|
||||
kind = int.from_bytes(hello[at:at + 2], "big")
|
||||
ln = int.from_bytes(hello[at + 2:at + 4], "big")
|
||||
body = hello[at + 4:at + 4 + ln]
|
||||
exts.append((kind, ln, body))
|
||||
at += 4 + ln
|
||||
return {"ciphers": ciphers, "compression": compression, "extensions": exts, "length": len(hello)}
|
||||
|
||||
|
||||
def print_fingerprint(hello):
|
||||
fp = hello_fingerprint(hello)
|
||||
if fp is None:
|
||||
return
|
||||
print(f" length {fp['length']} ciphers {[hex(c) for c in fp['ciphers']]} compression {fp['compression']}")
|
||||
print(" order: " + " ".join(f"{kind:#06x}({ln})" for kind, ln, _ in fp["extensions"]))
|
||||
for kind, ln, body in fp["extensions"]:
|
||||
if kind in (0x000A, 0x000D, 0x0033, 0x002B, 0x0010, 0x002D, 0x44CD, 0xFE0D, 0xCA34, 0x0039):
|
||||
shown = body.hex()
|
||||
print(f" {kind:#06x} len={ln:4} {shown[:120]}{'...' if len(shown) > 120 else ''}")
|
||||
|
||||
|
||||
def main(path):
|
||||
out = subprocess.run(
|
||||
[TSHARK, "-r", path, "-Y", "quic", "-T", "fields", "-e", "frame.number", "-e", "ip.src", "-e", "udp.payload"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
streams = {}
|
||||
sources = {}
|
||||
for line in out.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
number, src, payload = line.split("\t")
|
||||
packet = bytes.fromhex(payload)
|
||||
if len(packet) < 40 or packet[0] & 0x80 == 0:
|
||||
continue
|
||||
try:
|
||||
_, dcid, scid, _, _, _ = parse_long_header(packet)
|
||||
except IndexError:
|
||||
continue
|
||||
try:
|
||||
plain = open_client(packet, dcid)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
streams.setdefault(dcid, []).extend(crypto_of(plain))
|
||||
sources.setdefault(dcid, (number, src))
|
||||
|
||||
for dcid, chunks in streams.items():
|
||||
hello = assemble(chunks)
|
||||
if len(hello) < 8 or hello[0] != 0x01:
|
||||
continue
|
||||
number, src = sources[dcid]
|
||||
print(f"connection DCID={dcid.hex()} (first packet seen: frame {number} from {src}): ClientHello {len(hello)} bytes")
|
||||
if VERBOSE:
|
||||
print_fingerprint(hello)
|
||||
for kind, body in extensions(hello):
|
||||
if kind == 0x39:
|
||||
print(" quic_transport_parameters:")
|
||||
for ident, shown in transport_parameters(body):
|
||||
print(f" {NAMES.get(ident, hex(ident)):38} = {shown}")
|
||||
elif kind == 0x10:
|
||||
print(f" ALPN: {body.hex()}")
|
||||
elif kind == 0x00:
|
||||
print(f" SNI: {body[5:].decode('latin-1', 'replace')}")
|
||||
if VERBOSE:
|
||||
shown = body.hex()
|
||||
print(f" ext {kind:#06x} len={len(body):4} {shown[:64]}{'...' if len(shown) > 64 else ''}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
paths = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
main(paths[0] if paths else "target/validation/chrome-quic.pcapng")
|
||||
206
scripts/quic/validate.py
Normal file
206
scripts/quic/validate.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Validate dpi-detector's QUIC column against an independent implementation.
|
||||
|
||||
aioquic (a pure-Python RFC 9000/9001/9114 stack) dials the same address the
|
||||
detector's probe used - the host's A record, not the Alt-Svc authority - and
|
||||
reports the exact outcome: an HTTP/3 status, the transport error code with the
|
||||
TLS alert it carries, a timeout, or a refusal. `wait_connected=False` keeps the
|
||||
context manager from swallowing a failed handshake as a bare ConnectionError.
|
||||
The Alt-Svc header, read over TCP, says whether the site advertises h3 at all.
|
||||
|
||||
Run: python target/quic-validate.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import ssl
|
||||
|
||||
from aioquic.asyncio.client import connect
|
||||
from aioquic.asyncio.protocol import QuicConnectionProtocol
|
||||
from aioquic.h3.connection import H3_ALPN, H3Connection
|
||||
from aioquic.h3.events import HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ConnectionTerminated, HandshakeCompleted
|
||||
|
||||
HOSTS = [
|
||||
("amnezia.org", "QUIC OK", ""),
|
||||
("aws.amazon.com", "QUIC CLOSED", "296"),
|
||||
("browserleaks.com", "QUIC DROP", ""),
|
||||
("danbooru.donmai.us", "QUIC DROP", ""),
|
||||
("discord.com", "QUIC DROP", ""),
|
||||
("gateway.discord.gg", "QUIC DROP", ""),
|
||||
("holod.media", "QUIC DROP", ""),
|
||||
("hub.docker.com", "QUIC DROP", ""),
|
||||
("media.discordapp.net", "QUIC DROP", ""),
|
||||
("meduza.io", "QUIC DROP", ""),
|
||||
("nnmclub.to", "QUIC DROP", ""),
|
||||
("protonvpn.com", "QUIC DROP", ""),
|
||||
("shikimori.io", "QUIC DROP", ""),
|
||||
("soundcloud.com", "QUIC CLOSED", "296"),
|
||||
("vk.ru", "REFUSED", "icmp"),
|
||||
("www.apkmirror.com", "QUIC CLOSED", "no crypto"),
|
||||
("www.canva.com", "QUIC CLOSED", "no crypto"),
|
||||
("www.cdn77.com", "QUIC CLOSED", "368"),
|
||||
("www.coursera.org", "QUIC CLOSED", "296"),
|
||||
("www.currenttime.tv", "QUIC CLOSED", "336"),
|
||||
("www.dw.com", "QUIC OK", ""),
|
||||
("www.euronews.com", "QUIC OK", ""),
|
||||
("www.facebook.com", "QUIC CLOSED", "reset"),
|
||||
("www.google.com", "QUIC CLOSED", "10"),
|
||||
("www.instagram.com", "QUIC CLOSED", "reset"),
|
||||
("www.intel.com", "QUIC OK", ""),
|
||||
("www.linkedin.com", "QUIC DROP", ""),
|
||||
("www.linuxserver.io", "QUIC DROP", ""),
|
||||
("www.messenger.com", "QUIC CLOSED", "reset"),
|
||||
("www.svoboda.org", "QUIC CLOSED", "336"),
|
||||
("www.themoscowtimes.com", "QUIC DROP", ""),
|
||||
("www.torproject.org", "QUIC DROP", ""),
|
||||
("www.youtube.com", "QUIC CLOSED", "10"),
|
||||
("x.com", "QUIC DROP", ""),
|
||||
]
|
||||
|
||||
TIMEOUT = 8.0 # the probe's own window (`QUIC_TIMEOUT`); see bracket.py's note
|
||||
SEM = asyncio.Semaphore(8)
|
||||
|
||||
|
||||
class H3Probe(QuicConnectionProtocol):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.http = H3Connection(self._quic)
|
||||
self.handshook = False
|
||||
self.status = None
|
||||
self.terminated = None
|
||||
self.reset = False
|
||||
self.done = asyncio.Event()
|
||||
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, HandshakeCompleted):
|
||||
self.handshook = True
|
||||
if isinstance(event, ConnectionTerminated):
|
||||
self.terminated = (event.error_code, event.frame_type, event.reason_phrase)
|
||||
self.done.set()
|
||||
for http_event in self.http.handle_event(event):
|
||||
if isinstance(http_event, HeadersReceived):
|
||||
self.status = dict(http_event.headers).get(b":status")
|
||||
self.done.set()
|
||||
|
||||
|
||||
async def probe(host, ip):
|
||||
cfg = QuicConfiguration(
|
||||
is_client=True,
|
||||
alpn_protocols=H3_ALPN,
|
||||
verify_mode=ssl.CERT_NONE,
|
||||
server_name=host,
|
||||
idle_timeout=TIMEOUT,
|
||||
)
|
||||
try:
|
||||
async with connect(
|
||||
ip, 443, configuration=cfg, create_protocol=H3Probe, wait_connected=False
|
||||
) as client:
|
||||
stream_id = client._quic.get_next_available_stream_id()
|
||||
client.http.send_headers(
|
||||
stream_id,
|
||||
[
|
||||
(b":method", b"GET"),
|
||||
(b":scheme", b"https"),
|
||||
(b":authority", host.encode()),
|
||||
(b":path", b"/"),
|
||||
(b"user-agent", b"quic-validate"),
|
||||
],
|
||||
end_stream=True,
|
||||
)
|
||||
client.transmit()
|
||||
try:
|
||||
await asyncio.wait_for(client.done.wait(), timeout=TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
if client.status is not None:
|
||||
return f"HTTP/3 {client.status.decode()}"
|
||||
if client.handshook:
|
||||
return "handshake ok, then no response in the window"
|
||||
return "timeout: no reply to our Initial"
|
||||
if client.status is not None:
|
||||
return f"HTTP/3 {client.status.decode()} (handshake completed)"
|
||||
code, frame, reason = client.terminated
|
||||
if reason and "idle timeout" in reason.lower():
|
||||
return "timeout: no reply to our Initial"
|
||||
if code is None:
|
||||
return "closed without a code"
|
||||
if 0x100 <= code <= 0x1FF:
|
||||
return f"closed {code} = CRYPTO_ERROR, TLS alert {code - 0x100}"
|
||||
return f"closed, transport error {code}"
|
||||
except ConnectionRefusedError:
|
||||
return "refused (ICMP port unreachable)"
|
||||
except Exception as error: # noqa: BLE001 - aioquic raises its own types
|
||||
return f"{type(error).__name__}: {error}"
|
||||
|
||||
|
||||
def alt_svc(host):
|
||||
ctx = ssl.create_default_context()
|
||||
try:
|
||||
with socket.create_connection((host, 443), timeout=6) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=host) as tls:
|
||||
request = f"HEAD / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: quic-validate\r\nConnection: close\r\n\r\n"
|
||||
tls.sendall(request.encode())
|
||||
data = b""
|
||||
while b"\r\n\r\n" not in data and len(data) < 65536:
|
||||
chunk = tls.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except Exception as error: # noqa: BLE001 - the point is to report anything
|
||||
return f"- ({type(error).__name__})"
|
||||
for line in data.split(b"\r\n"):
|
||||
if line.lower().startswith(b"alt-svc:"):
|
||||
return line.split(b":", 1)[1].strip().decode("latin-1")
|
||||
return "absent"
|
||||
|
||||
|
||||
def resolve(host):
|
||||
try:
|
||||
return socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_DGRAM)[0][4][0]
|
||||
except OSError as error:
|
||||
return f"!{error}"
|
||||
|
||||
|
||||
async def one(host, mine, detail, results):
|
||||
async with SEM:
|
||||
ip = resolve(host)
|
||||
if ip.startswith("!"):
|
||||
results[host] = (mine, detail, ip, ip, "-")
|
||||
return
|
||||
theirs = await probe(host, ip)
|
||||
svc = await asyncio.to_thread(alt_svc, host)
|
||||
results[host] = (mine, detail, ip, theirs, svc)
|
||||
|
||||
|
||||
def verdict(mine, theirs, svc):
|
||||
"""Does the independent client confirm the detector's row?"""
|
||||
if theirs.startswith("HTTP/3"):
|
||||
return "NO" if mine != "QUIC OK" else "yes"
|
||||
if theirs.startswith("timeout"):
|
||||
return "yes" if mine in ("QUIC DROP", "REFUSED") else "NO"
|
||||
if theirs.startswith("refused"):
|
||||
return "yes" if mine == "REFUSED" else "NO"
|
||||
if theirs.startswith("closed"):
|
||||
return "yes" if mine == "QUIC CLOSED" else "NO"
|
||||
return "?"
|
||||
|
||||
|
||||
async def main():
|
||||
results = {}
|
||||
await asyncio.gather(*(one(h, m, d, results) for h, m, d in HOSTS))
|
||||
rows = [(h, *results[h]) for h, _, _ in HOSTS]
|
||||
print(f"{'host':23} {'detector':14} {'aioquic (independent)':44} {'alt-svc':14} agree")
|
||||
print("-" * 120)
|
||||
for host, mine, detail, ip, theirs, svc in sorted(rows, key=lambda r: (r[1], r[0])):
|
||||
tag = f"{mine}" + (f"({detail})" if detail else "")
|
||||
ok = verdict(mine, theirs, svc)
|
||||
print(f"{host:23} {tag:14} {theirs:44} {svc.split(';')[0][:14]:14} {ok}")
|
||||
agree = sum(1 for r in rows if verdict(r[1], r[4], r[5]) == "yes")
|
||||
print(f"\nсогласие: {agree}/{len(rows)}; расхождений: {sum(1 for r in rows if verdict(r[1], r[4], r[5]) == 'NO')}")
|
||||
print("\nA-записи (по ним шли обе стороны):")
|
||||
for host, _, _, ip, _, _ in sorted(rows):
|
||||
print(f" {host:23} {ip}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
88
scripts/quic/variants.py
Normal file
88
scripts/quic/variants.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Which property of the hello does an endpoint object to?
|
||||
|
||||
The detector's QUIC hello is legal (a strict server accepts it) but unlike a
|
||||
browser's in ways a browser never is: its transport parameters carry only
|
||||
`initial_source_connection_id`, so every flow-control limit stays at the RFC's
|
||||
zero default. This runs a stock client with one such property at a time and
|
||||
reports what the endpoint does — the experiment that says whether the remaining
|
||||
`QUIC CLOSED` verdicts are about the hello or about the path.
|
||||
|
||||
Run: python scripts/quic/variants.py www.google.com cloudflare.com
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
from aioquic.asyncio.client import connect
|
||||
from aioquic.asyncio.protocol import QuicConnectionProtocol
|
||||
from aioquic.h3.connection import H3_ALPN, H3Connection
|
||||
from aioquic.h3.events import HeadersReceived
|
||||
from aioquic.quic.configuration import QuicConfiguration
|
||||
from aioquic.quic.events import ConnectionTerminated, HandshakeCompleted
|
||||
|
||||
TIMEOUT = 8.0
|
||||
|
||||
|
||||
class H3Probe(QuicConnectionProtocol):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.http = H3Connection(self._quic)
|
||||
self.status = None
|
||||
self.terminated = None
|
||||
self.handshook = False
|
||||
self.done = asyncio.Event()
|
||||
|
||||
def quic_event_received(self, event):
|
||||
if isinstance(event, HandshakeCompleted):
|
||||
self.handshook = True
|
||||
if isinstance(event, ConnectionTerminated):
|
||||
self.terminated = (event.error_code, event.reason_phrase)
|
||||
self.done.set()
|
||||
for http_event in self.http.handle_event(event):
|
||||
if isinstance(http_event, HeadersReceived):
|
||||
self.status = dict(http_event.headers).get(b":status")
|
||||
self.done.set()
|
||||
|
||||
|
||||
async def attempt(host, label, **config):
|
||||
cfg = QuicConfiguration(
|
||||
is_client=True,
|
||||
alpn_protocols=H3_ALPN,
|
||||
verify_mode=ssl.CERT_NONE,
|
||||
server_name=host,
|
||||
idle_timeout=TIMEOUT,
|
||||
**config,
|
||||
)
|
||||
try:
|
||||
async with connect(host, 443, configuration=cfg, create_protocol=H3Probe, wait_connected=False) as client:
|
||||
stream_id = client._quic.get_next_available_stream_id()
|
||||
client.http.send_headers(
|
||||
stream_id,
|
||||
[(b":method", b"GET"), (b":scheme", b"https"), (b":authority", host.encode()), (b":path", b"/")],
|
||||
end_stream=True,
|
||||
)
|
||||
client.transmit()
|
||||
try:
|
||||
await asyncio.wait_for(client.done.wait(), timeout=TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
return f"{label:26} no answer"
|
||||
if client.status is not None:
|
||||
return f"{label:26} HTTP/3 {client.status.decode()}"
|
||||
code, reason = client.terminated
|
||||
alert = f" (TLS alert {code - 0x100})" if 0x100 <= code <= 0x1FF else ""
|
||||
return f"{label:26} closed {code}{alert} {reason or ''}"
|
||||
except Exception as error: # noqa: BLE001
|
||||
return f"{label:26} {type(error).__name__}: {error}"
|
||||
|
||||
|
||||
async def main(hosts):
|
||||
for host in hosts:
|
||||
print(f"--- {host}")
|
||||
print(await attempt(host, "stock"))
|
||||
print(await attempt(host, "zeroed flow control", max_data=0, max_stream_data=0))
|
||||
print(await attempt(host, "1200-byte datagrams", max_datagram_size=1200))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(sys.argv[1:] or ["www.google.com"]))
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
// that prints what crossed it.
|
||||
//
|
||||
// lab [--port 8443] [--tap-port 443] [--keylog keys.log] [--upstream host:port]
|
||||
// lab --quic-tap-port 443 --quic-upstream host:port
|
||||
//
|
||||
// The burst resolves its target itself and dials a fixed 443, so a stand that
|
||||
// wants to see the handshake has to own that port: the tap accepts there, relays
|
||||
|
|
@ -119,6 +120,10 @@ func main() {
|
|||
tapPort := fs.Int("tap-port", 443, "port the tap listens on and relays to the server")
|
||||
upstream := fs.String("upstream", "",
|
||||
"relay to this host:port instead of the local server (e.g. www.google.com:443); the local server is then not started")
|
||||
quicTapPort := fs.Int("quic-tap-port", 0,
|
||||
"port the UDP (QUIC) tap listens on, relaying to --quic-upstream; 0 = no QUIC tap")
|
||||
quicUpstream := fs.String("quic-upstream", "",
|
||||
"the QUIC tap relays to this host:port (e.g. discord.com:443); required with --quic-tap-port")
|
||||
fs.Usage = usage
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
return
|
||||
|
|
@ -129,6 +134,17 @@ func main() {
|
|||
}
|
||||
out.w = os.Stdout
|
||||
|
||||
// The QUIC tap is a mode of its own: it has no local server (a QUIC endpoint
|
||||
// would need a handshake the probe never finishes), it takes a UDP port, and
|
||||
// it neither needs nor starts the TCP tap below.
|
||||
if *quicTapPort != 0 {
|
||||
if *quicUpstream == "" {
|
||||
fatal(fmt.Errorf("--quic-tap-port needs --quic-upstream host:port"))
|
||||
}
|
||||
runQuicTap(*quicTapPort, *quicUpstream)
|
||||
return
|
||||
}
|
||||
|
||||
// The local server is the default: the tap relays to it and the stand is
|
||||
// self-contained. `--upstream` makes the stand a pure tap in front of a real
|
||||
// host — the client's own bytes and SNI, the real server's answer — and then
|
||||
|
|
@ -282,6 +298,21 @@ Both streams share stdout, tagged:
|
|||
A refused profile ends with a [server] line naming the reason and a request
|
||||
census of zero; if no such line appears, the stand is more tolerant than the
|
||||
remote server and that is the finding.
|
||||
|
||||
The QUIC probe dials UDP 443 and never finishes the handshake, so it gets a tap
|
||||
of its own rather than the server above:
|
||||
|
||||
lab --quic-tap-port 443 --quic-upstream discord.com:443
|
||||
|
||||
That tap relays every datagram to the named host and back, printing its size,
|
||||
its direction and the fields that are in the clear: the header form, the version,
|
||||
the connection ID lengths, and the first bytes. A reply line is what settles the
|
||||
question the remote server cannot - a QUIC DROP next to a stock client's HTTP/3
|
||||
answer means either the endpoint sent nothing or the probe did not read what it
|
||||
sent, and a short-header reply, a Retry, a version-negotiation packet or silence
|
||||
are told apart here. It listens on 127.0.0.1 (IPv4) and always relays: there is
|
||||
no local QUIC server, because an endpoint would need the handshake the probe
|
||||
deliberately stops short of.
|
||||
`)
|
||||
}
|
||||
|
||||
|
|
|
|||
152
tools/fingerprint/utls/lab/quic_tap.go
Normal file
152
tools/fingerprint/utls/lab/quic_tap.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// The QUIC half of the stand: a UDP tap on the port the probe dials, relaying to
|
||||
// a real host and printing every datagram in both directions.
|
||||
//
|
||||
// lab --quic-tap-port 443 --quic-upstream discord.com:443
|
||||
//
|
||||
// The QUIC probe resolves its target and dials a fixed 443, so a stand that wants
|
||||
// to see the Initial has to own that port - and unlike the TCP tap there is no
|
||||
// local server to fall back on: a QUIC endpoint needs the handshake the probe
|
||||
// never finishes, so the tap always relays to `--quic-upstream`.
|
||||
//
|
||||
// What this answers is the question a remote server cannot: the probe reports
|
||||
// silence (`QUIC DROP`) while a stock client gets an HTTP/3 answer from the same
|
||||
// address. Either the endpoint sent nothing, or it answered and the probe did not
|
||||
// read it - and the difference decides whether the fault is in the ClientHello or
|
||||
// in the receive path. Every datagram is therefore logged with its size, its
|
||||
// direction and its readable header: a long header names the version and the
|
||||
// packet type, a short header on the reply side where the client has no keys yet
|
||||
// is a stateless reset, and a version 0 packet is version negotiation.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// quicTap relays one client's datagrams to the upstream and back. The probe
|
||||
// sends a flight and waits, so the first datagram from a new client address
|
||||
// opens a connected socket that stays open for the replies.
|
||||
type quicTap struct {
|
||||
upstream string
|
||||
mu sync.Mutex
|
||||
clients map[string]*net.UDPConn
|
||||
}
|
||||
|
||||
func newQuicTap(upstream string) *quicTap {
|
||||
return &quicTap{upstream: upstream, clients: map[string]*net.UDPConn{}}
|
||||
}
|
||||
|
||||
func (t *quicTap) socketFor(client net.Addr) (*net.UDPConn, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if conn, ok := t.clients[client.String()]; ok {
|
||||
return conn, nil
|
||||
}
|
||||
raddr, err := net.ResolveUDPAddr("udp", t.upstream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, raddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.clients[client.String()] = conn
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// runQuicTap listens on the tap port until the process is killed. A read error
|
||||
// on one client's socket is logged and the socket dropped, so a probe that goes
|
||||
// away does not stop the tap.
|
||||
func runQuicTap(tapPort int, upstream string) {
|
||||
ln, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", tapPort))
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("quic tap listener: %w", err))
|
||||
}
|
||||
tap := newQuicTap(upstream)
|
||||
logf("[lab]", "quic tap listening %s -> %s (udp, IPv4)", ln.LocalAddr(), upstream)
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := ln.ReadFrom(buf)
|
||||
if err != nil {
|
||||
logf("[tap]", "read: %v", err)
|
||||
continue
|
||||
}
|
||||
payload := append([]byte(nil), buf[:n]...)
|
||||
logf("[tap]", "-> %d bytes to %s %s", n, upstream, describeQuic(payload, false))
|
||||
up, err := tap.socketFor(from)
|
||||
if err != nil {
|
||||
logf("[tap]", "upstream socket: %v", err)
|
||||
continue
|
||||
}
|
||||
if _, err := up.Write(payload); err != nil {
|
||||
logf("[tap]", "forward: %v", err)
|
||||
continue
|
||||
}
|
||||
go func(up *net.UDPConn, client net.Addr) {
|
||||
reply := make([]byte, 65535)
|
||||
for {
|
||||
rn, err := up.Read(reply)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
logf("[tap]", "<- %d bytes from %s %s", rn, upstream, describeQuic(reply[:rn], true))
|
||||
if _, err := ln.WriteTo(reply[:rn], client); err != nil {
|
||||
logf("[tap]", "send back: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(up, from)
|
||||
}
|
||||
}
|
||||
|
||||
// describeQuic reads the fields that are in the clear: the header form, the
|
||||
// version, the connection ID lengths, and the first bytes, which are what tells
|
||||
// a stateless reset from a packet the probe could have decrypted.
|
||||
func describeQuic(packet []byte, reply bool) string {
|
||||
if len(packet) == 0 {
|
||||
return "empty datagram"
|
||||
}
|
||||
head := packet[0]
|
||||
kind := "short header (1-RTT, or a stateless reset)"
|
||||
version := ""
|
||||
ids := ""
|
||||
if head&0x80 != 0 {
|
||||
if len(packet) < 7 {
|
||||
return fmt.Sprintf("truncated long header (%d bytes) %s", len(packet), hexHead(packet))
|
||||
}
|
||||
ver := uint32(packet[1])<<24 | uint32(packet[2])<<16 | uint32(packet[3])<<8 | uint32(packet[4])
|
||||
version = fmt.Sprintf(" version=0x%08x", ver)
|
||||
switch {
|
||||
case ver == 0:
|
||||
kind = "long header, VERSION NEGOTIATION"
|
||||
case head&0x30 == 0x00:
|
||||
kind = "long header, Initial"
|
||||
case head&0x30 == 0x10:
|
||||
kind = "long header, 0-RTT"
|
||||
case head&0x30 == 0x20:
|
||||
kind = "long header, Handshake"
|
||||
case head&0x30 == 0x30:
|
||||
kind = "long header, RETRY"
|
||||
}
|
||||
dcidLen := int(packet[5])
|
||||
if 6+dcidLen+1 <= len(packet) {
|
||||
scidLen := int(packet[6+dcidLen])
|
||||
ids = fmt.Sprintf(" dcid=%d scid=%d", dcidLen, scidLen)
|
||||
}
|
||||
} else if reply {
|
||||
kind = "short header: 1-RTT (we hold no keys) or a stateless reset"
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%s%s%s first bytes %s", kind, version, ids, hexHead(packet)))
|
||||
}
|
||||
|
||||
func hexHead(packet []byte) string {
|
||||
n := len(packet)
|
||||
if n > 24 {
|
||||
n = 24
|
||||
}
|
||||
return hex.EncodeToString(packet[:n])
|
||||
}
|
||||
2
tools/quinn-stand/.gitignore
vendored
Normal file
2
tools/quinn-stand/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# The stand's own build, never tracked.
|
||||
target/
|
||||
965
tools/quinn-stand/Cargo.lock
generated
Normal file
965
tools/quinn-stand/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "fastbloom"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"libm",
|
||||
"portable-atomic",
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fastbloom",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-stand"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"quinn",
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
20
tools/quinn-stand/Cargo.toml
Normal file
20
tools/quinn-stand/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "quinn-stand"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
# Not a workspace member on purpose: this is an experiment about a dependency
|
||||
# the workspace deliberately does not have (`quinn`), and it must not enter the
|
||||
# root lock file or the policy job's graph. The empty table below is what keeps
|
||||
# cargo from adopting the root workspace.
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
# Default features: `rustls-ring`. On x86_64 that is fine, and it is exactly the
|
||||
# point of the measurement - see README.md.
|
||||
quinn = "0.11"
|
||||
# `ring`, explicitly: quinn needs a process-level provider, and this is the one
|
||||
# its default features bring - the dependency the workspace refuses (Rule 1).
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
80
tools/quinn-stand/README.md
Normal file
80
tools/quinn-stand/README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# `quinn-stand` — the QUIC library this repository does not use
|
||||
|
||||
An experiment, not part of the product. It answers one question with a
|
||||
measurement instead of an argument: **what does a ready-made QUIC stack see on
|
||||
the host list test 2 already covers?**
|
||||
|
||||
It is a standalone cargo project, not a workspace member (the empty `[workspace]`
|
||||
table in `Cargo.toml` keeps it out): `quinn`'s default features pull
|
||||
`rustls-ring`, and neither `quinn` nor `ring` may enter the root lock file or the
|
||||
`policy` CI job's graph. Building it here also measures that cost, which is the
|
||||
second thing the experiment is for.
|
||||
|
||||
What it does: for every host in `scripts/quic/hosts.txt`, connect over QUIC with
|
||||
ALPN `h3`, an 8-second idle timeout (the value measured for the probe), and a
|
||||
certificate verifier that accepts anything (the endpoint's chain is not the
|
||||
subject). Then print the outcome in the vocabulary the column uses — a completed
|
||||
handshake, a stateless reset, a close with a code, a transport error, a version
|
||||
mismatch — plus `ConnectionStats` and the elapsed time.
|
||||
|
||||
The question that matters is not whether it connects. It is:
|
||||
|
||||
* does it classify the endpoints this probe gets wrong the same way, or better?
|
||||
* and, decisively, **can it tell "a reply arrived that opens under no key" from
|
||||
"nothing arrived"?** That distinction is the probe's most valuable finding
|
||||
(`quic_answered_without_handshake`, and the decoy Initial behind it), and a
|
||||
stack that requires decryption before it reports anything may not be able to
|
||||
make it at all. `ConnectionStats` is printed for exactly this reason.
|
||||
|
||||
```
|
||||
cargo run --release # the whole list
|
||||
cargo run --release -- host ... # a subset
|
||||
```
|
||||
|
||||
## What it found (2026-09-26, all 34 hosts)
|
||||
|
||||
**It connects where this probe does not.** `www.apkmirror.com`,
|
||||
`www.facebook.com` and `www.instagram.com` complete the handshake in quinn
|
||||
(1.1–3.1 s) while the hand-written probe reports
|
||||
`quic_answered_without_handshake` or a stateless reset — a third independent
|
||||
implementation on the same addresses, agreeing with the stock client and with
|
||||
Chrome. `www.messenger.com` is the exception: quinn times out there too.
|
||||
|
||||
**Its close codes are the same numbers.** `x.com`, `gateway.discord.gg`,
|
||||
`hub.docker.com`, `www.themoscowtimes.com` and `www.canva.com` give
|
||||
`the cryptographic handshake failed: error 40` — 0x100 + 40 = 296, which is
|
||||
exactly the probe's `quic_close_296`; `www.cdn77.com` gives `error 112` = 368,
|
||||
the probe's `quic_close_368`. So the vocabulary maps one to one, and the earlier
|
||||
worry that a library collapses everything into "handshake failed" was wrong:
|
||||
quinn separates reset, timeout, version mismatch and a close with its code.
|
||||
|
||||
**It is *less* sensitive on five rows.** `aws.amazon.com`, `soundcloud.com`,
|
||||
`vk.ru`, `www.currenttime.tv`, `www.svoboda.org` (and `www.coursera.org`) time
|
||||
out in quinn where the probe sees a close (296/336) or an ICMP
|
||||
`port unreachable` — and where a *forced* Chrome completes the handshake. quinn
|
||||
does not surface ICMP, and it has no connection to read stats from once
|
||||
`Connecting` fails, so the decoy an endpoint sends first is not observable there.
|
||||
|
||||
**The timing tells the story.** A clean handshake takes 1.05 s; the hosts whose
|
||||
endpoint answers only a *retransmission* take 3.06 s. `www.apkmirror.com` is a
|
||||
3.06 s host — the endpoint answers the second flight, which is why a client that
|
||||
waits (quinn, aioquic) gets a ServerHello and this probe's own schedule did not.
|
||||
|
||||
**What it costs** (measured on the branch `quinn-probe`, where the same column is
|
||||
also implemented through quinn behind a `quinn-probe` feature):
|
||||
|
||||
* the artifact grows from **4 215 296** to **5 072 384** bytes — **+857 088
|
||||
(+20 %)** — with `--release --target x86_64-pc-windows-msvc` and
|
||||
`-C target-feature=+crt-static`;
|
||||
* `cargo tree --target all -e normal,build -i ring` puts **`ring` in the shipped
|
||||
graph**, both through `quinn-proto` and through the vendored `rustls`:
|
||||
`ring ← quinn-proto ← quinn ← dpi-core ← dpi-detector`. That is what the
|
||||
`policy` job checks, so the feature cannot be enabled in this repository —
|
||||
`ring` is assembly, and there is no MIPS target for it.
|
||||
|
||||
**What the branch proved anyway**: the column run through quinn reports
|
||||
`QUIC OK` for `www.apkmirror.com` and `www.facebook.com` and
|
||||
`QUIC CLOSED (296)` for `www.canva.com` — the rows the hand-written probe gets
|
||||
wrong. So the target for the probe is now a measured one, and the next step is
|
||||
the one that found the previous three defects: capture the stand's own hello
|
||||
(`tp_dump.py --extensions`) and diff it against the probe's.
|
||||
166
tools/quinn-stand/src/main.rs
Normal file
166
tools/quinn-stand/src/main.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
//! What does `quinn` see on the host list this repository's QUIC column tests?
|
||||
//!
|
||||
//! Same hosts, same window (8 s, the value measured for the probe), and the
|
||||
//! outcome classified the way the column classifies it: a completed handshake,
|
||||
//! a stateless reset, a close with a code, a timeout. The interesting question
|
||||
//! is not whether it connects - it will - but what it says about the endpoints
|
||||
//! this probe gets wrong, and in particular whether an endpoint that answers
|
||||
//! with a packet the client cannot decrypt is distinguishable from silence.
|
||||
//!
|
||||
//! Run: cargo run --release (from this directory)
|
||||
//! cargo run --release -- host... (a subset)
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use quinn::{ConnectionError, Endpoint, TransportConfig};
|
||||
|
||||
const IDLE: Duration = Duration::from_secs(8);
|
||||
|
||||
fn transport() -> TransportConfig {
|
||||
let mut config = TransportConfig::default();
|
||||
config.max_idle_timeout(Some(IDLE.try_into().expect("8 s fits")));
|
||||
config
|
||||
}
|
||||
|
||||
fn client_config() -> quinn::ClientConfig {
|
||||
let mut crypto = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(rustls::RootCertStore::empty())
|
||||
.with_no_client_auth();
|
||||
crypto.alpn_protocols = vec![b"h3".to_vec()];
|
||||
// The endpoint's certificate is not the subject: a diagnostic client must
|
||||
// reach the same verdict a browser does on a host whose chain it cannot
|
||||
// verify.
|
||||
crypto
|
||||
.dangerous()
|
||||
.set_certificate_verifier(std::sync::Arc::new(AcceptAny));
|
||||
let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).expect("quinn can use rustls");
|
||||
let mut config = quinn::ClientConfig::new(std::sync::Arc::new(quic));
|
||||
config.transport_config(std::sync::Arc::new(transport()));
|
||||
config
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AcceptAny;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for AcceptAny {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
use rustls::SignatureScheme::*;
|
||||
vec![
|
||||
RSA_PSS_SHA256, RSA_PSS_SHA384, RSA_PSS_SHA512, RSA_PKCS1_SHA256, RSA_PKCS1_SHA384,
|
||||
RSA_PKCS1_SHA512, ECDSA_NISTP256_SHA256, ECDSA_NISTP384_SHA384, ED25519,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// One host, in the vocabulary the column uses.
|
||||
async fn probe(endpoint: &Endpoint, host: &str, addr: std::net::SocketAddr) -> String {
|
||||
let started = Instant::now();
|
||||
let connecting = match endpoint.connect_with(client_config(), addr, host) {
|
||||
Ok(connecting) => connecting,
|
||||
Err(error) => return format!("connect: {error}"),
|
||||
};
|
||||
match connecting.await {
|
||||
Ok(connection) => {
|
||||
let stats = connection.stats();
|
||||
let elapsed = started.elapsed().as_secs_f32();
|
||||
connection.close(0u32.into(), b"done");
|
||||
format!(
|
||||
"ok {elapsed:.2}s udp_rx={} crypto_rx={} udp_tx={}",
|
||||
stats.udp_rx.datagrams, stats.frame_rx.crypto, stats.udp_tx.datagrams
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
let elapsed = started.elapsed().as_secs_f32();
|
||||
let kind = match &error {
|
||||
ConnectionError::Reset => "stateless_reset".to_owned(),
|
||||
ConnectionError::TimedOut => "timeout".to_owned(),
|
||||
ConnectionError::VersionMismatch => "version_negotiation".to_owned(),
|
||||
ConnectionError::ConnectionClosed(close) => format!(
|
||||
"close_{} ({})",
|
||||
close.error_code,
|
||||
String::from_utf8_lossy(&close.reason)
|
||||
),
|
||||
ConnectionError::ApplicationClosed(close) => format!(
|
||||
"application_close_{} ({})",
|
||||
close.error_code,
|
||||
String::from_utf8_lossy(&close.reason)
|
||||
),
|
||||
ConnectionError::TransportError(transport) => {
|
||||
format!("transport_error_{} ({})", transport.code, transport.reason)
|
||||
}
|
||||
other => format!("{other}"),
|
||||
};
|
||||
format!("{kind} {elapsed:.2}s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve(host: &str) -> Option<std::net::SocketAddr> {
|
||||
tokio::net::lookup_host((host, 443)).await.ok()?.next()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// `ring` as the process-wide provider: rustls refuses to guess when it
|
||||
// cannot see exactly one, and quinn's handshake runs through it.
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("no other provider installed");
|
||||
let listed: Vec<String> = {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
let listing = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../scripts/quic/hosts.txt");
|
||||
std::fs::read_to_string(listing)
|
||||
.expect("scripts/quic/hosts.txt")
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
} else {
|
||||
args
|
||||
}
|
||||
};
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().expect("bind address")).expect("endpoint");
|
||||
endpoint.set_default_client_config(client_config());
|
||||
|
||||
for host in &listed {
|
||||
let Some(addr) = resolve(host).await else {
|
||||
println!("{host:24} resolve failed");
|
||||
continue;
|
||||
};
|
||||
let outcome = probe(&endpoint, host, addr).await;
|
||||
println!("{host:24} {addr:22} {outcome}");
|
||||
}
|
||||
}
|
||||
89
vendor/rustls/PATCH.diff
vendored
89
vendor/rustls/PATCH.diff
vendored
|
|
@ -211,9 +211,8 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
+ fn messages(&self, acknowledged: &[u16], transcript_hash: &[u8]) -> Vec<(u8, Vec<u8>)>;
|
||||
+}
|
||||
diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock -x Cargo.toml.orig a/src/client/hello_profile.rs b/src/client/hello_profile.rs
|
||||
--- a/src/client/hello_profile.rs 1970-01-01 03:00:00.000000000 +0300
|
||||
+++ b/src/client/hello_profile.rs 2026-09-25 17:28:52.364155100 +0300
|
||||
@@ -0,0 +1,445 @@
|
||||
+++ b/src/client/hello_profile.rs 1970-01-01 03:00:00.000000000 +0300
|
||||
@@ -0,0 +1,460 @@
|
||||
+//! ClientHello profile override (dpi-detector patch).
|
||||
+//!
|
||||
+//! Upstream rustls fixes the ClientHello shape: the cipher-suite list and its
|
||||
|
|
@ -357,6 +356,21 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
+ /// `PeerIncompatible::ServerDoesNotSupportTls12Or13`, which callers can tell
|
||||
+ /// apart from a real block.
|
||||
+ pub legacy_versions: Vec<u16>,
|
||||
+
|
||||
+ /// Present the hello a QUIC client sends: an *empty* `legacy_session_id`.
|
||||
+ ///
|
||||
+ /// TLS 1.3 over TCP sets that field to 32 random bytes for middlebox
|
||||
+ /// compatibility (RFC 8446 Appendix D.4), and rustls does it from the
|
||||
+ /// connection's protocol — `is_quic()` — which a caller cannot reach
|
||||
+ /// without a QUIC-capable cipher suite. RFC 9001 §8.4 removes the mode for
|
||||
+ /// QUIC and has a server treat a non-empty field as `PROTOCOL_VIOLATION`;
|
||||
+ /// Cloudflare answers a hello that carries one with
|
||||
+ /// `CRYPTO_ERROR 0x12f` (`illegal_parameter`), which is what a probe on a
|
||||
+ /// profile-built hello meets. This flag is the one thing about a QUIC hello
|
||||
+ /// rustls decides outside a profile — the `quic_transport_parameters`
|
||||
+ /// extension and `h3` in ALPN are a raw extension and an ALPN override
|
||||
+ /// already.
|
||||
+ pub quic: bool,
|
||||
+}
|
||||
+
|
||||
+/// Marker for a GREASE extension (RFC 8701) inside a profile's extension order.
|
||||
|
|
@ -659,9 +673,10 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
+ /// `renegotiation_info`
|
||||
+ pub const RENEGOTIATION_INFO: u16 = 65281;
|
||||
+}
|
||||
|
||||
|
||||
diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock -x Cargo.toml.orig a/src/client/hs.rs b/src/client/hs.rs
|
||||
--- a/src/client/hs.rs 2006-07-24 05:21:28.000000000 +0400
|
||||
+++ b/src/client/hs.rs 2026-09-25 17:28:52.365680000 +0300
|
||||
+++ b/src/client/hs.rs 2026-09-26 02:38:15.631586800 +0300
|
||||
@@ -56,7 +56,7 @@
|
||||
//
|
||||
// If this is `None` then we do not support early data.
|
||||
|
|
@ -671,7 +686,20 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
suite: Option<SupportedCipherSuite>,
|
||||
ech_state: Option<EchState>,
|
||||
}
|
||||
@@ -153,14 +153,14 @@
|
||||
@@ -112,7 +112,11 @@
|
||||
// https://tools.ietf.org/html/draft-ietf-quic-tls-34#section-8.4
|
||||
let session_id = match session_id {
|
||||
Some(session_id) => session_id,
|
||||
- None if cx.common.is_quic() => SessionId::empty(),
|
||||
+ None if cx.common.is_quic()
|
||||
+ || config.hello_profile.as_ref().is_some_and(|p| p.quic) =>
|
||||
+ {
|
||||
+ SessionId::empty()
|
||||
+ }
|
||||
None if !config.supports_version(ProtocolVersion::TLSv1_3, cx.common.protocol) => {
|
||||
SessionId::empty()
|
||||
}
|
||||
@@ -153,14 +157,14 @@
|
||||
transcript_buffer.set_client_auth_enabled();
|
||||
}
|
||||
|
||||
|
|
@ -690,7 +718,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
};
|
||||
|
||||
let ech_state = match self.config.ech_mode.as_ref() {
|
||||
@@ -173,7 +173,7 @@
|
||||
@@ -173,7 +177,7 @@
|
||||
emit_client_hello_for_retry(
|
||||
transcript_buffer,
|
||||
None,
|
||||
|
|
@ -699,7 +727,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
extra_exts,
|
||||
None,
|
||||
self,
|
||||
@@ -187,11 +187,12 @@
|
||||
@@ -187,11 +191,12 @@
|
||||
/// a HelloRetryRequest.
|
||||
///
|
||||
/// `retryreq` and `suite` are `None` if this is the initial
|
||||
|
|
@ -714,7 +742,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
extra_exts: ClientExtensionsInput<'static>,
|
||||
suite: Option<SupportedCipherSuite>,
|
||||
mut input: ClientHelloInput,
|
||||
@@ -207,11 +208,21 @@
|
||||
@@ -207,11 +212,21 @@
|
||||
tls12: config.supports_version(ProtocolVersion::TLSv1_2, cx.common.protocol)
|
||||
&& !forbids_tls12,
|
||||
tls13: config.supports_version(ProtocolVersion::TLSv1_3, cx.common.protocol),
|
||||
|
|
@ -736,7 +764,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
let mut exts = Box::new(ClientExtensions {
|
||||
// offer groups which are usable for any offered version
|
||||
named_groups: Some(
|
||||
@@ -252,7 +263,7 @@
|
||||
@@ -252,7 +267,7 @@
|
||||
None => {}
|
||||
};
|
||||
|
||||
|
|
@ -745,13 +773,18 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
if let Some(cas_extension) = config.verifier.root_hint_subjects() {
|
||||
exts.certificate_authority_names = Some(cas_extension.to_owned());
|
||||
}
|
||||
@@ -286,26 +297,35 @@
|
||||
@@ -286,26 +301,35 @@
|
||||
(None, false) => None,
|
||||
};
|
||||
|
||||
- if let Some(key_share) = &key_share {
|
||||
- debug_assert!(supported_versions.tls13);
|
||||
- let mut shares = vec![KeyShareEntry::new(key_share.group(), key_share.pub_key())];
|
||||
-
|
||||
- if !retryreq
|
||||
- .map(|rr| rr.key_share.is_some())
|
||||
- .unwrap_or_default()
|
||||
- {
|
||||
+ if !key_shares.is_empty() {
|
||||
+ debug_assert!(offers_tls13);
|
||||
+ let mut shares = Vec::new();
|
||||
|
|
@ -768,11 +801,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
+ // A HRR that names a group asks for that one alone.
|
||||
+ continue;
|
||||
+ }
|
||||
|
||||
- if !retryreq
|
||||
- .map(|rr| rr.key_share.is_some())
|
||||
- .unwrap_or_default()
|
||||
- {
|
||||
+
|
||||
// Only for the initial client hello, or a HRR that does not specify a kx group,
|
||||
// see if we can send a second KeyShare for "free". We only do this if the same
|
||||
// algorithm is also supported separately by our provider for this version
|
||||
|
|
@ -796,7 +825,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
{
|
||||
shares.push(KeyShareEntry::new(component_group, component_share));
|
||||
}
|
||||
@@ -318,7 +338,7 @@
|
||||
@@ -318,7 +342,7 @@
|
||||
exts.cookie = Some(cookie.clone());
|
||||
}
|
||||
|
||||
|
|
@ -805,7 +834,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
// We could support PSK_KE here too. Such connections don't
|
||||
// have forward secrecy, and are similar to TLS1.2 resumption.
|
||||
exts.preshared_key_modes = Some(PskKeyExchangeModes {
|
||||
@@ -335,7 +355,7 @@
|
||||
@@ -335,7 +359,7 @@
|
||||
}
|
||||
|
||||
input.hello.offered_cert_compression =
|
||||
|
|
@ -814,7 +843,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
exts.certificate_compression_algorithms = Some(
|
||||
config
|
||||
.cert_decompressors
|
||||
@@ -388,11 +408,53 @@
|
||||
@@ -388,11 +412,53 @@
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -869,7 +898,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
let mut chp_payload = ClientHelloPayload {
|
||||
client_version: ProtocolVersion::TLSv1_2,
|
||||
random: input.random,
|
||||
@@ -409,7 +471,6 @@
|
||||
@@ -409,7 +475,6 @@
|
||||
EchMode::Grease(cfg) => Some(cfg.grease_ext(
|
||||
config.provider.secure_random,
|
||||
input.server_name.clone(),
|
||||
|
|
@ -877,7 +906,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
)),
|
||||
_ => None,
|
||||
});
|
||||
@@ -442,8 +503,10 @@
|
||||
@@ -442,8 +507,10 @@
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
|
@ -890,7 +919,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
input.hello.offered_cipher_suites = chp_payload.cipher_suites.clone();
|
||||
|
||||
let mut chp = HandshakeMessagePayload(HandshakePayload::ClientHello(chp_payload));
|
||||
@@ -527,12 +590,12 @@
|
||||
@@ -527,12 +594,12 @@
|
||||
input,
|
||||
transcript_buffer,
|
||||
early_data_key_schedule,
|
||||
|
|
@ -905,7 +934,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
Box::new(ExpectServerHelloOrHelloRetryRequest {
|
||||
next,
|
||||
extra_exts: extra_exts.into_owned(),
|
||||
@@ -839,7 +902,7 @@
|
||||
@@ -839,7 +906,7 @@
|
||||
transcript,
|
||||
self.early_data_key_schedule,
|
||||
// We always send a key share when TLS 1.3 is enabled.
|
||||
|
|
@ -914,7 +943,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
&m,
|
||||
self.ech_state,
|
||||
self.input,
|
||||
@@ -880,21 +943,28 @@
|
||||
@@ -880,21 +947,28 @@
|
||||
cx.common.check_aligned_handshake()?;
|
||||
|
||||
// We always send a key share when TLS 1.3 is enabled.
|
||||
|
|
@ -936,7 +965,8 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
+ if share.group() == req_group {
|
||||
+ return true;
|
||||
+ }
|
||||
+
|
||||
|
||||
- if req_group == offered_key_share.group() || Some(req_group) == offered_hybrid {
|
||||
+ share
|
||||
+ .hybrid_component()
|
||||
+ .and_then(|(group_name, _)| {
|
||||
|
|
@ -945,13 +975,12 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
+ .map(|skxg| skxg.name() == req_group)
|
||||
+ .unwrap_or_default()
|
||||
+ });
|
||||
|
||||
- if req_group == offered_key_share.group() || Some(req_group) == offered_hybrid {
|
||||
+
|
||||
+ if offered {
|
||||
return Err({
|
||||
cx.common.send_fatal_alert(
|
||||
AlertDescription::IllegalParameter,
|
||||
@@ -1022,8 +1092,19 @@
|
||||
@@ -1022,8 +1096,19 @@
|
||||
cx.data.early_data.rejected();
|
||||
}
|
||||
|
||||
|
|
@ -973,7 +1002,7 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
let Some(skxg) = config.find_kx_group(group, ProtocolVersion::TLSv1_3) else {
|
||||
return Err(cx.common.send_fatal_alert(
|
||||
AlertDescription::IllegalParameter,
|
||||
@@ -1032,15 +1113,15 @@
|
||||
@@ -1032,15 +1117,15 @@
|
||||
};
|
||||
|
||||
cx.common.kx_state = KxState::Start(skxg);
|
||||
|
|
@ -992,6 +1021,8 @@ diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock
|
|||
self.extra_exts,
|
||||
Some(cs),
|
||||
self.next.input,
|
||||
|
||||
|
||||
diff -ruN --strip-trailing-cr -x .cargo-ok -x .cargo_vcs_info.json -x Cargo.lock -x Cargo.toml.orig a/src/client/tls13.rs b/src/client/tls13.rs
|
||||
--- a/src/client/tls13.rs 2006-07-24 05:21:28.000000000 +0400
|
||||
+++ b/src/client/tls13.rs 2026-09-25 17:28:52.369232600 +0300
|
||||
|
|
|
|||
35
vendor/rustls/README-PATCH.md
vendored
35
vendor/rustls/README-PATCH.md
vendored
|
|
@ -57,12 +57,12 @@ survive real servers; they are described under "findings" below.
|
|||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `src/client/hello_profile.rs` | **new**: public `ClientHelloProfile` (cipher list, groups, the key-share group list, signature schemes, ALPN, extension order with `GREASE_EXTENSION_MARKER` placeholders, verbatim extra extensions, suppressed extensions, GREASE, the per-connection shuffle, certificate compression, `padding_to`, `legacy_versions`) and its `apply`. An empty certificate-compression list clears the typed value instead of setting an empty one: the profiles whose client advertises no algorithm (Safari 15.3, Tor 14.5) must send no extension 27 at all, not one with zero entries. `permute_extensions` shuffles the order the way BoringSSL's `ssl_setup_extension_permutation` does — one Fisher–Yates pass from the end, seeded by `hs.rs`, with the GREASE slots, the padding and the extensions TLS 1.3 requires last left in place |
|
||||
| `src/client/hello_profile.rs` | **new**: public `ClientHelloProfile` (cipher list, groups, the key-share group list, signature schemes, ALPN, extension order with `GREASE_EXTENSION_MARKER` placeholders, verbatim extra extensions, suppressed extensions, GREASE, the per-connection shuffle, certificate compression, `padding_to`, `legacy_versions`, `quic`) and its `apply`. An empty certificate-compression list clears the typed value instead of setting an empty one: the profiles whose client advertises no algorithm (Safari 15.3, Tor 14.5) must send no extension 27 at all, not one with zero entries. `permute_extensions` shuffles the order the way BoringSSL's `ssl_setup_extension_permutation` does — one Fisher–Yates pass from the end, seeded by `hs.rs`, with the GREASE slots, the padding and the extensions TLS 1.3 requires last left in place |
|
||||
| `src/client/follow_up.rs` | **new**: public `ClientFollowUp` (one method, `messages(acknowledged, transcript_hash) -> Vec<(u8, Vec<u8>)>`) and the place it is called from. A browser hello advertises ALPS or `channel_id`, the server acknowledges it in its EncryptedExtensions, and the client then owes the server a handshake message before its Finished; upstream rustls has neither a typed field for either extension nor a way to add a message to the second flight |
|
||||
| `src/client/client_conn.rs` | `ClientConfig::hello_profile: Option<Arc<ClientHelloProfile>>`, `ClientConfig::client_follow_up: Option<Arc<dyn ClientFollowUp>>`, `ClientConnectionData::server_extensions` (the extension ids the server sent in its EncryptedExtensions) and `ClientConnection::server_encrypted_extensions()`, which reads that set back |
|
||||
| `src/client/builder.rs` | initializes both hooks to `None`; `with_ech_mode` sets the ECH mode on a builder that has already chosen its versions — upstream's `with_ech` forces TLS 1.3 alone, and a profile that keeps its 1.2 fallback (Chrome 120) still carries the extension |
|
||||
| `src/crypto/hpke.rs` | re-exports `HpkeKem`, `HpkeKdf`, `HpkeAead` and `HpkeSymmetricCipherSuite` — an `Hpke` implementation outside the crate cannot name the suite it implements otherwise — and gives `HpkePrivateKey` the `from_bytes` constructor its private field implies |
|
||||
| `src/client/hs.rs` | applies the profile while building the ClientHello, with a per-connection GREASE seed and a 128-bit shuffle seed from the provider's CSPRNG; adds the GREASE key share and the GREASE `supported_versions` entry for a greasing profile; carries a *list* of key exchanges (`offered_key_shares`) instead of one, so a profile can send the shares a browser's `--tls-key-shares-limit` produces, and handles the HelloRetryRequest against that list; records the *encoded* extension set as `sent_extensions`; gates `compress_certificate` on the hello offering TLS 1.3 |
|
||||
| `src/client/hs.rs` | applies the profile while building the ClientHello, with a per-connection GREASE seed and a 128-bit shuffle seed from the provider's CSPRNG; adds the GREASE key share and the GREASE `supported_versions` entry for a greasing profile; carries a *list* of key exchanges (`offered_key_shares`) instead of one, so a profile can send the shares a browser's `--tls-key-shares-limit` produces, and handles the HelloRetryRequest against that list; records the *encoded* extension set as `sent_extensions`; gates `compress_certificate` on the hello offering TLS 1.3; and leaves `legacy_session_id` empty for a profile with `quic` set, since the QUIC rule for that field (RFC 9001 §8.4) is decided from the *connection*'s protocol, which a TCP client cannot reach |
|
||||
| `src/client/tls13.rs` | `initial_key_shares` builds one exchange per group `key_share_groups` names; `KeyExchangeChoice::new` looks the server's group up among every share the client sent (whole or hybrid component); `ExpectEncryptedExtensions` records the server's extension set, and `ExpectFinished` calls `client_follow_up` and appends what it returns to the client's second flight ahead of the Finished, hashed into the transcript |
|
||||
| `src/client/ech.rs` | `EchGreaseConfig::grease_ext` sizes the GREASE payload the way BoringSSL does — one of 128, 160, 192 or 224 bytes, a rounded estimate of the inner hello, plus the AEAD tag (`setup_ech_grease()` in its `ssl/encrypted_client_hello.cc`) — instead of encoding the inner hello this client would really send, which is 441 bytes and a length no browser produces. The outer hello is no longer needed to size the body, so that argument is gone |
|
||||
| `src/msgs/handshake.rs` | `SupportedProtocolVersions` gains `grease: Option<u16>` (written ahead of the real versions) and `legacy: Vec<u16>` (the fallbacks a browser advertises behind 1.2, written after them); `ClientExtensions` gains `profile_order`, `raw_extensions`, `suppress_extensions`, `padding_to`; the encoder honours them, computes RFC 7685 padding to the profile's target size, and still keeps ECH/PSK last; a certificate entry carrying SCTs (type 18) is accepted and ignored; `ServerExtensions::extension_types()` lists every extension id the server sent, typed fields and unknown ones together |
|
||||
|
|
@ -103,6 +103,17 @@ patch -p1 -d vendor/rustls < PATCH.diff # expect hunks only in the files ab
|
|||
`scripts/vendor-advisories.sh` asks the same database about the vendored
|
||||
crates by their published names and versions, and runs in the `policy` job.
|
||||
|
||||
One thing that rebase made visible, and that is deliberately left alone:
|
||||
upstream's TLS-1.2 signature-scheme filter (in `emit_client_hello_for_retry`)
|
||||
runs *before* the hook installs the shape, so a shaped 1.2 hello carries the
|
||||
shape's `signature_algorithms` unfiltered. Measured on the shipped shapes: not
|
||||
one of them advertises a code outside 0.23.45's `SignatureScheme::algorithm()`
|
||||
set — `rsa_pss_pss_*`, `0x0809`–`0x080b`, is what that filter drops — so the
|
||||
wire is unchanged and `cargo test -p dpi-core fingerprint` still pins the same
|
||||
JA4. Re-applying the filter after `profile.apply` was tried and reverted: it
|
||||
would make a shaped 1.2 hello differ from the browser's, and this probe exists
|
||||
to report what a browser would get.
|
||||
|
||||
3. Re-verify — this is the part that matters (see below).
|
||||
|
||||
## How the patch is verified
|
||||
|
|
@ -240,6 +251,26 @@ failing handshake or a real mismatched fingerprint, not a theoretical concern:
|
|||
follow-up, and the hook is additionally gated on the shape having advertised
|
||||
the extension the server named, so an unsolicited acknowledgement is not
|
||||
answered either.
|
||||
* **A QUIC ClientHello must leave `legacy_session_id` empty.** RFC 8446
|
||||
Appendix D.4 puts 32 random bytes there for middlebox compatibility over TCP,
|
||||
and RFC 9001 §8.4 removes the mode for QUIC: a server treats a non-empty field
|
||||
as `PROTOCOL_VIOLATION`. rustls decides it from `cx.common.is_quic()`, which a
|
||||
TCP-shaped client cannot reach without a QUIC-capable cipher suite — the
|
||||
provider here declares `quic: None` for every suite
|
||||
(`docs/ADDING_A_PROFILE.md` §6) — so the hello a profile builds over TCP
|
||||
carries the field and a QUIC endpoint refuses it. Measured against
|
||||
`cloudflare.com`: `CRYPTO_ERROR 0x12f` (`illegal_parameter`, the alert the
|
||||
server puts in a `CONNECTION_CLOSE`) with the field present, a ServerHello in
|
||||
0.1 s with `quic: true`. The flag is the whole fix; the
|
||||
`quic_transport_parameters` extension a QUIC endpoint also requires is a raw
|
||||
extension the caller supplies.
|
||||
* **A browser ClientHello does not fit one QUIC datagram.** The post-quantum key
|
||||
share alone is 1216 bytes (`X25519MLKEM768`), so a Chrome-shaped hello is
|
||||
~1700 bytes: the client's first flight has to be split across Initial packets,
|
||||
each padded to 1200 bytes, with the `CRYPTO` frame's offset carrying the
|
||||
stream on (`crates/dpi-core/src/net/quic.rs::client_initials`). Dropping the
|
||||
PQ group or the shape to make one datagram fit would send a hello no client
|
||||
sends.
|
||||
* **`compress_certificate` belongs to TLS 1.3 only.** rustls sets the extension
|
||||
only when the hello offers 1.3; the profile hook runs after that and must not
|
||||
put it back into a 1.2-only hello (RFC 8879), which is exactly the hello the
|
||||
|
|
|
|||
15
vendor/rustls/src/client/hello_profile.rs
vendored
15
vendor/rustls/src/client/hello_profile.rs
vendored
|
|
@ -141,6 +141,21 @@ pub struct ClientHelloProfile {
|
|||
/// `PeerIncompatible::ServerDoesNotSupportTls12Or13`, which callers can tell
|
||||
/// apart from a real block.
|
||||
pub legacy_versions: Vec<u16>,
|
||||
|
||||
/// Present the hello a QUIC client sends: an *empty* `legacy_session_id`.
|
||||
///
|
||||
/// TLS 1.3 over TCP sets that field to 32 random bytes for middlebox
|
||||
/// compatibility (RFC 8446 Appendix D.4), and rustls does it from the
|
||||
/// connection's protocol — `is_quic()` — which a caller cannot reach
|
||||
/// without a QUIC-capable cipher suite. RFC 9001 §8.4 removes the mode for
|
||||
/// QUIC and has a server treat a non-empty field as `PROTOCOL_VIOLATION`;
|
||||
/// Cloudflare answers a hello that carries one with
|
||||
/// `CRYPTO_ERROR 0x12f` (`illegal_parameter`), which is what a probe on a
|
||||
/// profile-built hello meets. This flag is the one thing about a QUIC hello
|
||||
/// rustls decides outside a profile — the `quic_transport_parameters`
|
||||
/// extension and `h3` in ALPN are a raw extension and an ALPN override
|
||||
/// already.
|
||||
pub quic: bool,
|
||||
}
|
||||
|
||||
/// Marker for a GREASE extension (RFC 8701) inside a profile's extension order.
|
||||
|
|
|
|||
6
vendor/rustls/src/client/hs.rs
vendored
6
vendor/rustls/src/client/hs.rs
vendored
|
|
@ -112,7 +112,11 @@ impl ClientHelloInput {
|
|||
// https://tools.ietf.org/html/draft-ietf-quic-tls-34#section-8.4
|
||||
let session_id = match session_id {
|
||||
Some(session_id) => session_id,
|
||||
None if cx.common.is_quic() => SessionId::empty(),
|
||||
None if cx.common.is_quic()
|
||||
|| config.hello_profile.as_ref().is_some_and(|p| p.quic) =>
|
||||
{
|
||||
SessionId::empty()
|
||||
}
|
||||
None if !config.supports_version(ProtocolVersion::TLSv1_3, cx.common.protocol) => {
|
||||
SessionId::empty()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue