From 00a25ea60908b8ceec9d1ed3dd21ff9a62c495e3 Mon Sep 17 00:00:00 2001 From: Hanashi Date: Mon, 24 Aug 2026 01:39:24 -0400 Subject: [PATCH] store statsig --- Cargo.lock | 1 + .../grok_consumer_statsig/Cargo.toml | 1 + .../grok_consumer_statsig/src/lib.rs | 4 + .../grok_consumer_statsig/src/statsig.rs | 22 +++ .../src/statsig_material.rs | 149 ++++++++++++++++++ .../credentials/add_web_credential_command.rs | 28 ++-- .../credentials/artcraft_login_command.rs | 4 +- .../edit_web_credential_command.rs | 8 +- .../src/credentials/auth_credential.rs | 30 ++-- .../src/credentials/cookie_credential.rs | 19 +-- .../cookie_credential_grok_extra_pieces.rs | 37 +++++ crates/artcraftx/src/credentials/mod.rs | 1 + .../login_window/login_window_thread.rs | 6 +- .../utils/grok_statsig_capture.rs | 33 ++-- 14 files changed, 268 insertions(+), 75 deletions(-) create mode 100644 crates/api_clients/grok_consumer_statsig/src/statsig.rs create mode 100644 crates/api_clients/grok_consumer_statsig/src/statsig_material.rs create mode 100644 crates/artcraftx/src/credentials/cookie_credential_grok_extra_pieces.rs diff --git a/Cargo.lock b/Cargo.lock index 1a1f56d6..1534e97f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2634,6 +2634,7 @@ dependencies = [ "log", "serde", "serde_json", + "sha2 0.11.0-rc.5", "tao", "thiserror 2.0.18", "tokio", diff --git a/crates/api_clients/grok_consumer_statsig/Cargo.toml b/crates/api_clients/grok_consumer_statsig/Cargo.toml index 117db9f9..85844e41 100644 --- a/crates/api_clients/grok_consumer_statsig/Cargo.toml +++ b/crates/api_clients/grok_consumer_statsig/Cargo.toml @@ -30,6 +30,7 @@ chrono.workspace = true log.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +sha2.workspace = true thiserror.workspace = true tokio = { version = "1", features = ["sync", "time", "macros", "rt"] } toml.workspace = true diff --git a/crates/api_clients/grok_consumer_statsig/src/lib.rs b/crates/api_clients/grok_consumer_statsig/src/lib.rs index 80e0788e..1dacc38c 100644 --- a/crates/api_clients/grok_consumer_statsig/src/lib.rs +++ b/crates/api_clients/grok_consumer_statsig/src/lib.rs @@ -56,6 +56,8 @@ mod browser_context; mod mint_harness; mod minted_statsig; +mod statsig; +mod statsig_material; mod statsig_minter; mod statsig_oracle; mod statsig_request; @@ -68,6 +70,8 @@ pub mod harvest_via_webview; pub use browser_context::{BrowserContext, DEFAULT_USER_AGENT}; pub use mint_harness::MINT_HARNESS_SCRIPT; pub use minted_statsig::MintedStatsig; +pub use statsig::Statsig; +pub use statsig_material::StatsigMaterial; pub use statsig_minter::{MinterConfig, StatsigMinter}; pub use statsig_oracle::{StatsigError, StatsigOracle}; pub use statsig_request::StatsigRequest; diff --git a/crates/api_clients/grok_consumer_statsig/src/statsig.rs b/crates/api_clients/grok_consumer_statsig/src/statsig.rs new file mode 100644 index 00000000..68fc0487 --- /dev/null +++ b/crates/api_clients/grok_consumer_statsig/src/statsig.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// A concrete, ready-to-send `x-statsig-id`, minted for one endpoint at one +/// time from [`StatsigMaterial`](crate::StatsigMaterial). +/// +/// The embedded timestamp ages out, so a `Statsig` is short-lived — regenerate +/// per request rather than caching it for long. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Statsig { + /// The header value to send as `x-statsig-id`. + pub statsig: String, + + /// When it was generated (this is the timestamp baked into the signature). + pub generated_at: DateTime, + + /// The HTTP method the signature is bound to (uppercase). + pub method: String, + + /// The request path the signature is bound to. + pub path: String, +} diff --git a/crates/api_clients/grok_consumer_statsig/src/statsig_material.rs b/crates/api_clients/grok_consumer_statsig/src/statsig_material.rs new file mode 100644 index 00000000..ee9b7a2d --- /dev/null +++ b/crates/api_clients/grok_consumer_statsig/src/statsig_material.rs @@ -0,0 +1,149 @@ +//! [`StatsigMaterial`] — the reusable, per-session material an +//! `x-statsig-id` is minted from, plus the pure signature assembly. + +use crate::statsig::Statsig; +use base64::prelude::BASE64_STANDARD_NO_PAD; +use base64::Engine; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Unix-time reference the embedded `number` counts from (2023-05-01). +const STATSIG_EPOCH: i64 = 1_682_924_400; + +/// Salt folded into the signed message (best-known; absent from current bundles). +const STATSIG_SALT: &str = "obfiowerehiring"; + +/// Trailing marker byte of the assembled payload. +const STATSIG_MARK: u8 = 0x03; + +/// The reusable material an `x-statsig-id` is minted from, independent of the +/// endpoint. A signature binds `method`, `path`, and a timestamp — all +/// per-request — around a "genuine hex" fingerprint **derived from the seed**. +/// So the one thing worth storing across requests is the 48-byte **seed** +/// (decoded from a real captured signature): with it we can mint a fresh +/// signature for any endpoint via [`generate_statsig`](Self::generate_statsig), +/// no browser required — see the caveat there. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StatsigMaterial { + /// The 48-byte seed, base64 (standard alphabet, no padding). + pub seed_b64: String, +} + +impl StatsigMaterial { + pub fn from_seed_b64(seed_b64: impl Into) -> Self { + Self { seed_b64: seed_b64.into() } + } + + /// Mint a signature for `method` + `path` stamped at `generated_at`. + /// + /// # Caveat: needs the seed → hex fingerprint + /// + /// The signature folds in a "genuine hex" that Grok derives from the seed by + /// sampling a CSS-animated SVG in-browser, rotating the constants over time. + /// [`compute_genuine_hex`] — the headless port of that — is currently + /// unavailable (stale + out of scope to extract), so this returns `None`. + /// The *assembly* around it ([`assemble`]) is complete and tested, so the + /// moment a real fingerprint lands, on-the-fly generation works. + pub fn generate_statsig(&self, method: &str, path: &str, generated_at: DateTime) -> Option { + let seed = BASE64_STANDARD_NO_PAD.decode(self.seed_b64.trim()).ok()?; + let genuine_hex = compute_genuine_hex(&seed)?; + Some(assemble(&seed, method, path, generated_at, &genuine_hex)) + } +} + +/// Assemble the 70-byte payload from every input (pure, deterministic). +/// +/// Layout: `[key][seed^key (48)][number_LE^key (4)][sha256(msg)[..16]^key (16)][mark^key]`, +/// where `msg = METHOD!path!` and +/// `number = generated_at - epoch`. +fn assemble(seed: &[u8], method: &str, path: &str, generated_at: DateTime, genuine_hex: &str) -> Statsig { + let method = method.trim().to_ascii_uppercase(); + let path = path.trim(); + let number = generated_at.timestamp().saturating_sub(STATSIG_EPOCH).max(0) as u32; + + let message = format!("{method}!{path}!{number}{STATSIG_SALT}{genuine_hex}"); + let digest = Sha256::digest(message.as_bytes()); + + // Any byte works as the XOR mask; derive one deterministically from the seed + // and timestamp so output is reproducible. + let key = seed.first().copied().unwrap_or(0) ^ (number as u8); + + let number_bytes = number.to_le_bytes(); + let mut out = Vec::with_capacity(70); + out.push(key); + out.extend(seed.iter().map(|byte| byte ^ key)); + out.extend(number_bytes.iter().map(|byte| byte ^ key)); + out.extend(digest[..16].iter().map(|byte| byte ^ key)); + out.push(STATSIG_MARK ^ key); + + Statsig { + statsig: BASE64_STANDARD_NO_PAD.encode(out), + generated_at, + method, + path: path.to_string(), + } +} + +/// Grok's seed → "genuine hex" fingerprint. Currently unavailable: Grok derives +/// it in-browser by sampling a rotating CSS-animated SVG, and the headless port +/// is stale (extracting the live one is out of scope / blocked). Until this +/// returns a real value, [`StatsigMaterial::generate_statsig`] yields `None`. +/// This is the single missing piece — the assembly around it is done. +fn compute_genuine_hex(_seed: &[u8]) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED: [u8; 48] = [7u8; 48]; + const NEW_CONVERSATION: &str = "/rest/app-chat/conversations/new"; + + #[test] + fn assemble_produces_a_self_consistent_signature() { + let generated_at = DateTime::from_timestamp(1_787_535_100, 0).unwrap(); + let hex = "3bab9506b851eb85"; // any fingerprint; the assembly is what's under test + + let signature = assemble(&SEED, "post", NEW_CONVERSATION, generated_at, hex); + assert_eq!(signature.method, "POST"); + assert_eq!(signature.path, NEW_CONVERSATION); + + // Decode the payload and check every region reconstructs its input. + let raw = BASE64_STANDARD_NO_PAD.decode(&signature.statsig).unwrap(); + assert_eq!(raw.len(), 70); + let key = raw[0]; + let unmask = |slice: &[u8]| -> Vec { slice.iter().map(|b| b ^ key).collect() }; + + assert_eq!(unmask(&raw[1..49]), SEED); + + let number_bytes = unmask(&raw[49..53]); + let number = u32::from_le_bytes([number_bytes[0], number_bytes[1], number_bytes[2], number_bytes[3]]); + assert_eq!(number as i64, 1_787_535_100 - STATSIG_EPOCH); + + let digest = unmask(&raw[53..69]); + let expected = &Sha256::digest( + format!("POST!{NEW_CONVERSATION}!{number}{STATSIG_SALT}{hex}").as_bytes(), + )[..16]; + assert_eq!(digest, expected); + + assert_eq!(raw[69] ^ key, STATSIG_MARK); + } + + #[test] + fn same_seed_different_endpoints_differ() { + let at = DateTime::from_timestamp(1_787_535_100, 0).unwrap(); + let a = assemble(&SEED, "POST", "/a", at, "beef"); + let b = assemble(&SEED, "POST", "/b", at, "beef"); + assert_ne!(a.statsig, b.statsig, "different paths must sign differently"); + } + + #[test] + fn generate_statsig_is_none_until_fingerprint_available() { + // The seed is present, but the seed -> hex fingerprint is not, so no + // signature can be minted yet. + let material = StatsigMaterial::from_seed_b64(BASE64_STANDARD_NO_PAD.encode(SEED)); + assert!(material.generate_statsig("POST", NEW_CONVERSATION, Utc::now()).is_none()); + } +} diff --git a/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs b/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs index 2a31a880..f610edc6 100644 --- a/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs +++ b/crates/artcraftx/src/commands/credentials/add_web_credential_command.rs @@ -6,10 +6,11 @@ use crate::credentials::credential_user_info::CredentialUserInfo; use crate::credentials::service_cookie_origin::cookie_origin_for_service; use crate::error::artcraftx_error::ArtcraftXError; use cookie_store_wrapper::cookie_store::CookieStore; +use crate::credentials::cookie_credential_grok_extra_pieces::CookieCredentialGrokExtraPieces; use crate::state::data_dir::app_data_root::AppDataRoot; use crate::state::data_dir::subdirectory::app_credentials_dir::AppCredentialsDir; -use chrono::{Duration, Utc}; -use grok_consumer_statsig::statsig_cache_file::CapturedStatsig; +use chrono::Utc; +use grok_consumer_statsig::StatsigMaterial; use log::{error, info}; use serde_derive::Serialize; use tauri::State; @@ -23,8 +24,8 @@ pub struct WebCredentialSave { pub service: GenerationSource, pub cookies: CookieStore, pub maybe_user_info: Option, - /// Preemptively captured statsig pieces (Grok only; `None` for other sites). - pub maybe_statsig: Option, + /// Preemptively captured statsig material (Grok only; `None` for other sites). + pub maybe_statsig: Option, } /// Save a web-login (cookie) credential to the credentials directory. @@ -47,28 +48,21 @@ pub fn save_web_credential( let now = Utc::now(); - // A fresh capture stamps fetched/refresh times; without one, preserve - // whatever the existing credential already had. - let (statsig, statsig_fetched_at, statsig_refresh_at) = match save.maybe_statsig { - Some(captured) => ( - Some(captured), - Some(now), - Some(now + Duration::minutes(STATSIG_REFRESH_MINUTES)), - ), + // Fresh material stamps fetched/refresh times; without one, preserve whatever + // the existing credential already had. + let grok_data = match save.maybe_statsig { + Some(material) => Some(CookieCredentialGrokExtraPieces::fresh(material, now, STATSIG_REFRESH_MINUTES)), None => existing .as_ref() .and_then(|credential| credential.cookies()) - .map(|cookie| (cookie.statsig.clone(), cookie.statsig_fetched_at, cookie.statsig_refresh_at)) - .unwrap_or((None, None, None)), + .and_then(|cookie| cookie.grok_data.clone()), }; let cookie = CookieCredential { updated_at: Some(now), failed_at: None, succeeded_at: None, - statsig_fetched_at, - statsig_refresh_at, - statsig, + grok_data, cookies: save.cookies, }; diff --git a/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs b/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs index 2380d178..0db05b3f 100644 --- a/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs +++ b/crates/artcraftx/src/commands/credentials/artcraft_login_command.rs @@ -140,9 +140,7 @@ fn save_session_credential( updated_at: Some(now), failed_at: None, succeeded_at: Some(now), - statsig_fetched_at: None, - statsig_refresh_at: None, - statsig: None, + grok_data: None, cookies: CookieStore::from_cookie_header( &format!("session={}", signed_session), &cookie_origin), }; diff --git a/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs b/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs index 4eeb1a04..64e936d2 100644 --- a/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs +++ b/crates/artcraftx/src/commands/credentials/edit_web_credential_command.rs @@ -51,9 +51,7 @@ pub async fn edit_web_credential_command( let existing_cookies = existing_cookie.cookies.clone(); let existing_failed_at = existing_cookie.failed_at; let existing_succeeded_at = existing_cookie.succeeded_at; - let existing_statsig = existing_cookie.statsig.clone(); - let existing_statsig_fetched_at = existing_cookie.statsig_fetched_at; - let existing_statsig_refresh_at = existing_cookie.statsig_refresh_at; + let existing_grok_data = existing_cookie.grok_data.clone(); if let Some(new_cookie_header) = cookie_header { let new_cookie_header = new_cookie_header.trim().to_string(); @@ -69,9 +67,7 @@ pub async fn edit_web_credential_command( updated_at: Some(Utc::now()), failed_at: existing_failed_at, succeeded_at: existing_succeeded_at, - statsig_fetched_at: existing_statsig_fetched_at, - statsig_refresh_at: existing_statsig_refresh_at, - statsig: existing_statsig, + grok_data: existing_grok_data, cookies: new_cookies, }); } diff --git a/crates/artcraftx/src/credentials/auth_credential.rs b/crates/artcraftx/src/credentials/auth_credential.rs index 2cbbf8f7..8170ff95 100644 --- a/crates/artcraftx/src/credentials/auth_credential.rs +++ b/crates/artcraftx/src/credentials/auth_credential.rs @@ -99,19 +99,19 @@ impl AuthCredential { mod tests { use super::*; - // A cookie credential carrying captured statsig pieces must round-trip: the - // statsig table has to serialize before the `cookies` array-of-tables, or the - // TOML is invalid. + // A cookie credential carrying Grok statsig data must round-trip: the + // `grok_data` table has to serialize before the `cookies` array-of-tables, or + // the TOML is invalid. #[test] - fn cookie_credential_with_statsig_round_trips() { + fn cookie_credential_with_grok_data_round_trips() { use crate::credentials::cookie_credential::CookieCredential; - use chrono::{Duration, Utc}; + use crate::credentials::cookie_credential_grok_extra_pieces::CookieCredentialGrokExtraPieces; + use chrono::Utc; use cookie_store_wrapper::cookie_store::CookieStore; - use grok_consumer_statsig::statsig_cache_file::decode_statsig; + use grok_consumer_statsig::StatsigMaterial; use reqwest::Url; - let capture = "XbHvyYh7hmNc+8sVAGnrpcuHN/MK+mgN63cmnBljOJaoCKu6zTPVw6C7HVv1wculKCNmYVum+L1h0IjFmtyZD/C53M+ZXg"; - let statsig = decode_statsig(capture, "POST", "/rest/app-chat/conversations/new", 1_787_535_100).unwrap(); + let material = StatsigMaterial::from_seed_b64("7LKU1SbbPgGmlkhdNLb4ltpqrlenNVC2KnvBRD5ly/VV9ueQboie/eZABqiclvh1"); let mut cookies = CookieStore::empty(); cookies.insert_named("sso", "token-value", &Url::parse("https://grok.com/").unwrap()); @@ -119,9 +119,7 @@ mod tests { let now = Utc::now(); let mut cookie = CookieCredential::new(cookies); cookie.updated_at = Some(now); - cookie.statsig_fetched_at = Some(now); - cookie.statsig_refresh_at = Some(now + Duration::minutes(30)); - cookie.statsig = Some(statsig.clone()); + cookie.grok_data = Some(CookieCredentialGrokExtraPieces::fresh(material.clone(), now, 30)); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("xai_cookies.toml"); @@ -136,11 +134,11 @@ mod tests { credential.save().unwrap(); let loaded = AuthCredential::load_from_file(&path).unwrap(); - let loaded_cookie = loaded.cookies().unwrap(); - assert_eq!(loaded_cookie.statsig.as_ref(), Some(&statsig)); - assert!(loaded_cookie.statsig_fetched_at.is_some()); - assert!(loaded_cookie.statsig_refresh_at.is_some()); - assert!(loaded_cookie.cookies.has_cookie("sso")); + let loaded_grok = loaded.cookies().unwrap().grok_data.as_ref().unwrap(); + assert_eq!(loaded_grok.statsig_material.as_ref(), Some(&material)); + assert!(loaded_grok.statsig_fetched_at.is_some()); + assert!(loaded_grok.statsig_refresh_at.is_some()); + assert!(loaded.cookies().unwrap().cookies.has_cookie("sso")); } #[test] diff --git a/crates/artcraftx/src/credentials/cookie_credential.rs b/crates/artcraftx/src/credentials/cookie_credential.rs index 27951a1c..5d3701b7 100644 --- a/crates/artcraftx/src/credentials/cookie_credential.rs +++ b/crates/artcraftx/src/credentials/cookie_credential.rs @@ -1,6 +1,6 @@ +use crate::credentials::cookie_credential_grok_extra_pieces::CookieCredentialGrokExtraPieces; use chrono::{DateTime, Utc}; use cookie_store_wrapper::cookie_store::CookieStore; -use grok_consumer_statsig::statsig_cache_file::CapturedStatsig; use serde_derive::{Deserialize, Serialize}; /// A browser-cookie secret for a website integration. @@ -18,19 +18,10 @@ pub struct CookieCredential { #[serde(default, skip_serializing_if = "Option::is_none")] pub succeeded_at: Option>, - /// When the stored [`statsig`](Self::statsig) pieces were captured. Grok only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub statsig_fetched_at: Option>, - - /// When the statsig pieces should be re-captured (30 min after fetch). Grok only. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub statsig_refresh_at: Option>, - - /// Preemptively captured `x-statsig-id` pieces (Grok only). Decoded from a - /// real browser request during login via `grok_consumer_statsig`. + /// Grok-only extras: the reusable statsig material and its refresh schedule. /// NB: a table, so it must stay after all scalar fields (before `cookies`). #[serde(default, skip_serializing_if = "Option::is_none")] - pub statsig: Option, + pub grok_data: Option, /// The stored cookies with their RFC 6265 attributes. /// NB: Serialized as an array of tables, so this field must stay LAST for @@ -44,9 +35,7 @@ impl CookieCredential { updated_at: None, failed_at: None, succeeded_at: None, - statsig_fetched_at: None, - statsig_refresh_at: None, - statsig: None, + grok_data: None, cookies, } } diff --git a/crates/artcraftx/src/credentials/cookie_credential_grok_extra_pieces.rs b/crates/artcraftx/src/credentials/cookie_credential_grok_extra_pieces.rs new file mode 100644 index 00000000..b13ba79e --- /dev/null +++ b/crates/artcraftx/src/credentials/cookie_credential_grok_extra_pieces.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use grok_consumer_statsig::StatsigMaterial; +use serde_derive::{Deserialize, Serialize}; + +/// Grok-specific extras stored alongside the cookies in a +/// [`CookieCredential`](crate::credentials::cookie_credential::CookieCredential). +/// +/// Holds the reusable statsig material (the seed a fresh `x-statsig-id` is +/// minted from for any endpoint — see [`StatsigMaterial`]) plus when it was +/// captured and when it should be re-captured. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct CookieCredentialGrokExtraPieces { + /// When the statsig material was captured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statsig_fetched_at: Option>, + + /// When the statsig material should be re-captured (30 min after fetch). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statsig_refresh_at: Option>, + + /// The reusable statsig material (currently just the captured seed). + /// NB: a table, so it must stay after the scalar timestamp fields. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub statsig_material: Option, +} + +impl CookieCredentialGrokExtraPieces { + /// Stamp fresh material with a fetch time and a refresh time + /// `refresh_minutes` into the future. + pub fn fresh(material: StatsigMaterial, now: DateTime, refresh_minutes: i64) -> Self { + Self { + statsig_fetched_at: Some(now), + statsig_refresh_at: Some(now + chrono::Duration::minutes(refresh_minutes)), + statsig_material: Some(material), + } + } +} diff --git a/crates/artcraftx/src/credentials/mod.rs b/crates/artcraftx/src/credentials/mod.rs index 7596b0f9..9bda6b1e 100644 --- a/crates/artcraftx/src/credentials/mod.rs +++ b/crates/artcraftx/src/credentials/mod.rs @@ -18,6 +18,7 @@ pub mod api_key_credential; pub mod auth_credential; pub mod cookie_credential; +pub mod cookie_credential_grok_extra_pieces; pub mod credential_toml; pub mod credential_user_info; pub mod find_service_credentials; diff --git a/crates/artcraftx/src/windows/login_window/login_window_thread.rs b/crates/artcraftx/src/windows/login_window/login_window_thread.rs index 454300df..3ff724e8 100644 --- a/crates/artcraftx/src/windows/login_window/login_window_thread.rs +++ b/crates/artcraftx/src/windows/login_window/login_window_thread.rs @@ -135,11 +135,11 @@ fn check_login_window( info!("Detected {} login identity: {:?}", website, user_info); } - // Preemptively grab the statsig pieces the capture harness stashed (Grok + // Preemptively grab the statsig material the capture harness stashed (Grok // only; other sites don't install it, so this is `None`). let maybe_statsig = read_captured_statsig(webview_window); - if let Some(statsig) = &maybe_statsig { - info!("Captured {} statsig for {} {}", website, statsig.method, statsig.path); + if maybe_statsig.is_some() { + info!("Captured {} statsig material (seed)", website); } let credential = save_web_credential( diff --git a/crates/artcraftx/src/windows/login_window/utils/grok_statsig_capture.rs b/crates/artcraftx/src/windows/login_window/utils/grok_statsig_capture.rs index 4d576a40..5d1ee5fe 100644 --- a/crates/artcraftx/src/windows/login_window/utils/grok_statsig_capture.rs +++ b/crates/artcraftx/src/windows/login_window/utils/grok_statsig_capture.rs @@ -10,8 +10,8 @@ //! makes on its own (no generation is triggered), so the stored statsig is a //! session sample, not necessarily the video endpoint's. -use grok_consumer_statsig::statsig_cache_file::{decode_statsig, CapturedStatsig}; -use grok_consumer_statsig::MINT_HARNESS_SCRIPT; +use grok_consumer_statsig::statsig_cache_file::decode_statsig; +use grok_consumer_statsig::{StatsigMaterial, MINT_HARNESS_SCRIPT}; use tauri::WebviewWindow; /// Cookie the capture harness stashes the latest statsig report into. Skipped @@ -43,8 +43,13 @@ pub fn grok_statsig_init_script() -> String { ) } -/// Read and decode the freshest statsig the harness captured, if any. -pub fn read_captured_statsig(webview: &WebviewWindow) -> Option { +/// Read the freshest statsig the harness captured and reduce it to reusable +/// [`StatsigMaterial`] (the seed), if any. +/// +/// The captured signature yields the seed (decodable) but not the genuine-hex +/// fingerprint (SHA-locked), so the material is the seed alone — see +/// [`StatsigMaterial::generate_statsig`]. +pub fn read_captured_statsig(webview: &WebviewWindow) -> Option { let packed = webview .cookies() .ok()? @@ -55,15 +60,17 @@ pub fn read_captured_statsig(webview: &WebviewWindow) -> Option parse_packed_statsig(&packed) } -/// Parse the `method|path|statsigId|capturedAtMs` cookie payload and decode it. -fn parse_packed_statsig(packed: &str) -> Option { +/// Parse the `method|path|statsigId|capturedAtMs` cookie payload and reduce it +/// to the reusable seed material. +fn parse_packed_statsig(packed: &str) -> Option { let mut parts = packed.splitn(4, '|'); let method = parts.next()?; let path = parts.next()?; let statsig_id = parts.next()?; let captured_at_ms: i64 = parts.next()?.parse().ok()?; - decode_statsig(statsig_id, method, path, captured_at_ms / 1000).ok() + let captured = decode_statsig(statsig_id, method, path, captured_at_ms / 1000).ok()?; + Some(StatsigMaterial::from_seed_b64(captured.seed_b64)) } #[cfg(test)] @@ -84,15 +91,11 @@ mod tests { } #[test] - fn parses_a_packed_capture_into_pieces() { + fn parses_a_packed_capture_into_seed_material() { let packed = format!("POST|/rest/app-chat/conversations/new|{CAPTURE}|1787535100000"); - let decoded = parse_packed_statsig(&packed).expect("should decode"); - assert_eq!(decoded.method, "POST"); - assert_eq!(decoded.path, "/rest/app-chat/conversations/new"); - assert_eq!(decoded.statsig_id, CAPTURE); - assert_eq!(decoded.captured_at_unix, 1_787_535_100); - assert_eq!(decoded.key, 93); - assert!(!decoded.seed_b64.is_empty()); + let material = parse_packed_statsig(&packed).expect("should decode"); + // The reusable piece is the decoded seed (48 bytes -> 64 base64 chars). + assert_eq!(material.seed_b64.len(), 64); } #[test]