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#
| WP | Scope | State |
|---|---|---|
| 0 | Scaffold, features, lint block, purity test | Merged |
| 1 | egress (redact, route, geo, pool), randomized_with_major | Merged |
| 2 | challenge (detect, markers, mitigation) | Merged |
| 3 | ledger::state, runtime::json_store | Merged |
| 4 | jar (cookie store, Set-Cookie) | Merged |
| 5 | wire::ids | Merged |
| 6 | wire::{client_hello, ja3, ja4, tls_spec} | Merged |
| 7 | wire::{akamai, h2_frames, h2_spec, header_spec} | Merged |
| 8 | passport (identity, policy), ledger::events, tracing sink, identity file | Merged |
| 9 | runtime::capture, capture_tls, capture_hints, first Chrome measurement | Merged |
| 10 | impersonate::tls_server | Merged |
| 11 | impersonate::capture_h2, wire::emulation, Chrome HTTP/2 and header measurement | Merged |
| 12 | impersonate::{convert, client}, CustomProfileBuilder, wire probe | Merged |
| 13 | scrape::error, runtime::{cdp, egress_switch, browser_scraper} | Merged |
| 14 | impersonate::http_session (HttpSession, HttpOptions, HttpRequest, HttpReply, HttpError, Attempt, Rules, Verdict, Fetched) | Merged |
| 15 | impersonate::mitm (MitmProxy, MitmOptions, MitmError, MitmStats) | Merged |
| 16 | impersonate::stealth_session (StealthSession, StealthSessionBuilder, StealthFetch, StealthError) | Merged |
| 17 | Hardening: session_registry, secure-context userAgentData, screen through the device metrics, no Runtime.enable | Merged |
| 18 | Split-role security review | Pass 1 and pass 2 reviews done; fixes in progress |
| 19 | Re-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 WP18 | In progress |
Deviations from the plan that the code made. The plan’s text above was not rewritten; read it with these:
SpecErroris split per module. The plan has oneSpecErrorthat holds every variant. In the codewire::tls_spec::SpecErrorholds the TLS variants,wire::h2_spec::SpecErrorthe HTTP/2 variants, and the variant that names an unmeasured browser and system,NotMeasured { kind, os }, lives inwire::emulation::EmulationError(withVersionNotMeasured,Parse,TooLarge,Inconsistent,HeadersandDuplicate).wire::emulation::SpecErroris an alias forEmulationError, so the plan’sSpecError::NotMeasuredis reachable under that name.ImpersonateErrorwraps each of them asTls,Http2andEmulation, next toJa3,Ja4,Peetprint,Akamai,Headers,Convert,VersionMismatch,Incomplete,Fingerprint,UnknownHeaderandBuild; there is no singleSpecvariant.KnownGaplives inwire::tls_spec, not inemulation.wire::emulationre-exports it. The plan’s name for the HTTP/2 spec,Http2Spec, is an alias ofH2Spec.BrowserKindhas no serde. The enum inwire/mod.rsderives onlyDebug,Clone,Copy,PartialEqandEq.EmulationRefinscrape::passport::identityserializes it through a private module that knows the one spelling,chrome, so a new variant breaks the build there until it is handled.Modelives inscrape::challenge::mitigation.scrape::ledger::stateandscrape::passport::policyimport it from there.- Chrome 155 sends a GREASE signature algorithm that the vendored BoringSSL does not write. It is not a
KnownGapvariant (those areTrustAnchorsExtensionandMlDsaSignatureAlgorithms). 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. ATlsSpecread from a hello drops GREASE signature algorithms. ScrapeErrorhas three more variants than §3.13 lists:CoolingDown,NavigationandNoEgressAvailable.BrowserScraperBuilderhas more settings than the sketch:mitigation,solve_timeout,clock,launcher(theBrowserLauncherport),fingerprintandsession_store.impersonatealso enablesbrotli, for the certificate compressor of the impersonated hello (Chrome offers brotli certificate compression).StealthSession::fetchreturnsStealthFetch, notFetched.FetchedisHttpSession::fetch’s{ reply, verdict }. TheHttpSessionsurface is larger than the sketch:HttpOptions,Attempt,Rules,Verdict,assessandpreflight, andHttpErrorhas more variants (§3.11).MitmErroris not the sketch’s. It hasBind,Tls,InvalidOptions,Random,Upstream,Egress,Unauthorized,BadTarget,NotIntercepted,HostMismatch,BodyTooLarge,VersionMismatch,UpstreamRequest,ForbiddenDestinationandMalformedUpgrade; the SNI mismatch isTlsServerError::SniMismatch(§3.12).- The MITM proxy relays WebSocket (
wss://only) with the measured WebSocket hello and refuses every other upgrade with an empty502(§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-listaccepts 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) bywithout_the_proxy_the_leaf_pin_alone_trusts_a_chain_carrying_the_leaf_keyintests/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. StealthSessiondefaults 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,scrapeandimpersonateand at the crate root; the two specSpecErrors areTlsSpecErrorandH2SpecError. The stealth layer is experimental and not a stable API.
0. Decisions#
- Nothing from
stealthscraper-rs,impersonate-rs,impcurl,curl-impersonate-cliorwreq-utilenters Cargo.toml, optional or not. Their numbers (Chrome 153, Safari 27) are hints. - Two new features; the default build gains no dependency and no C code.
scrape(pure Rust, adds onlypsl): domain layer, JSON state store, CDP browser leg, TLS and client-hint capture.impersonate = ["scrape", ...]: wreq=6.0.0-rc.31(BoringSSL throughbtls, needs cmake and a C/C++ toolchain), MITM proxy,HttpSession,StealthSession, HTTP/2 capture. - 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. - Custom JA3 and Akamai strings overlay a measured base. What BoringSSL or wreq’s HTTP/2 stack cannot
emit is refused with a named
SpecErrorbefore a client exists; what is accepted is proven by a wire round trip. - Hexagonal:
src/scrape/domainis pure (no tokio,std::fs/std::net/std::processor clock reads; functions takenow), enforced by asyntest that allows from sb_cdp only the data typesProxy,Cookie,SameSite,Captchaand thesync::lockedhelper. Adapters:src/scrape/adapters(std, tokio, sb_cdp) andsrc/impersonate(wreq, btls, hyper). Ports:StateStore,EventSink,GeoResolver,EgressSwitch. - One
thiserrorenum per module, mapped toSeleniumBaseError::Stealth { reason }at the crate boundary.tracingonly, with redacted fields (instrument(skip_all, fields(url = %redacted))). - New modules:
#![forbid(unsafe_code)], the pedantic lint blocksrc/lib.rsgivessb_cdp, files under 800 lines, functions under 50, named constants,.expect()only with a reason.
1. Feature map#
| # | stealthscraper-rs feature | Our home | Reused as is | New |
|---|---|---|---|---|
| 1 | Local MITM TlsSpoofingProxy: terminate the browser’s TLS, replay with the profile’s ClientHello and H2, hyper streaming, hot-swappable upstream | impersonate::mitm::MitmProxy, impersonate::tls_server | sb_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 |
| 2 | H2 SETTINGS and their order, connection window, HEADERS priority, pseudo-header order, header set and order | wire::{h2_spec, header_spec, emulation}, impersonate::convert | - | measured Http2Spec/HeaderSpec converted to wreq::http2::Http2Options and OrigHeaderMap |
| 3 | impersonation_client(&BrowserProfile) -> wreq::ClientBuilder with UA, Sec-CH-UA*, Accept-Language | impersonate::client | Fingerprint, ClientHints, BrandVersion, randomize::chromium_hints | ImpersonationProfile; identity headers set per request, after emulation |
| 4 | BrowserProfile::random() | ImpersonationProfile::random(seed) | Fingerprint::randomized and the randomize.rs machine and place tables | Fingerprint::randomized_with_major so the UA claims the measured major |
| 5 | BrowserKind picks the JA4/H2 source; fingerprint DB (hardware/software traits, JA4 suites) | wire::BrowserKind, wire::emulation | randomize.rs GPU tiers, screens, cores, memory | measured TLS/H2 entries, for_kind |
| 6 | CloudScraper builder (with_geo_proxies, proxy_strategy, with_max_challenge_attempts, with_state_store, with_event_sink), solve_challenge | scrape::adapters::browser_scraper::BrowserScraper (no MITM), impersonate::StealthSession (with MITM) | Browser, Page, BrowserContext, ContextOptions::proxy, Page::{solve_captcha, human}, Lease::save_session | builders, fetch/detect/mitigate loop, clearance wait |
| 7 | rotate_profile(): new fingerprint, same MITM port and egress | BrowserScraper::rotate_profile, StealthSession::rotate_profile | Browser::launch, LaunchOptionsBuilder::fingerprint, Page::apply_fingerprint | relaunch rules (§3.13) |
| 8 | Challenge detect and MitigationPolicy deciding an Action | domain::{challenge, markers, mitigation} | sb_cdp::Captcha (widget evidence), RetryPolicy::delay_for_attempt (backoff) | markers, Detection, decision table |
| 9 | Proxy pool with RotationStrategy | domain::{proxy_pool, egress} | sb_cdp::Proxy::parse (password-free Debug), stealth::humanize::Rng | strategies, health, quarantine |
| 10 | CountryCode, Locale table, GeoResolver port, locale led by the egress country and applied over CDP | domain::geo, adapters::cdp::apply_locale | randomize.rs REGIONS (moved into LOCALES), Page::apply_fingerprint, Emulation::{locale, timezone, geolocation} | CountryCode, LOCALES, StaticGeoResolver |
| 11 | DomainState + StateStore (memory; redb under persistence) | domain::state, adapters::json_store | SessionStore’s Arc<Mutex<BTreeMap>> shape, sb_cdp::sync::locked, RateLimiter (per-site pacing) | JsonStateStore (atomic file) instead of redb |
| 12 | ScraperEvent + EventSink (Noop, Log); credentials redacted before logs, events and state | domain::{events, redact}, adapters::tracing_sink | redacting Debug of Proxy, ProxyConfig, Cookie, Session | RedactedUrl, Secret, TracingEventSink, CollectingEventSink |
| 13 | Stealth injection JS (navigator, WebGL, Canvas, Audio) | stealth::providers (exists) | 29 builtin providers, SB_PREAMBLE native-shape helpers, Identity::of | session_registry() (§3.14) |
| 14 | Interactive solver (Turnstile click) | sb_cdp::captcha (exists) | Page::solve_captcha, Captcha::ALL | adapters::cdp::wait_for_clearance |
| 15 | Human behaviour (Bezier mouse, keystroke jitter) | exists | humanize::{bezier_mouse_path, keystroke_delays}, stealth::behavior, sb_cdp::Human | - |
| 16 | Dual-mode StealthSession/StealthIdentity; re-escalate on a fresh challenge or a near-expiry clearance | impersonate::{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 |
| 17 | Measurement tools (capture_fingerprint, capture_h2, capture_hints, emulation_roundtrip) | adapters::capture, impersonate::capture_h2, examples/capture_*.rs, tests/impersonate_wire.rs | LaunchOptionsBuilder::arg (--host-resolver-rules), Page::evaluate, Browser::execute("Browser.getVersion") | ClientHello and H2 frame readers, JA3/JA4/Akamai |
| 18 | Hardening: 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 transport | stealth::{cdp, providers}, sb_cdp::identity, audit tests | noise is already a pure function of (seed, index) | drop Runtime.enable in src/stealth/cdp.rs:61, hygiene and audit tests; pipe deferred (§6) |
| 19 | Long-lived browser (their idle-timeout bug) | BrowserScraper + test | no idle timeout anywhere (BrowserPool has only acquire_timeout) | idle-browser test, named lifetime constants |
| 20 | (ours) custom JA3 and Akamai strings | wire::{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;BrowserKindstays inwire/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.rsaresrc/wireandsrc/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).
3.5 Cookies, RFC 6265 with psl (domain/{cookie_jar,set_cookie}.rs, WP4)#
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 field | Accepted | Rejected, with error |
|---|---|---|
| version | 771 (0x0303) | anything else: SpecError::LegacyVersion |
| ciphers | ids 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 |
| extensions | ids 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 trip | 41 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 |
| groups | 29 x25519, 23 P-256, 24 P-384, 25 P-521, 4588 X25519MLKEM768, 25497 X25519Kyber768Draft00; MLKEM1024, P256Kyber768, ffdhe2048/3072 if the probe confirms them | 30 x448, brainpool, secp256k1, ffdhe6144/8192, others: UnsupportedGroup |
| point formats | 0, or empty when ext 11 is absent | anything else: PointFormats |
| not in JA3 | signature algorithms, ALPN, key shares, cert compression, versions, PSK modes come from the base spec or the builder | ML-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 part | Accepted | Rejected, with error |
|---|---|---|
SETTINGS id:value;... | ids 1, 2, 3, 4, 5, 6, 8, 9, each once, any order | malformed: 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_UPDATE | 0 (none) up to 2^31-1-65535 | SpecError::WindowUpdate |
PRIORITY s:e:d:w,... or 0 | odd stream ids, e 0/1, weight 1..=256, d != s; sets initial_stream_id above the largest | SpecError::Priority { stream, reason } |
| pseudo order | a permutation of m,a,s,p | AkamaiError::PseudoOrder (PseudoOrderBuilder::build would pad a partial list) |
| not in the string | HEADERS priority flags, header order, Accept-Encoding | taken 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,fetchandpreflightapplyMitigationPolicy::decide,SessionPolicy::before_fetchandafter_fetch; the plan’sFetchedfor a session is therefore the{ reply, verdict }pair ofHttpSession::fetch, and the dual-mode session returnsStealthFetch(§3.13).HttpOptions,Attempt,RulesandVerdictare new types. - Redirects by hand (10 hops by default, 30 at most;
Set-Cookiestored andCookiesent per hop;303toGET; every header the caller set is dropped on a hop to another origin exceptAcceptandContent-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,Cookieandpriority, and the framing headers, are refused withReservedHeaderwhen the request is sent. - Page handoff.
from_pagereadsnavigator.userAgent,navigator.languages,Browser.getVersion,Network.getAllCookiesand the proxy of the page’s browser context. It refuses another Chrome major (VersionMismatch, judged on both the user agent andBrowser.getVersion), a browser that is not Chrome, a user agent that saysHeadlessChrome, 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
priorityheader 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). AGETwithUpgrade: websocketandConnection: upgradeinside aCONNECTtunnel takes the ordinary checks (token, destination,Host, user agent) and goes upstream over HTTP/1.1 through a second wreq client built from the measuredtls_websocketspec minus its known gaps (ALPNhttp/1.1only), with the same egress,PublicOnlyresolver, roots and timeouts as the ordinary client. On the wire its JA4 ist13d1515h1_8daaf6152771_0a20fe35d3a5: Chrome’s measuredt13d1516h1_8daaf6152771_5b37b36a67c0without the trust-anchors extension and the ML-DSA signature algorithms (the known gaps), which is whatthe_upstream_hello_is_the_measured_websocket_hello_minus_exactly_the_known_gapsasserts. The browser’s header order is kept without the hop-by-hop fields; the proxy writesConnection: UpgradeandUpgrade: websocketitself, and the handshake fields the measured lists lack get Chrome’s spelling (Sec-WebSocket-Keyand the rest). The upstream101is relayed with its own headers; theSec-WebSocket-Acceptcheck is left to the browser. A101that switches to another protocol is an empty502; any other answer reaches the browser as an ordinary answer. The bytes are then copied both ways withtokio::io::copy_bidirectionalin the browser connection’s task (so the accept loop’sJoinSetstill owns everything), bounded bywebsocket_idle_timeout(300 s, no byte either way) andmax_websocket_bytesper 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 empty502; half an upgrade, another method, HTTP/1.0 or a body is an empty400(MalformedUpgrade); a handshake outside a tunnel is an empty502. A custom profile’s WebSocket connections send its base browser’s measured WebSocket hello; a profile without one gets a502for 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_echoanda_plain_ws_websocket_goes_through_the_proxy_to_its_siteintests/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: aws://WebSocket reaches an HTTP proxy asCONNECT host:port(port 80 when the URL names none), never as an absolute-formGET, and the tunnel then carries the handshake in plain text, with thewss://fields in the same order (Originis the page’s,nullon 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;GETis served as HTTP/1.1 with the same head limits, and a WebSocket handshake in it goes throughforwardexactly as awss://one (token at theCONNECT, destination filter at theCONNECTand in the resolver,Hostagainst the target with thehttpdefault port, user-agent major, header rewrite, the relay bounds), upstream ashttp://on a plain connection, so noClientHelloapplies. Any other plain-text request is an empty502(400for 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 answered101, which is also whenwss://bytes start to be copied.tunnelsstill counts TLS legs only; aws://WebSocket counts inwebsockets. Not tested: aws://WebSocket through an egress (the plain handshake leaves the way anyhttp://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
CONNECTthat gets the407is never surfaced asFetch.authRequired, soProxyAuthcannot 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 laterCONNECT, 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 ownshttps://sb-mitm.invalid/<128 random bits>(PRIME_HOSTunder the reserved.invalid,MitmProxy::prime_url): after the token check aGETof exactly that is answered by the proxy with an empty,no-storeHTML page (MitmStats::primed); anything else for that host, Chrome’sfavicon.icoincluded, is an empty404; nothing for it goes upstream or to the event sink.configure_launchmakes it the start page unless the builder already has one, soBrowser::launchloads it through the407andProxyAuthbefore returning; a context behind the proxy loads it in its first tab. A browser-level answer was tried and is not possible: withFetch.enable { handleAuthRequests: true }sent to the browser target as well, noFetch.authRequired(nor anyFetchevent) arrives there for the WebSocket’s407, and the WebSocket still fails (measured on Chrome 155). Measured as well: the default tab’shistory.lengthafter the priming page and one navigation is 2, as without it (the start page replaces the initialabout:blank). Tests:a_websocket_as_a_new_tabs_first_request_through_the_proxy_gets_its_echoanda_context_behind_the_proxy_takes_the_credentials_from_the_priming_page(Chrome), and the priming tests intests/impersonate_mitm/access.rs. - A request whose
User-Agentnames 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
priorityheader: 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_connections1 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 bystart. StealthSessionstarts 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 viaurl::Host::parse, no wildcard, max 253 chars), EKUserverAuth, 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, whichbrowser_argsalways passes), never--ignore-certificate-errors, and never the CA pin. What the switch trusts, read from Chromium’sservices/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_keystarts 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-serverwith--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 a502). - Binds
127.0.0.1:0only. A random 128-bit token (ring::rand::SystemRandom) is required asProxy-Authorization: Basicon every CONNECT and absolute-form request (constant-time compare, else 407); Chrome gets it throughProxyAuth, 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:
Connectionand its listed tokens,Keep-Alive,Proxy-Connection,Proxy-Authorization,Proxy-Authenticate,TE(buttrailers),Trailer,Transfer-Encoding,Upgrade, andHost(the upstream client writes it). On a WebSocket handshake the proxy writes theConnection: UpgradeandUpgrade: websocketpair itself, in the browser’s places for them. Chrome’s header order is kept; the h2-onlypriorityheader comes from the measured table. - Upstream verification always on (webpki roots, or only the roots added with
trust_upstream_root); failure answers an empty502. The upstream client follows no redirects, decompresses nothing (soContent-Encodingpasses 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_upstreambuilds the new client first, then swaps anRwLock<Arc<Upstream>>; in-flight requests keep the old one. Logs carryRedactedUrl/EgressKeyonly;Dropusesshutdown::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:
TlsCapturebinds127.0.0.1:0, reads untilClientHello::needed_len(bounded size and timeout), recomputes JA3 (GREASE removed) and JA4. Clients dialhttps://capture.test:<port>via wreqClientBuilder::resolveor Chrome--host-resolver-rules=MAP capture.test 127.0.0.1:<port>, so SNI is real. - HTTP/2, our client:
H2cCapturewithhttp2_only()(prior knowledge, plain TCP) reads the preface, SETTINGS, WINDOW_UPDATE, PRIORITY and first HEADERS, answers SETTINGS + ACK +:status 200(HPACK0x88), and computes the Akamai string, HEADERS priority and pseudo order. - HTTP/2, real browser:
capture_h2does the same behindtls_server::acceptor(ALPNh2);--h1(ALPNhttp/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’stls.handshake.ja3_fullandtls.handshake.ja4must equal ours.
4.2 Per module#
| Module | Pure unit tests (in file) | Wire / integration | #[ignore] real Chrome or network |
|---|---|---|---|
| redact, egress | userinfo and sensitive query values never survive Debug, Display, Serialize (seeded loop of random secrets, humanize::Rng) | - | - |
| geo, proxy_pool | CountryCode 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, mitigation | fixture 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_store | round 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, events | serde 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 probe | our 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_store | page-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’s | Chrome through the MITM loads a local h2 site; a different CA is rejected |
| browser_scraper | fetch, mitigate and rotate flows on Browser::new_mocked | - | an idle browser is still usable after a named idle window; Turnstile demo page |
| stealth_session | escalate/demote state machine on mocked transports, injected clock for near-expiry | restore refuses another egress; every transition emits ModeChanged | full flow against a local fake-challenge server; a live Cloudflare demo page (network) |
| hardening | session_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.
| WP | Scope (files in §2.1) | Builds on (existing) | Needs | Size | Model | Build |
|---|---|---|---|---|---|---|
| 0 | Scaffold: 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 | - | S | haiku | test + one B build (records time, peak RSS, disk) |
| 1 | redact, egress, geo, proxy_pool; randomized_with_major; ClientHints.wow64/form_factors; REGIONS from LOCALES | sb_cdp::Proxy, Fingerprint, randomize.rs, humanize::Rng, sync::locked | 0 | M | sonnet | test |
| 2 | challenge, markers, mitigation, fixtures | sb_cdp::Captcha, RetryPolicy | 0 | M | sonnet | test |
| 3 | state, json_store, atomic_write | SessionStore pattern, RateLimiter | 1, 2 | S | sonnet | test |
| 4 | cookie_jar, set_cookie | sb_cdp::{Cookie, SameSite}, url, chrono, psl | 0 | M | opus | test |
| 5 | wire ids.rs (IANA names, capability seeds) | btls 0.5.6 constants | 0 | S | haiku | check |
| 6 | client_hello, ja3, ja4, tls_spec | ring::digest | 5 | L | sonnet | test |
| 7 | akamai, h2_frames, h2_spec, header_spec | - | 0 | M | sonnet | test |
| 8 | identity, session_policy, events, tracing_sink, identity_file | Fingerprint, ClientHints, OsType, WP1-4 types | 1, 2, 3, 4 | M | sonnet | test |
| 9 | capture.rs (TLS, h2c, h1 readers), capture_tls, capture_hints; first Chrome measurement (TLS + hints JSON, raw bytes) | Browser, LaunchOptionsBuilder::{arg, lang}, Page::evaluate, Browser::execute | 6, 7 | M | sonnet | test + chrome |
| 10 | tls_server: EphemeralCa, leaf cache, acceptor, SPKI pins | btls, tokio-btls, base64, ring::rand | 0 | M | opus | B |
| 11 | capture_h2 (+ --h1), emulation.rs, Chrome H2/header JSON; Safari JSON (user opens the URL) | WP9 capture, WP10 acceptor | 7, 9, 10 | M | sonnet | B + chrome |
| 12 | convert, client, CustomProfileBuilder, capability probe, tests/impersonate_wire.rs; fixes ids.rs flags | wreq TlsOptions, Http2Options, OrigHeaderMap | 5, 6, 7, 11 | L | sonnet | B |
| 13 | error.rs; adapters cdp, browser_scraper, egress_switch; ContextRoute port from the worktree | Browser, Page, BrowserContext, ContextOptions::proxy, Cookies, Page::{solve_captcha, human, apply_fingerprint, events}, poll_until, RateLimiter, shutdown | 1, 2, 3, 8 | L | sonnet | test + chrome |
| 14 | http_session (salvage the worktree draft, drop wreq-util) | WP4 jar, WP12 client, WP13 adapters::cdp | 4, 8, 12, 13 | M | sonnet | B |
| 15 | mitm (connect, forward, upstream), tests/impersonate_mitm.rs | ProxyAuth, LaunchOptionsBuilder::proxy, shutdown, hyper | 1, 8, 10, 11, 12 | L | opus | B + chrome |
| 16 | stealth_session, tests/stealth_session.rs | WP13, WP14, WP15, WP17 | 13, 14, 15, 17 | L | sonnet | B + chrome |
| 17 | hardening: stealth/cdp.rs, providers/{mod,builtin}.rs, sb_cdp/identity.rs, hygiene and audit tests | MockCtrl::calls_to, EvasionRegistry | 0 | M | sonnet | test + chrome |
| 18 | split-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 scope | M | opus | B |
| 19 | re-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 | - | all | S | haiku | test + 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.
| Wave | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Agent 1 | WP0 | WP1 (A) | WP2 (A) | WP5 (A) | WP6 (A) | WP8 (A) | WP9 (A, chrome) | WP13 (A, chrome) | WP13 | WP14 (B) | WP16 (B) | WP18 pass 2 |
| Agent 2 | - | WP10 (B) | WP4 (A) | WP7 (A) | WP3 (A) | WP17 (A, chrome) | WP18 pass 1 | WP11 (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
chash: recorded asKnownGap::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 andjust measure. - ML-DSA sigalgs 0x0904/0x0905/0x0906 cannot be emitted: a
KnownGapfor measured entries,UnsupportedSigalgfor 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,
HttpSessionand the MITM refuse (VersionMismatch) untiljust measurereruns; 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
priorityheader; the MITM re-adds the measured value. Any other h1/h2 difference WP11 finds must go intoHeaderSpec, or it leaks. ProxyAuthrelies onFetchinterception (WP15 measures the cost); rc pins may change API (convert.rsis the only file naming wreq types).- BoringSSL needs cmake, a C/C++ toolchain and about 1 GB of target disk, and
pslis 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.
JsonStateStoreis 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):
--remote-debugging-pipeneeds fds 3/4 in the child, which std only offers throughunsafepre_exec(command-fdsis unsafe inside). Accept that, or keep--remote-debugging-port=0? Answer: keep--remote-debugging-port=0. Nounsafe; the pipe transport is deferred.- Try ALPN
h2on the intercepted leg (Chrome’s own header block) instead of the recordedhttp/1.1? Answer: keephttp/1.1on the browser-to-proxy leg. - 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::NotMeasureduntil they are captured on those systems. - v2: one MITM for many
BrowserPoolcontexts, routed by per-context token (ProxyAuth::register)? Answer: later. One proxy per context for now. - 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-Languageand the client-hint set. Each input is checked for what BoringSSL and wreq can emit; anything they cannot reproduce is refused with a namedSpecErrorbefore 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.