Skip to main content

Moved: on 2026-10-15 (version 0.3.0) this code moved to seleniumbase-rs-stealth (crates seleniumbase-rs-wire, -scrape, -impersonate); this plan is kept as design history, with the paths the code had here.

Status (2026-10-11, branch feat/pure-cdp-and-mcp-parity at 4184e74): WP0 to WP17 are merged, including WP14 (HttpSession), WP15 (MitmProxy) and WP16 (StealthSession). The WP18 reviews (two passes) are done and their fixes are in progress. WP19 (re-exports and documentation) is in progress. The plan was written on 2026-10-10 as design only, at 8c1394d. Where the code differs from what is written below, the next section says how, and the code wins. Sections 3.11 to 3.13 describe what was built. The user-facing description of what exists is Stealth Scraping; the layout rules are recorded in ADR 003. Sources: the project memory (stealthscraper-feature-parity, project-priorities-2026-10-10), three Context7 reads of /ypno/stealthscraper-rs, the wreq 6.0.0-rc.31, wreq-proto 0.2.6, http2 0.5.20 and btls 0.5.6 sources in ~/.cargo/registry, and our code read through TokenSave.

Status and what the code changed#

WPScopeState
0Scaffold, features, lint block, purity testMerged
1egress (redact, route, geo, pool), randomized_with_majorMerged
2challenge (detect, markers, mitigation)Merged
3ledger::state, runtime::json_storeMerged
4jar (cookie store, Set-Cookie)Merged
5wire::idsMerged
6wire::{client_hello, ja3, ja4, tls_spec}Merged
7wire::{akamai, h2_frames, h2_spec, header_spec}Merged
8passport (identity, policy), ledger::events, tracing sink, identity fileMerged
9runtime::capture, capture_tls, capture_hints, first Chrome measurementMerged
10impersonate::tls_serverMerged
11impersonate::capture_h2, wire::emulation, Chrome HTTP/2 and header measurementMerged
12impersonate::{convert, client}, CustomProfileBuilder, wire probeMerged
13scrape::error, runtime::{cdp, egress_switch, browser_scraper}Merged
14impersonate::http_session (HttpSession, HttpOptions, HttpRequest, HttpReply, HttpError, Attempt, Rules, Verdict, Fetched)Merged
15impersonate::mitm (MitmProxy, MitmOptions, MitmError, MitmStats)Merged
16impersonate::stealth_session (StealthSession, StealthSessionBuilder, StealthFetch, StealthError)Merged
17Hardening: session_registry, secure-context userAgentData, screen through the device metrics, no Runtime.enableMerged
18Split-role security reviewPass 1 and pass 2 reviews done; fixes in progress
19Re-exports, final gates, documentation. The tutorial now covers HttpSession, MitmProxy and StealthSession; the re-exports in lib.rs, scrape/mod.rs, impersonate/mod.rs and wire/mod.rs are written and checked with cargo check and cargo clippy over the lib for each feature. The full --all-targets gates, doctests, cargo audit and cargo deny are the lead’s, after the fixes of WP18In progress

Deviations from the plan that the code made. The plan’s text above was not rewritten; read it with these:

  • SpecError is split per module. The plan has one SpecError that holds every variant. In the code wire::tls_spec::SpecError holds the TLS variants, wire::h2_spec::SpecError the HTTP/2 variants, and the variant that names an unmeasured browser and system, NotMeasured { kind, os }, lives in wire::emulation::EmulationError (with VersionNotMeasured, Parse, TooLarge, Inconsistent, Headers and Duplicate). wire::emulation::SpecError is an alias for EmulationError, so the plan’s SpecError::NotMeasured is reachable under that name. ImpersonateError wraps each of them as Tls, Http2 and Emulation, next to Ja3, Ja4, Peetprint, Akamai, Headers, Convert, VersionMismatch, Incomplete, Fingerprint, UnknownHeader and Build; there is no single Spec variant.
  • KnownGap lives in wire::tls_spec, not in emulation. wire::emulation re-exports it. The plan’s name for the HTTP/2 spec, Http2Spec, is an alias of H2Spec.
  • BrowserKind has no serde. The enum in wire/mod.rs derives only Debug, Clone, Copy, PartialEq and Eq. EmulationRef in scrape::passport::identity serializes it through a private module that knows the one spelling, chrome, so a new variant breaks the build there until it is handled.
  • Mode lives in scrape::challenge::mitigation. scrape::ledger::state and scrape::passport::policy import it from there.
  • Chrome 155 sends a GREASE signature algorithm that the vendored BoringSSL does not write. It is not a KnownGap variant (those are TrustAnchorsExtension and MlDsaSignatureAlgorithms). JA3 and JA4 strip GREASE, so only the peetprint shows it, and a wire test pins the difference and fails once a newer BoringSSL closes it. A TlsSpec read from a hello drops GREASE signature algorithms.
  • ScrapeError has three more variants than §3.13 lists: CoolingDown, Navigation and NoEgressAvailable. BrowserScraperBuilder has more settings than the sketch: mitigation, solve_timeout, clock, launcher (the BrowserLauncher port), fingerprint and session_store.
  • impersonate also enables brotli, for the certificate compressor of the impersonated hello (Chrome offers brotli certificate compression).
  • StealthSession::fetch returns StealthFetch, not Fetched. Fetched is HttpSession::fetch’s { reply, verdict }. The HttpSession surface is larger than the sketch: HttpOptions, Attempt, Rules, Verdict, assess and preflight, and HttpError has more variants (§3.11).
  • MitmError is not the sketch’s. It has Bind, Tls, InvalidOptions, Random, Upstream, Egress, Unauthorized, BadTarget, NotIntercepted, HostMismatch, BodyTooLarge, VersionMismatch, UpstreamRequest, ForbiddenDestination and MalformedUpgrade; the SNI mismatch is TlsServerError::SniMismatch (§3.12).
  • The MITM proxy relays WebSocket (wss:// only) with the measured WebSocket hello and refuses every other upgrade with an empty 502 (§3.12); it also refuses a request whose user agent claims another Chrome major than the profile’s.
  • The SPKI-list trust is measured. What --ignore-certificate-errors-spki-list accepts was read from the Chromium source; the acceptance of a chain that carries the pinned leaf key is now measured on Chrome 155 (headless, macOS) by without_the_proxy_the_leaf_pin_alone_trusts_a_chain_carrying_the_leaf_key in tests/impersonate_mitm_chrome.rs, and the proxy never serves such a chain to a page. Each skipped check is still as read in the source.
  • StealthSession defaults to 4 attempts per fetch and starts the MITM proxy lazily (§3.13).
  • Re-exports. The main types are re-exported at the top of wire, scrape and impersonate and at the crate root; the two spec SpecErrors are TlsSpecError and H2SpecError. The stealth layer is experimental and not a stable API.

0. Decisions#

  1. Nothing from stealthscraper-rs, impersonate-rs, impcurl, curl-impersonate-cli or wreq-util enters Cargo.toml, optional or not. Their numbers (Chrome 153, Safari 27) are hints.
  2. Two new features; the default build gains no dependency and no C code. scrape (pure Rust, adds only psl): domain layer, JSON state store, CDP browser leg, TLS and client-hint capture. impersonate = ["scrape", ...]: wreq =6.0.0-rc.31 (BoringSSL through btls, needs cmake and a C/C++ toolchain), MITM proxy, HttpSession, StealthSession, HTTP/2 capture.
  3. Every TLS, HTTP/2, header and client-hint value ships as JSON written by our capture tools against the local browsers, embedded with include_str!, parsed once, never typed by hand. An unmeasured browser/OS pair is an error (SpecError::NotMeasured), never a fallback to bare wreq.
  4. Custom JA3 and Akamai strings overlay a measured base. What BoringSSL or wreq’s HTTP/2 stack cannot emit is refused with a named SpecError before a client exists; what is accepted is proven by a wire round trip.
  5. Hexagonal: src/scrape/domain is pure (no tokio, std::fs/std::net/std::process or clock reads; functions take now), enforced by a syn test that allows from sb_cdp only the data types Proxy, Cookie, SameSite, Captcha and the sync::locked helper. Adapters: src/scrape/adapters (std, tokio, sb_cdp) and src/impersonate (wreq, btls, hyper). Ports: StateStore, EventSink, GeoResolver, EgressSwitch.
  6. One thiserror enum per module, mapped to SeleniumBaseError::Stealth { reason } at the crate boundary. tracing only, with redacted fields (instrument(skip_all, fields(url = %redacted))).
  7. New modules: #![forbid(unsafe_code)], the pedantic lint block src/lib.rs gives sb_cdp, files under 800 lines, functions under 50, named constants, .expect() only with a reason.

1. Feature map#

#stealthscraper-rs featureOur homeReused as isNew
1Local MITM TlsSpoofingProxy: terminate the browser’s TLS, replay with the profile’s ClientHello and H2, hyper streaming, hot-swappable upstreamimpersonate::mitm::MitmProxy, impersonate::tls_serversb_cdp::Proxy, LaunchOptionsBuilder::{proxy, arg}, ProxyAuth (answers the MITM’s 407 over Fetch.authRequired), common::shutdown::{spawn_cleanup, cleanup_within, timeout_from_env}ephemeral CA, SPKI pin, CONNECT + h1 leg, wreq replay, upstream swap
2H2 SETTINGS and their order, connection window, HEADERS priority, pseudo-header order, header set and orderwire::{h2_spec, header_spec, emulation}, impersonate::convert-measured Http2Spec/HeaderSpec converted to wreq::http2::Http2Options and OrigHeaderMap
3impersonation_client(&BrowserProfile) -> wreq::ClientBuilder with UA, Sec-CH-UA*, Accept-Languageimpersonate::clientFingerprint, ClientHints, BrandVersion, randomize::chromium_hintsImpersonationProfile; identity headers set per request, after emulation
4BrowserProfile::random()ImpersonationProfile::random(seed)Fingerprint::randomized and the randomize.rs machine and place tablesFingerprint::randomized_with_major so the UA claims the measured major
5BrowserKind picks the JA4/H2 source; fingerprint DB (hardware/software traits, JA4 suites)wire::BrowserKind, wire::emulationrandomize.rs GPU tiers, screens, cores, memorymeasured TLS/H2 entries, for_kind
6CloudScraper builder (with_geo_proxies, proxy_strategy, with_max_challenge_attempts, with_state_store, with_event_sink), solve_challengescrape::adapters::browser_scraper::BrowserScraper (no MITM), impersonate::StealthSession (with MITM)Browser, Page, BrowserContext, ContextOptions::proxy, Page::{solve_captcha, human}, Lease::save_sessionbuilders, fetch/detect/mitigate loop, clearance wait
7rotate_profile(): new fingerprint, same MITM port and egressBrowserScraper::rotate_profile, StealthSession::rotate_profileBrowser::launch, LaunchOptionsBuilder::fingerprint, Page::apply_fingerprintrelaunch rules (§3.13)
8Challenge detect and MitigationPolicy deciding an Actiondomain::{challenge, markers, mitigation}sb_cdp::Captcha (widget evidence), RetryPolicy::delay_for_attempt (backoff)markers, Detection, decision table
9Proxy pool with RotationStrategydomain::{proxy_pool, egress}sb_cdp::Proxy::parse (password-free Debug), stealth::humanize::Rngstrategies, health, quarantine
10CountryCode, Locale table, GeoResolver port, locale led by the egress country and applied over CDPdomain::geo, adapters::cdp::apply_localerandomize.rs REGIONS (moved into LOCALES), Page::apply_fingerprint, Emulation::{locale, timezone, geolocation}CountryCode, LOCALES, StaticGeoResolver
11DomainState + StateStore (memory; redb under persistence)domain::state, adapters::json_storeSessionStore’s Arc<Mutex<BTreeMap>> shape, sb_cdp::sync::locked, RateLimiter (per-site pacing)JsonStateStore (atomic file) instead of redb
12ScraperEvent + EventSink (Noop, Log); credentials redacted before logs, events and statedomain::{events, redact}, adapters::tracing_sinkredacting Debug of Proxy, ProxyConfig, Cookie, SessionRedactedUrl, Secret, TracingEventSink, CollectingEventSink
13Stealth injection JS (navigator, WebGL, Canvas, Audio)stealth::providers (exists)29 builtin providers, SB_PREAMBLE native-shape helpers, Identity::ofsession_registry() (§3.14)
14Interactive solver (Turnstile click)sb_cdp::captcha (exists)Page::solve_captcha, Captcha::ALLadapters::cdp::wait_for_clearance
15Human behaviour (Bezier mouse, keystroke jitter)existshumanize::{bezier_mouse_path, keystroke_delays}, stealth::behavior, sb_cdp::Human-
16Dual-mode StealthSession/StealthIdentity; re-escalate on a fresh challenge or a near-expiry clearanceimpersonate::{stealth_session, http_session}, domain::{identity, session_policy, cookie_jar}unreviewed draft .claude/worktrees/agent-adf311134dafc0ac4/src/sb_cdp/handoff.rs: UA parsing, Accept-Language, body limits, cookie sync-back (its wreq-util preset table is dropped)RFC 6265 jar, identity serde, mode state machine
17Measurement tools (capture_fingerprint, capture_h2, capture_hints, emulation_roundtrip)adapters::capture, impersonate::capture_h2, examples/capture_*.rs, tests/impersonate_wire.rsLaunchOptionsBuilder::arg (--host-resolver-rules), Page::evaluate, Browser::execute("Browser.getVersion")ClientHello and H2 frame readers, JA3/JA4/Akamai
18Hardening: never Runtime/DOM/Log/Debugger/Profiler.enable; screen via setDeviceMetricsOverride 0x0; userAgentData only in secure contexts; no plugins/mimeTypes/pdfViewerEnabled/connection hooks; seeded noise; pipe transportstealth::{cdp, providers}, sb_cdp::identity, audit testsnoise is already a pure function of (seed, index)drop Runtime.enable in src/stealth/cdp.rs:61, hygiene and audit tests; pipe deferred (§6)
19Long-lived browser (their idle-timeout bug)BrowserScraper + testno idle timeout anywhere (BrowserPool has only acquire_timeout)idle-browser test, named lifetime constants
20(ours) custom JA3 and Akamai stringswire::{ja3, akamai, tls_spec, h2_spec}, impersonate::client::CustomProfileBuilder-parse, reproducibility check, wire round trip

2. Layout, features, dependencies#

Layout revised (2026-10-11). The tree below was first written by hexagonal layer (scrape/domain, scrape/adapters). It is now laid out by concern. Work packages, file owners and the contents of each file are unchanged; only the paths move:

  • src/scrape/domain/wire/** -> src/wire/** (pure; cfg(any(feature = "scrape", feature = "impersonate"))): ja3, ja4, akamai, ids, tls_spec, h2_spec, h2_frames, header_spec, client_hello, emulation; BrowserKind stays in wire/mod.rs.
  • src/scrape/domain/{challenge,markers,mitigation}.rs -> src/scrape/challenge/{detect,markers,mitigation}.rs.
  • src/scrape/domain/{egress,proxy_pool,geo,redact}.rs -> src/scrape/egress/{route,pool,geo,redact}.rs.
  • src/scrape/domain/{cookie_jar,set_cookie}.rs -> src/scrape/jar/{store,set_cookie}.rs.
  • src/scrape/domain/{state,events}.rs -> src/scrape/ledger/{state,events}.rs.
  • src/scrape/domain/{identity,session_policy}.rs -> src/scrape/passport/{identity,policy}.rs.
  • src/scrape/adapters/** -> src/scrape/runtime/** (everything that does I/O).
  • src/impersonate/** stays where it is.

The pure directories checked by tests/scrape_domain_purity.rs are src/wire and src/scrape/{challenge,egress,jar,ledger,passport}. Module names drop “domain”, “adapters” and “ports”; the public type names in §3 are unchanged. The paths in §2.1 below and in §5 read as the old names until this note is applied; the map above is the authority.

2.1 Files and owners (WP = work package, §5)#

WP0 creates every listed source file with only a //! line, so later packages fill files they own and never edit a shared mod.rs. Unit tests stay in-file; integration tests are grouped into the few binaries above because each test binary links the whole crate (disk).

2.2 Cargo.toml (only WP0 edits it)#

Reused, already present: tokio (net, io-util, fs, sync, time, macros), serde, serde_json, thiserror 2, tracing, async-trait, url 2.5, chrono, ring 0.17 (SHA-256 for JA4, SystemRandom for tokens), base64 0.23 (SPKI pins), futures-util; dev: tokio test-util, syn 3. hyper 1.12.0, hyper-util 0.1.21, http-body-util 0.1.5, http 1.5.0 and bytes 1.12.1 are already in Cargo.lock through reqwest.

Not enabled on purpose: wreq cookies (our RFC 6265 jar writes Cookie; redirects are followed by hand), ws, hickory-dns, system-proxy. zstd pulls zstd-sys (C, built by cc), reachable only under impersonate. Licenses: wreq and btls Apache-2.0, btls-sys MIT, tokio-btls MIT OR Apache-2.0, psl MIT/Apache-2.0, all inside deny.toml’s allow-list (its [graph] all-features sees impersonate).

3. Public API#

Conventions for the blocks below: public enums are #[non_exhaustive]; data types derive Clone, Debug, PartialEq and Serialize, Deserialize when persisted; errors derive thiserror::Error; the Debug of anything holding a secret is hand-written and prints <redacted> or counts.

3.1 Redaction and egress (domain/{redact,egress}.rs, WP1)#

Only EgressKey is ever serialized (state, identity, events); credentials never are.

3.2 Geo and proxy pool (domain/{geo,proxy_pool}.rs, WP1)#

Fingerprint::randomized_with_major(os, seed, major) is randomized with the Chromium major fixed: an identity riding the measured TLS must claim the measured version, never 153 while Chrome 155 renders.

3.3 Challenge and mitigation (domain/{challenge,markers,mitigation}.rs, WP2)#

A challenge needs interstitial markers (just a moment, cf_chl_opt, /cdn-cgi/challenge-platform/h/{b,g}/orchestrate, form/stage ids, cf-mitigated: challenge, per-vendor equivalents: one named const each, with the fixture that proves it). A bare cf-ray, server: cloudflare or Turnstile widget gives None with the evidence attached. Decisions: None -> Proceed; RateLimited -> Cooldown (Retry-After); JS/interactive in Http -> Escalate, in Browser -> Solve, then RotateEgress, then RotateProfile as consecutive_challenges crosses thresholds; Blocked -> RotateEgress else Cooldown; attempts exhausted -> GiveUp.

3.4 State (domain/state.rs, adapters/json_store.rs, WP3)#

atomic_write: sibling temp file opened 0o600 (Unix OpenOptionsExt::mode), write_all, sync_all, rename, then sync_all on the parent directory, inside spawn_blocking. A missing file is an empty store; a corrupt one is StateError::Corrupt, never silently reset. One writer process per file (documented; no cross-process lock).

Rules, each a named test: §5.2 attributes; §5.1.1 dates (two-digit years, any token order); Max-Age beats Expires, <= 0 deletes, expiry capped at 400 days (6265bis). Domain is lower-cased, loses a leading dot, must domain-match the host (§5.1.3). Public suffix (§5.3 step 5): ignored unless it equals the host, which then gets a host-only cookie. IP hosts: Domain must equal the IP, cookie host-only, no suffix matching. Paths per §5.1.4. 6265bis as Chrome applies it: an insecure origin cannot set or overwrite a Secure cookie; __Secure- needs Secure; __Host- needs Secure, Path=/, no Domain. SameSite per SiteContext; order per §5.4; bounds (named consts) 4096 bytes, 180 per site, 3000 total, least-recently-accessed eviction. CDP: a leading dot in sb_cdp::Cookie::domain means a domain cookie; expires: None is a session cookie.

3.6 Identity, session policy, events (domain/{identity,session_policy,events}.rs, WP8)#

URLs enter events only as RedactedUrl and proxies only as EgressKey, so a secret cannot reach an event by construction. adapters/identity_file.rs saves through atomic_write (0600: the file holds cookies) and loads with a schema check.

3.7 Wire parsers and specs (domain/wire/*, WP5 to WP7)#

JA4 per FoxIO’s TLS-client definition (BSD-3-Clause): a = t + highest non-GREASE version + d/i

  • two-digit cipher and extension counts + first and last ALPN character; b/c = first 12 hex of SHA-256 (ring::digest) over sorted ciphers / sorted extensions minus SNI and ALPN, _, sigalgs in wire order. JA3 is compared as a string (no MD5 dependency). Pseudo order comes from HPACK static-table name indices (1-7), so no Huffman decoder; header names and values are measured over plaintext h1.

3.8 What BoringSSL and wreq cannot reproduce#

ids.rs holds the IANA names and the capability sets BORINGSSL_CIPHERS, BORINGSSL_CLIENT_EXTENSIONS, BORINGSSL_GROUPS, BORINGSSL_SIGALGS. WP5 seeds them from btls 0.5.6’s constants (ExtensionType, KeyShare, SslSignatureAlgorithm); WP12’s probe test builds a client per id, captures the ClientHello and fails if a set claims an id that does not appear on the wire. The probe, not this table, decides.

JA3 fieldAcceptedRejected, with error
version771 (0x0303)anything else: SpecError::LegacyVersion
ciphersids in BORINGSSL_CIPHERS; the TLS 1.3 trio in any order (preserve_tls13_cipher_list)DHE (0x0033, 0x0039, 0x009e, 0x009f), CCM (0x1304, 0x1305), SCSVs (0x00ff, which BoringSSL replaces by ext 0xff01; 0x5600), NULL/EXPORT/RC4/anon, any id the probe does not confirm (CBC-SHA256/384 included): UnsupportedCipher; a TLS 1.3 subset: Tls13Suites
extensionsids a TlsOptions knob turns on: 0, 5, 10, 11, 13, 16, 18, 23, 27, 28, 34, 35, 43, 45, 51, 17513, 17613, 65037, 65281; 21 (padding) only where BoringSSL’s length rule puts it, checked by round trip41 pre_shared_key (resumption only), 42 early_data, 49 post_handshake_auth, 50 signature_algorithms_cert, 22 encrypt_then_mac, 15 heartbeat, 0xca34 trust_anchors (needs btls > 0.5.6 and wreq > rc.31: btls#168, wreq#1273), unknown ids: UnsupportedExtension { id, reason }; repeats: DuplicateExtension
groups29 x25519, 23 P-256, 24 P-384, 25 P-521, 4588 X25519MLKEM768, 25497 X25519Kyber768Draft00; MLKEM1024, P256Kyber768, ffdhe2048/3072 if the probe confirms them30 x448, brainpool, secp256k1, ffdhe6144/8192, others: UnsupportedGroup
point formats0, or empty when ext 11 is absentanything else: PointFormats
not in JA3signature algorithms, ALPN, key shares, cert compression, versions, PSK modes come from the base spec or the builderML-DSA 0x0904, 0x0905, 0x0906 and any id outside BORINGSSL_SIGALGS: UnsupportedSigalg { id, name }

A custom JA3 turns extension permutation off, so its JA3 on the wire is exact. Measured Chrome keeps permutation on, as Chrome does: its JA3 changes per connection by design, and JA4 is the target.

Akamai partAcceptedRejected, with error
SETTINGS id:value;...ids 1, 2, 3, 4, 5, 6, 8, 9, each once, any ordermalformed: AkamaiError::Setting; a repeated id: AkamaiError::DuplicateSetting (http2’s SettingsOrderBuilder would drop it silently); any other id (GREASE, experimental): SpecError::UnsupportedSetting (wreq-proto 0.2.6 Http2Options exposes no experimental settings); values against RFC 9113 6.5.2 (push or connect-protocol not 0/1, window above 2^31-1, frame size outside 2^14..2^24-1): SettingValue; a setting the string omits but wreq always sends (the probe tells): SettingAlwaysSent
WINDOW_UPDATE0 (none) up to 2^31-1-65535SpecError::WindowUpdate
PRIORITY s:e:d:w,... or 0odd stream ids, e 0/1, weight 1..=256, d != s; sets initial_stream_id above the largestSpecError::Priority { stream, reason }
pseudo ordera permutation of m,a,s,pAkamaiError::PseudoOrder (PseudoOrderBuilder::build would pad a partial list)
not in the stringHEADERS priority flags, header order, Accept-Encodingtaken from the base Http2Spec/HeaderSpec

3.9 Measured tables (domain/wire/emulation.rs, WP11)#

Only the local Mac is measured: Chrome on macOS by code through sb_cdp, Safari on macOS when the user opens the capture URL in it. Windows and Linux entries appear only when someone runs the capture tools there (capture_hints also prints a console snippet for machines without Rust); until then get returns NotMeasured and ImpersonationProfile::random picks measured OSes only. Client hints reuse ClientHints: brand order and GREASE brand checked against randomize::chromium_hints(major), the real full version, the per-OS platformVersion.

3.10 impersonation_client (impersonate/{convert,client}.rs, WP12)#

convert.rs alone names wreq types: TlsSpec -> wreq::tls::TlsOptions (cipher_list, curves_list, sigalgs_list, extension_permutation, permute_extensions, grease_enabled, key_shares, alpn_protocols, alps_*, certificate_compressors, record_size_limit, enable_ech_grease, enable_ocsp_stapling, enable_signed_cert_timestamps, preserve_tls13_cipher_list); Http2Spec -> wreq::http2::Http2Options (settings_order, headers_pseudo_order, headers_stream_dependency, priorities, initial_stream_id, windows); HeaderSpec -> OrigHeaderMap. UA, Sec-CH-UA*, Accept-Language and Cookie are set per request after emulation. Verification stays on; tests trust a root via tls_cert_store, never tls_verify_hostname(false).

3.11 HttpSession (impersonate/http_session.rs and http_session/*, WP14) — as built#

The sketch below is the code’s public surface at 4184e74. HttpSession is Clone (an Arc over the wreq client, the profile, a jar behind a mutex, the egress, the limits, a per-site pacer, the sink, the clock and the protocol each origin settled on).

What differs from the first sketch, and what the code does:

  • Judging is part of the session. assess, fetch and preflight apply MitigationPolicy::decide, SessionPolicy::before_fetch and after_fetch; the plan’s Fetched for a session is therefore the { reply, verdict } pair of HttpSession::fetch, and the dual-mode session returns StealthFetch (§3.13). HttpOptions, Attempt, Rules and Verdict are new types.
  • Redirects by hand (10 hops by default, 30 at most; Set-Cookie stored and Cookie sent per hop; 303 to GET; every header the caller set is dropped on a hop to another origin except Accept and Content-Type). A redirect is followed where it points, a private address included.
  • Limits are named constants in http_session::options: total time 30 s (at most 1 h), connect 10 s, reply body 64 MiB after decompression (at most 1 GiB), request body 64 MiB, per-site pacing optional.
  • Owned headers. User-Agent, Sec-CH-UA*, Accept-Language, Accept-Encoding, Cookie and priority, and the framing headers, are refused with ReservedHeader when the request is sent.
  • Page handoff. from_page reads navigator.userAgent, navigator.languages, Browser.getVersion, Network.getAllCookies and the proxy of the page’s browser context. It refuses another Chrome major (VersionMismatch, judged on both the user agent and Browser.getVersion), a browser that is not Chrome, a user agent that says HeadlessChrome, and a system that was not measured (Impersonate).
  • The first request to an origin is written for HTTP/2, so an HTTP/1.1-only origin gets the HTTP/2 priority header once; the session then remembers the origin and omits it. Only the second half is asserted by a test.
  • wreq errors are reduced to a fixed word (kind); their text, which can name the URL and the proxy password, is dropped. Credentials in a URL are refused.

3.12 MITM proxy (impersonate/{tls_server/*, mitm/*}, WP10 and WP15) — as built#

The CA (WP10) is in impersonate::tls_server; the proxy (WP15) is in impersonate::mitm.

SniMismatch of the first sketch is TlsServerError::SniMismatch, reached as MitmError::Tls(..). The variants after Egress are what the proxy logs when it refuses a request; the browser only sees an empty 407, 400, 403, 404 (on the priming host only), 413, 431 or 502.

Differences from the first sketch that matter:

  • WebSocket is relayed, as the plan sketched, after WP15 (mitm/websocket.rs). A GET with Upgrade: websocket and Connection: upgrade inside a CONNECT tunnel takes the ordinary checks (token, destination, Host, user agent) and goes upstream over HTTP/1.1 through a second wreq client built from the measured tls_websocket spec minus its known gaps (ALPN http/1.1 only), with the same egress, PublicOnly resolver, roots and timeouts as the ordinary client. On the wire its JA4 is t13d1515h1_8daaf6152771_0a20fe35d3a5: Chrome’s measured t13d1516h1_8daaf6152771_5b37b36a67c0 without the trust-anchors extension and the ML-DSA signature algorithms (the known gaps), which is what the_upstream_hello_is_the_measured_websocket_hello_minus_exactly_the_known_gaps asserts. The browser’s header order is kept without the hop-by-hop fields; the proxy writes Connection: Upgrade and Upgrade: websocket itself, and the handshake fields the measured lists lack get Chrome’s spelling (Sec-WebSocket-Key and the rest). The upstream 101 is relayed with its own headers; the Sec-WebSocket-Accept check is left to the browser. A 101 that switches to another protocol is an empty 502; any other answer reaches the browser as an ordinary answer. The bytes are then copied both ways with tokio::io::copy_bidirectional in the browser connection’s task (so the accept loop’s JoinSet still owns everything), bounded by websocket_idle_timeout (300 s, no byte either way) and max_websocket_bytes per direction (512 MiB; the read that crosses it is not passed on), and both sides are shut down together within one 5 s grace (common::shutdown::cleanup_within) when one closes, a bound is hit or the proxy stops. Every other upgrade is an empty 502; half an upgrade, another method, HTTP/1.0 or a body is an empty 400 (MalformedUpgrade); a handshake outside a tunnel is an empty 502. A custom profile’s WebSocket connections send its base browser’s measured WebSocket hello; a profile without one gets a 502 for every handshake. Tests: tests/impersonate_mitm/websocket.rs (an echo site written in the test, the measured WebSocket JA4 on the wire, the hostile inputs) and, on headless Chrome 155, a_page_opens_a_websocket_through_the_proxy_and_receives_its_echo and a_plain_ws_websocket_goes_through_the_proxy_to_its_site in tests/impersonate_mitm_chrome.rs.
  • ws:// is relayed in plain text, the handshake only (mitm/sniff.rs, forward::forward_plain). Measured on Chrome 155 with a recording proxy: a ws:// WebSocket reaches an HTTP proxy as CONNECT host:port (port 80 when the URL names none), never as an absolute-form GET, and the tunnel then carries the handshake in plain text, with the wss:// fields in the same order (Origin is the page’s, null on a blank tab). The proxy reads the first 4 bytes of every tunnel and puts them back: 0x16 (a TLS record) goes to the TLS leg as before; GET is served as HTTP/1.1 with the same head limits, and a WebSocket handshake in it goes through forward exactly as a wss:// one (token at the CONNECT, destination filter at the CONNECT and in the resolver, Host against the target with the http default port, user-agent major, header rewrite, the relay bounds), upstream as http:// on a plain connection, so no ClientHello applies. Any other plain-text request is an empty 502 (400 for half an upgrade) and closes the tunnel; first bytes that are neither close it unanswered and count as refused. This keeps the intercept-only rule: nothing is relayed before the proxy has parsed and checked a WebSocket handshake and the upstream has answered 101, which is also when wss:// bytes start to be copied. tunnels still counts TLS legs only; a ws:// WebSocket counts in websockets. Not tested: a ws:// WebSocket through an egress (the plain handshake leaves the way any http:// request does, so an HTTP egress has to relay the upgrade itself).
  • The priming page (closes the limit that a WebSocket could not be a tab’s first request). Measured on Chrome 155 (headless): a WebSocket’s CONNECT that gets the 407 is never surfaced as Fetch.authRequired, so ProxyAuth cannot answer it and Chrome does not retry it; credentials Chrome was given for any tab’s request are cached for the whole browser context and sent preemptively on every later CONNECT, WebSockets included (a navigation in one tab, then a WebSocket as another tab’s first request, goes through); a browser context created afterwards has a cache of its own and does not inherit the default context’s. So the proxy owns https://sb-mitm.invalid/<128 random bits> (PRIME_HOST under the reserved .invalid, MitmProxy::prime_url): after the token check a GET of exactly that is answered by the proxy with an empty, no-store HTML page (MitmStats::primed); anything else for that host, Chrome’s favicon.ico included, is an empty 404; nothing for it goes upstream or to the event sink. configure_launch makes it the start page unless the builder already has one, so Browser::launch loads it through the 407 and ProxyAuth before returning; a context behind the proxy loads it in its first tab. A browser-level answer was tried and is not possible: with Fetch.enable { handleAuthRequests: true } sent to the browser target as well, no Fetch.authRequired (nor any Fetch event) arrives there for the WebSocket’s 407, and the WebSocket still fails (measured on Chrome 155). Measured as well: the default tab’s history.length after the priming page and one navigation is 2, as without it (the start page replaces the initial about:blank). Tests: a_websocket_as_a_new_tabs_first_request_through_the_proxy_gets_its_echo and a_context_behind_the_proxy_takes_the_credentials_from_the_priming_page (Chrome), and the priming tests in tests/impersonate_mitm/access.rs.
  • A request whose User-Agent names another Chrome major than the profile’s is refused (MitmError::VersionMismatch), because the measured handshake would contradict the header.
  • The first request to an HTTP/1.1-only origin carries the HTTP/2 priority header: the upstream protocol is settled by ALPN after the request was prepared. The proxy remembers the origin and omits the header from then on (asserted: an_http1_origin_gets_the_measured_spelling_and_no_priority_once_known).
  • Options and bounds (max_connections 1 to 4096, default 256; request head 8 KiB to 1 MiB, default 64 KiB; headers 1 to 1024, default 128; body default 512 MiB; idle 60 s, handshake 10 s, connect 30 s, read 60 s; WebSocket idle 300 s and 512 MiB per direction) are checked by start.
  • StealthSession starts the proxy lazily, the first time a browser is needed (§3.13).

Security rules, each with a WP15 test:

  • CA: P-256, critical CA:TRUE, pathlen:0, keyCertSign|cRLSign, random serial, per process, never on disk. Leaves: one shared per-process key, SAN exactly the CONNECT host (DNS or IP via url::Host::parse, no wildcard, max 253 chars), EKU serverAuth, short validity. The server presents the leaf alone (no CA certificate in the chain).
  • Chrome gets --ignore-certificate-errors-spki-list=<leaf pin> (honoured only with --user-data-dir, which browser_args always passes), never --ignore-certificate-errors, and never the CA pin. What the switch trusts, read from Chromium’s services/network/ignore_errors_cert_verifier.cc: Chrome hashes the SPKI of every certificate in the presented chain and, when one hash is in the list, accepts the connection with no other check (no signature, host name, validity or revocation). So it trusts any chain that contains a certificate with the pinned public key, from any sender; the key is public (every handshake carries it), so “this CA and its leaves” would be a wrong description. The acceptance is now measured on Chrome 155 (headless, macOS): tests/impersonate_mitm_chrome.rs::without_the_proxy_the_leaf_pin_alone_trusts_a_chain_carrying_the_leaf_key starts Chrome with the pin and no proxy and it loads a page from a loopback server whose chain is its own self-signed certificate followed by the proxy’s leaf. That test does not isolate each skipped check (signature, host name, validity, revocation), which stay as read in the source. Narrowing it is done by pinning only the leaf key and sending the leaf alone, so the CA key and an intermediate are no second way in. The rest is WP15’s duty: every connection of that Chrome profile must end at the MITM (--proxy-server with --proxy-bypass-list=<-loopback> so loopback hosts are intercepted too, --disable-quic), a CONNECT the MITM cannot intercept is refused rather than tunnelled blind, and no leaf certificate is logged or written into a capture artifact. Its real-Chrome test, a_loopback_server_carrying_the_pinned_key_is_never_served_to_the_page: behind the MITM, a loopback server whose chain carries the pinned leaf key is never served to the page (the request goes through the MITM, whose upstream verification refuses it, and the page sees a 502).
  • Binds 127.0.0.1:0 only. A random 128-bit token (ring::rand::SystemRandom) is required as Proxy-Authorization: Basic on every CONNECT and absolute-form request (constant-time compare, else 407); Chrome gets it through ProxyAuth, so it is never on Chrome’s command line.
  • SNI must equal the CONNECT host or the connection closes. The intercepted leg offers ALPN http/1.1.
  • Stripped before replay: Connection and its listed tokens, Keep-Alive, Proxy-Connection, Proxy-Authorization, Proxy-Authenticate, TE (but trailers), Trailer, Transfer-Encoding, Upgrade, and Host (the upstream client writes it). On a WebSocket handshake the proxy writes the Connection: Upgrade and Upgrade: websocket pair itself, in the browser’s places for them. Chrome’s header order is kept; the h2-only priority header comes from the measured table.
  • Upstream verification always on (webpki roots, or only the roots added with trust_upstream_root); failure answers an empty 502. The upstream client follows no redirects, decompresses nothing (so Content-Encoding passes through) and sets no default headers: the browser’s own go out.
  • Bodies stream both ways, never buffered whole; bounds: 256 connections, 64 KiB request head, named idle and connect timeouts. A relayed WebSocket has its own idle and byte bounds (see above).
  • swap_upstream builds the new client first, then swaps an RwLock<Arc<Upstream>>; in-flight requests keep the old one. Logs carry RedactedUrl/EgressKey only; Drop uses shutdown::spawn_cleanup.

3.13 BrowserScraper and StealthSession (WP13, WP16) — StealthSession as built#

The BrowserScraper sketch is the first design; the deviations section at the top lists what the code added (ScrapeError::{CoolingDown, Navigation, NoEgressAvailable} and more builder settings). The StealthSession block further down is the code’s surface at 4184e74.

Deviations of StealthSession from the sketch: fetch returns StealthFetch (the plan’s Fetched is the { reply, verdict } of HttpSession::fetch); the plan’s .profile() and .mitm() are fingerprint(..) and mitm_options(..); StealthError has no Spec-style variant of its own (Unsolved, GaveUp and CoolingDown arrive as StealthError::Scrape); the attempts default to 4, one more than a browser-only scraper (DEFAULT_ATTEMPTS: one load meets the challenge over HTTP, one solves it in the browser, one uses the clearance over HTTP); and the MITM proxy is started lazily, the first time a browser is needed (at build() when the policy prefers the browser), and lives until close(), so its port and its certificate stay the same across browsers.

fetch: (1) site_of, DomainState, per-site pacing, a site in cooldown is not requested. (2) before_fetch; escalate = start the MITM proxy if it is not running (or point it at the identity’s profile with swap_profile), launch Chrome with the identity’s fingerprint and cookies through configure_launch; demote = take the cookies out of Chrome, HttpSession::from_identity on the same egress, close Chrome. (3) Fetch, detect. (4) decide, then apply Solve, Escalate, RotateEgress (pick_other, swap_upstream, new identity.egress, clearances dropped: they belong to the old IP), RotateProfile, Cooldown, Retry or GiveUp, bounded by max_challenge_attempts. (5) Record state; after a solve, demote if demote_after_solve. Every transition emits ModeChanged. Both transports share one egress; restore refuses an identity with another (EgressMismatch). rotate_profile: new seed, randomized_with_major, Chrome relaunched behind the same MITM port and egress, cookies and clearances dropped, DomainState kept. A launched browser lives until we close it: no idle timeout, never Instant + Duration::MAX, lifetimes are named constants.

3.14 Hardening hooks (WP17)#

stealth::providers::session_registry(fp: &Fingerprint) -> EvasionRegistry: the default registry without PluginsProvider and ConnectionProvider; a test checks the assembled script never names plugins, mimeTypes, pdfViewerEnabled or connection. ClientHintsProvider returns early when !isSecureContext. sb_cdp::identity sends the screen through Emulation.setDeviceMetricsOverride { width: 0, height: 0, screenWidth, screenHeight, ... }. stealth::cdp::enable_default_domains stops sending Runtime.enable.

4. Tests and gates#

4.1 Wire proofs#

  • TLS: TlsCapture binds 127.0.0.1:0, reads until ClientHello::needed_len (bounded size and timeout), recomputes JA3 (GREASE removed) and JA4. Clients dial https://capture.test:<port> via wreq ClientBuilder::resolve or Chrome --host-resolver-rules=MAP capture.test 127.0.0.1:<port>, so SNI is real.
  • HTTP/2, our client: H2cCapture with http2_only() (prior knowledge, plain TCP) reads the preface, SETTINGS, WINDOW_UPDATE, PRIORITY and first HEADERS, answers SETTINGS + ACK + :status 200 (HPACK 0x88), and computes the Akamai string, HEADERS priority and pseudo order.
  • HTTP/2, real browser: capture_h2 does the same behind tls_server::acceptor (ALPN h2); --h1 (ALPN http/1.1) records header names, values and order in plaintext.
  • Oracle (ignored, when tshark >= 4.2 is on PATH): captured bytes wrapped in a pcap; tshark’s tls.handshake.ja3_full and tls.handshake.ja4 must equal ours.

4.2 Per module#

ModulePure unit tests (in file)Wire / integration#[ignore] real Chrome or network
redact, egressuserinfo and sensitive query values never survive Debug, Display, Serialize (seeded loop of random secrets, humanize::Rng)--
geo, proxy_poolCountryCode validation; every LOCALES entry parses; REGIONS derived from it; strategies deterministic with injected now; quarantine and release; sticky per site-accept_language() equals Chrome’s header for several --lang values
challenge, mitigationfixture table (kind + vendor per fixture); cf-ray-only and bare-Turnstile negatives give None with evidence; Retry-After; scan cap; the whole decision table-known-challenge pages confirm the markers
state, json_storeround trip; missing = empty; corrupt = Corrupt; leftover temp file ignored; 0600 on Unix--
cookie_jar§5.1.1 dates, §5.1.3 domain-match, §5.1.4 paths, §5.2 attributes, §5.3 public suffix (co.uk, github.io), IP-literal rule, host-only, prefixes, Secure overwrite, Max-Age precedence, 400-day cap, §5.4 order, bounds, CDP round trip-the same Set-Cookie table served to Chrome: Network.getAllCookies must match our jar
identity, session_policy, eventsserde round trip; fingerprint.proxy cleared; serialized identity and events never contain a test secret; check_egress; transitions incl. the 120 s margin--
wire (client_hello, ja3, ja4, akamai, h2_frames, specs)parse committed capture bytes; GREASE; canonical strings; every Ja3Error, AkamaiError and SpecError case; truncated and hostile input (seeded mutation loop) never panics-tshark oracle
convert, client-tests/impersonate_wire.rs: the measured Chrome profile’s captured JA4 equals the measured JA4 except exactly the KnownGaps; ALPN, SNI and key-share groups equal; bare wreq differs; custom JA3 strings round trip exactly; Akamai over h2c round trips exactly; HEADERS priority and h1 header order equal the spec; rejected specs never build; capability probeour client and local Chrome captured side by side
http_session-redirects with Set-Cookie per hop, body cap, reserved headers, version mismatch, sync-back to a Browser::new_mocked page, against a local tls_server site trusted through tls_cert_storepage-to-HTTP handoff with real Chrome
mitm-407 without the token; Proxy-Authorization and hop-by-hop headers never reach the upstream; SNI mismatch closed; bad upstream cert gives 502; 64 MiB body streamed without full buffering; swap_upstream mid-session; leaf cache bounded; no key file written; the forwarded request’s JA4 and Akamai equal the profile’sChrome through the MITM loads a local h2 site; a different CA is rejected
browser_scraperfetch, mitigate and rotate flows on Browser::new_mocked-an idle browser is still usable after a named idle window; Turnstile demo page
stealth_sessionescalate/demote state machine on mocked transports, injected clock for near-expiryrestore refuses another egress; every transition emits ModeChangedfull flow against a local fake-challenge server; a live Cloudflare demo page (network)
hardeningsession_registry content; sb_cdp_domain_hygiene.rs: scraper flows on MockCtrl, calls_to of Runtime/DOM/Log/Debugger/Profiler.enable is empty-stealth_audit_chrome.rs: navigator.webdriver false, patched getters print [native code], WebGL/Canvas agree with the profile, no userAgentData on an http: page, screen equals the profile; stealth_audit_network.rs: CreepJS, SannySoft

#[ignore] tests never run in CI; real-Chrome binaries run one at a time with --test-threads=1.

4.3 Gates (definition of done for every package)#

Never --all-features (it pulls gui, tui, rustwright); --all-targets clippy runs once, in WP19. Review checklist: owned files only, no unsafe, files < 800 lines, functions < 50, named constants, no secret in any Debug/log/event, tracing::instrument(skip_all) on async pub fns.

5. Work packages#

Models: haiku = mechanical or high-volume, sonnet = primary implementation, opus = security-sensitive design and audits. Size: S up to 400 lines with tests, M 400-900, L 900-1600. Build: check, test, chrome (needs the local browser), B (impersonate lane, BoringSSL). Every package needs a build.

WPScope (files in §2.1)Builds on (existing)NeedsSizeModelBuild
0Scaffold: Cargo.toml, features, stubs, pub(crate) mod sync, lint block, purity test, justfile (scrape-gate, imp-gate, measure), CI (scrape in the matrix; a separate Linux impersonate job with cmake)lib.rs lint block, deny.toml-Shaikutest + one B build (records time, peak RSS, disk)
1redact, egress, geo, proxy_pool; randomized_with_major; ClientHints.wow64/form_factors; REGIONS from LOCALESsb_cdp::Proxy, Fingerprint, randomize.rs, humanize::Rng, sync::locked0Msonnettest
2challenge, markers, mitigation, fixturessb_cdp::Captcha, RetryPolicy0Msonnettest
3state, json_store, atomic_writeSessionStore pattern, RateLimiter1, 2Ssonnettest
4cookie_jar, set_cookiesb_cdp::{Cookie, SameSite}, url, chrono, psl0Mopustest
5wire ids.rs (IANA names, capability seeds)btls 0.5.6 constants0Shaikucheck
6client_hello, ja3, ja4, tls_specring::digest5Lsonnettest
7akamai, h2_frames, h2_spec, header_spec-0Msonnettest
8identity, session_policy, events, tracing_sink, identity_fileFingerprint, ClientHints, OsType, WP1-4 types1, 2, 3, 4Msonnettest
9capture.rs (TLS, h2c, h1 readers), capture_tls, capture_hints; first Chrome measurement (TLS + hints JSON, raw bytes)Browser, LaunchOptionsBuilder::{arg, lang}, Page::evaluate, Browser::execute6, 7Msonnettest + chrome
10tls_server: EphemeralCa, leaf cache, acceptor, SPKI pinsbtls, tokio-btls, base64, ring::rand0MopusB
11capture_h2 (+ --h1), emulation.rs, Chrome H2/header JSON; Safari JSON (user opens the URL)WP9 capture, WP10 acceptor7, 9, 10MsonnetB + chrome
12convert, client, CustomProfileBuilder, capability probe, tests/impersonate_wire.rs; fixes ids.rs flagswreq TlsOptions, Http2Options, OrigHeaderMap5, 6, 7, 11LsonnetB
13error.rs; adapters cdp, browser_scraper, egress_switch; ContextRoute port from the worktreeBrowser, Page, BrowserContext, ContextOptions::proxy, Cookies, Page::{solve_captcha, human, apply_fingerprint, events}, poll_until, RateLimiter, shutdown1, 2, 3, 8Lsonnettest + chrome
14http_session (salvage the worktree draft, drop wreq-util)WP4 jar, WP12 client, WP13 adapters::cdp4, 8, 12, 13MsonnetB
15mitm (connect, forward, upstream), tests/impersonate_mitm.rsProxyAuth, LaunchOptionsBuilder::proxy, shutdown, hyper1, 8, 10, 11, 12LopusB + chrome
16stealth_session, tests/stealth_session.rsWP13, WP14, WP15, WP1713, 14, 15, 17LsonnetB + chrome
17hardening: stealth/cdp.rs, providers/{mod,builtin}.rs, sb_cdp/identity.rs, hygiene and audit testsMockCtrl::calls_to, EvasionRegistry0Msonnettest + chrome
18split-role security review. Pass 1 after WP4, 8, 10 (cookies, identity files, CA); pass 2 after WP14-16 (MITM, credentials, egress stickiness); audit, deny, ripsecrets; may patch only a reviewed package’s files after it merged-see scopeMopusB
19re-exports in scrape/mod.rs, impersonate/mod.rs, lib.rs; docs/tutorials/stealth_scraping.md; fix fingerprint_stealth.md lines 529-530 (“not implemented”, “consider wreq-util”); ROADMAP; full --all-targets clippy per feature, doctests, audit, deny-allShaikutest + B

Schedule: at most two builds at once, CARGO_BUILD_JOBS=1, two shared target dirs: target-scrape (lane A) and target-imp (lane B, the only place BoringSSL compiles). Cargo’s dir lock serialises builds per lane, so at most two compilations run at once; real Chrome runs one test binary at a time across both agents (waves 7-8 take turns). Delete both dirs after WP19.

Wave123456789101112
Agent 1WP0WP1 (A)WP2 (A)WP5 (A)WP6 (A)WP8 (A)WP9 (A, chrome)WP13 (A, chrome)WP13WP14 (B)WP16 (B)WP18 pass 2
Agent 2-WP10 (B)WP4 (A)WP7 (A)WP3 (A)WP17 (A, chrome)WP18 pass 1WP11 (B, chrome, Safari by the user)WP12 (B)WP15 (B)-WP19 (after pass 2 fixes)

Every work package names its file list; a package that needs a file it does not own stops and reports instead of editing it.

6. Risks, open questions, not copied#

Cannot be done yet with btls 0.5.6 / wreq rc.31:

  • Extension 0xca34 (trust anchors) needs btls#168 and wreq#1273 in a release. If the local Chrome sends it, our JA4 differs in extension count and c hash: recorded as KnownGap::TrustAnchorsExtension; the wire test asserts the difference is exactly the known gaps and fails once one disappears. Fix: bump both exact pins in one package, rerun the probe and just measure.
  • ML-DSA sigalgs 0x0904/0x0905/0x0906 cannot be emitted: a KnownGap for measured entries, UnsupportedSigalg for custom specs. Experimental/GREASE H2 settings: UnsupportedSetting.
  • Chrome permutes extensions per connection, so a preset’s JA3 varies by design (JA4 is stable); a custom JA3 pins the order and matches exactly.

Risks:

  • Version drift: once the local Chrome updates past the measured major, HttpSession and the MITM refuse (VersionMismatch) until just measure reruns; an ignored test flags it.
  • Only macOS is measured until Windows/Linux captures exist, so other personas cannot use HTTP mode or the MITM. Deliberate: no reasoned values.
  • The h1 intercepted leg drops Chrome’s h2-only priority header; the MITM re-adds the measured value. Any other h1/h2 difference WP11 finds must go into HeaderSpec, or it leaks.
  • ProxyAuth relies on Fetch interception (WP15 measures the cost); rc pins may change API (convert.rs is the only file naming wreq types).
  • BoringSSL needs cmake, a C/C++ toolchain and about 1 GB of target disk, and psl is a large generated crate; WP0 records time, peak memory and disk for both with one job.
  • No HTTP/3 in HTTP mode (wreq has none; Chrome behind a proxy skips QUIC too). CHIPS cookies and Critical-CH retries are not modelled. JsonStateStore is single-writer.
  • CA key material is freed, not guaranteed wiped (no zeroize in btls): acceptable for an ephemeral, loopback-only CA. A matching fingerprint does not make automation permitted; the tutorial says so.

Questions the plan raised, and the answers (2026-10-10):

  1. --remote-debugging-pipe needs fds 3/4 in the child, which std only offers through unsafe pre_exec (command-fds is unsafe inside). Accept that, or keep --remote-debugging-port=0? Answer: keep --remote-debugging-port=0. No unsafe; the pipe transport is deferred.
  2. Try ALPN h2 on the intercepted leg (Chrome’s own header block) instead of the recorded http/1.1? Answer: keep http/1.1 on the browser-to-proxy leg.
  3. Will you run the capture tools on Windows and Linux machines, and open the URL in Safari in WP11? Answer (user): macOS only for now. Windows, Linux and Safari personas stay refused with SpecError::NotMeasured until they are captured on those systems.
  4. v2: one MITM for many BrowserPool contexts, routed by per-context token (ProxyAuth::register)? Answer: later. One proxy per context for now.
  5. Accept peetprint or JA4_r strings as further custom inputs (they carry signature algorithms)? Answer (user): “custom inputs are for everything”. Every part of a profile can be supplied by the caller, layered over a measured base: JA3, JA4 and JA4_r, peetprint, Akamai HTTP/2 fingerprints, header names, order and values, the User-Agent, Accept-Language and the client-hint set. Each input is checked for what BoringSSL and wreq can emit; anything they cannot reproduce is refused with a named SpecError before a client exists, and every accepted input is proven by a wire round trip. WP6 and WP7 therefore also parse JA4_r and peetprint (WP6), and the builder in WP12 takes each of them.

Deliberately not copied: their code and names (CloudScraper is BrowserScraper/StealthSession); their measured values (Chrome 153, Safari 27); wreq-util and its presets, including the worktree draft’s CHROME_PROFILES; RedbStateStore and persistence; log sinks (we use tracing); headless_chrome and its idle timeout; the no_std domain check; a crate-wide #![forbid(unsafe_code)] (the new modules get it); --ignore-certificate-errors; any CA key on disk; built-in third-party IP-geolocation (port only); JA4+ beyond JA4 (JA4H, JA4S, JA4X… are under the FoxIO License); automatic planner and reviewer agents on every change.