chasm_cli/
browser.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Browser authentication detection for web-based LLM providers
4//!
5//! This module reads browser cookies (without opening windows) to detect
6//! which cloud LLM providers the user is authenticated with.
7
8use anyhow::{anyhow, Context, Result};
9use colored::Colorize;
10use rusqlite::{Connection, OpenFlags};
11use std::collections::HashMap;
12use std::fs;
13use std::path::PathBuf;
14
15// Suppress dead code warnings for fields used in debugging
16#[allow(dead_code)]
17
18/// Supported browser types for cookie extraction
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum BrowserType {
21    Chrome,
22    Edge,
23    Firefox,
24    Brave,
25    Vivaldi,
26    Opera,
27}
28
29impl BrowserType {
30    pub fn name(&self) -> &'static str {
31        match self {
32            BrowserType::Chrome => "Chrome",
33            BrowserType::Edge => "Edge",
34            BrowserType::Firefox => "Firefox",
35            BrowserType::Brave => "Brave",
36            BrowserType::Vivaldi => "Vivaldi",
37            BrowserType::Opera => "Opera",
38        }
39    }
40
41    /// Get the default profile path for this browser
42    #[cfg(windows)]
43    pub fn profile_path(&self) -> Option<PathBuf> {
44        let local_app_data = dirs::data_local_dir()?;
45        let roaming_app_data = dirs::data_dir()?;
46
47        let path = match self {
48            BrowserType::Chrome => local_app_data.join("Google/Chrome/User Data/Default"),
49            BrowserType::Edge => local_app_data.join("Microsoft/Edge/User Data/Default"),
50            BrowserType::Brave => {
51                local_app_data.join("BraveSoftware/Brave-Browser/User Data/Default")
52            }
53            BrowserType::Vivaldi => local_app_data.join("Vivaldi/User Data/Default"),
54            BrowserType::Opera => roaming_app_data.join("Opera Software/Opera Stable"),
55            BrowserType::Firefox => {
56                // Firefox uses random profile directories
57                // Select the profile with the largest cookies.sqlite (most likely active)
58                let profiles_dir = roaming_app_data.join("Mozilla/Firefox/Profiles");
59                if profiles_dir.exists() {
60                    if let Ok(entries) = fs::read_dir(&profiles_dir) {
61                        let mut best_profile: Option<(PathBuf, u64)> = None;
62
63                        for entry in entries.flatten() {
64                            let profile_path = entry.path();
65                            let cookies_path = profile_path.join("cookies.sqlite");
66
67                            if cookies_path.exists() {
68                                if let Ok(metadata) = fs::metadata(&cookies_path) {
69                                    let size = metadata.len();
70                                    if best_profile.as_ref().is_none_or(|(_, s)| size > *s) {
71                                        best_profile = Some((profile_path, size));
72                                    }
73                                }
74                            }
75                        }
76
77                        if let Some((path, _)) = best_profile {
78                            return Some(path);
79                        }
80                    }
81                }
82                return None;
83            }
84        };
85
86        if path.exists() {
87            Some(path)
88        } else {
89            None
90        }
91    }
92
93    #[cfg(not(windows))]
94    pub fn profile_path(&self) -> Option<PathBuf> {
95        let home = dirs::home_dir()?;
96
97        let path = match self {
98            BrowserType::Chrome => {
99                #[cfg(target_os = "macos")]
100                {
101                    home.join("Library/Application Support/Google/Chrome/Default")
102                }
103                #[cfg(target_os = "linux")]
104                {
105                    home.join(".config/google-chrome/Default")
106                }
107            }
108            BrowserType::Edge => {
109                #[cfg(target_os = "macos")]
110                {
111                    home.join("Library/Application Support/Microsoft Edge/Default")
112                }
113                #[cfg(target_os = "linux")]
114                {
115                    home.join(".config/microsoft-edge/Default")
116                }
117            }
118            BrowserType::Firefox => {
119                #[cfg(target_os = "macos")]
120                let profiles_dir = home.join("Library/Application Support/Firefox/Profiles");
121                #[cfg(target_os = "linux")]
122                let profiles_dir = home.join(".mozilla/firefox");
123
124                if profiles_dir.exists() {
125                    if let Ok(entries) = fs::read_dir(&profiles_dir) {
126                        for entry in entries.flatten() {
127                            let name = entry.file_name().to_string_lossy().to_string();
128                            if name.ends_with(".default-release") || name.ends_with(".default") {
129                                return Some(entry.path());
130                            }
131                        }
132                    }
133                }
134                return None;
135            }
136            BrowserType::Brave => {
137                #[cfg(target_os = "macos")]
138                {
139                    home.join("Library/Application Support/BraveSoftware/Brave-Browser/Default")
140                }
141                #[cfg(target_os = "linux")]
142                {
143                    home.join(".config/BraveSoftware/Brave-Browser/Default")
144                }
145            }
146            _ => return None,
147        };
148
149        if path.exists() {
150            Some(path)
151        } else {
152            None
153        }
154    }
155
156    /// Get the cookie database path for Chromium-based browsers
157    pub fn cookies_path(&self) -> Option<PathBuf> {
158        let profile = self.profile_path()?;
159
160        match self {
161            BrowserType::Firefox => {
162                let path = profile.join("cookies.sqlite");
163                if path.exists() {
164                    Some(path)
165                } else {
166                    None
167                }
168            }
169            _ => {
170                // Chromium-based browsers store cookies in Network/Cookies (newer) or Cookies (older)
171                let network_path = profile.join("Network/Cookies");
172                if network_path.exists() {
173                    return Some(network_path);
174                }
175                let old_path = profile.join("Cookies");
176                if old_path.exists() {
177                    Some(old_path)
178                } else {
179                    None
180                }
181            }
182        }
183    }
184
185    /// Get the Local State file path (contains encryption key for Chromium browsers)
186    /// Reserved for future cookie decryption implementation
187    #[cfg(windows)]
188    #[allow(dead_code)]
189    pub fn local_state_path(&self) -> Option<PathBuf> {
190        let local_app_data = dirs::data_local_dir()?;
191        let roaming_app_data = dirs::data_dir()?;
192
193        let path = match self {
194            BrowserType::Chrome => local_app_data.join("Google/Chrome/User Data/Local State"),
195            BrowserType::Edge => local_app_data.join("Microsoft/Edge/User Data/Local State"),
196            BrowserType::Brave => {
197                local_app_data.join("BraveSoftware/Brave-Browser/User Data/Local State")
198            }
199            BrowserType::Vivaldi => local_app_data.join("Vivaldi/User Data/Local State"),
200            BrowserType::Opera => roaming_app_data.join("Opera Software/Opera Stable/Local State"),
201            BrowserType::Firefox => return None, // Firefox doesn't use this
202        };
203
204        if path.exists() {
205            Some(path)
206        } else {
207            None
208        }
209    }
210}
211
212/// Web LLM provider authentication info
213#[derive(Debug, Clone)]
214pub struct ProviderAuth {
215    pub name: &'static str,
216    pub domain: &'static str,
217    pub auth_cookie_names: &'static [&'static str],
218    #[allow(dead_code)]
219    pub description: &'static str,
220}
221
222/// Known web LLM providers and their authentication cookies
223pub const WEB_LLM_PROVIDERS: &[ProviderAuth] = &[
224    ProviderAuth {
225        name: "ChatGPT",
226        domain: "chatgpt.com", // Also checked: openai.com, chat.openai.com
227        auth_cookie_names: &[
228            "__Secure-next-auth.session-token",
229            "_puid",
230            "__cf_bm",
231            "cf_clearance",
232        ],
233        description: "OpenAI ChatGPT",
234    },
235    ProviderAuth {
236        name: "Claude",
237        domain: "claude.ai",
238        auth_cookie_names: &["sessionKey", "__cf_bm"],
239        description: "Anthropic Claude",
240    },
241    ProviderAuth {
242        name: "Gemini",
243        domain: "gemini.google.com",
244        auth_cookie_names: &["SID", "HSID", "SSID"],
245        description: "Google Gemini",
246    },
247    ProviderAuth {
248        name: "Perplexity",
249        domain: "perplexity.ai",
250        auth_cookie_names: &["pplx.visitor-id", "__Secure-next-auth.session-token"],
251        description: "Perplexity AI",
252    },
253    ProviderAuth {
254        name: "DeepSeek",
255        domain: "chat.deepseek.com",
256        auth_cookie_names: &["token", "sessionid"],
257        description: "DeepSeek Chat",
258    },
259    ProviderAuth {
260        name: "Poe",
261        domain: "poe.com",
262        auth_cookie_names: &["p-b", "p-lat"],
263        description: "Quora Poe",
264    },
265    ProviderAuth {
266        name: "HuggingChat",
267        domain: "huggingface.co",
268        auth_cookie_names: &["token", "hf-chat"],
269        description: "HuggingFace Chat",
270    },
271    ProviderAuth {
272        name: "Copilot",
273        domain: "copilot.microsoft.com",
274        auth_cookie_names: &["_U", "MUID"],
275        description: "Microsoft Copilot",
276    },
277    ProviderAuth {
278        name: "Mistral",
279        domain: "chat.mistral.ai",
280        auth_cookie_names: &["__Secure-next-auth.session-token"],
281        description: "Mistral Le Chat",
282    },
283    ProviderAuth {
284        name: "Cohere",
285        domain: "coral.cohere.com",
286        auth_cookie_names: &["session", "auth_token"],
287        description: "Cohere Coral",
288    },
289    ProviderAuth {
290        name: "Groq",
291        domain: "groq.com",
292        auth_cookie_names: &["__Secure-next-auth.session-token"],
293        description: "Groq Cloud",
294    },
295    ProviderAuth {
296        name: "Phind",
297        domain: "phind.com",
298        auth_cookie_names: &["__Secure-next-auth.session-token", "phind-session"],
299        description: "Phind AI",
300    },
301    ProviderAuth {
302        name: "Character.AI",
303        domain: "character.ai",
304        auth_cookie_names: &["token", "web-next-auth.session-token"],
305        description: "Character.AI",
306    },
307    ProviderAuth {
308        name: "You.com",
309        domain: "you.com",
310        auth_cookie_names: &["stytch_session", "youchat_session"],
311        description: "You.com AI",
312    },
313    ProviderAuth {
314        name: "Pi",
315        domain: "pi.ai",
316        auth_cookie_names: &["__Secure-next-auth.session-token"],
317        description: "Inflection Pi",
318    },
319];
320
321/// Result of checking browser authentication
322#[derive(Debug, Clone)]
323pub struct BrowserAuthResult {
324    pub browser: BrowserType,
325    pub provider: String,
326    pub authenticated: bool,
327    #[allow(dead_code)]
328    pub cookies_found: Vec<String>,
329}
330
331/// Scan browsers for authenticated LLM providers
332pub fn scan_browser_auth() -> Vec<BrowserAuthResult> {
333    scan_browser_auth_internal(false)
334}
335
336/// Scan browsers for authenticated LLM providers with verbose output
337pub fn scan_browser_auth_verbose() -> Vec<BrowserAuthResult> {
338    scan_browser_auth_internal(true)
339}
340
341fn scan_browser_auth_internal(verbose: bool) -> Vec<BrowserAuthResult> {
342    let mut results = Vec::new();
343
344    let browsers = [
345        BrowserType::Edge,
346        BrowserType::Chrome,
347        BrowserType::Brave,
348        BrowserType::Firefox,
349        BrowserType::Vivaldi,
350        BrowserType::Opera,
351    ];
352
353    for browser in browsers {
354        if let Some(cookies_path) = browser.cookies_path() {
355            if verbose {
356                println!(
357                    "      {} {} cookies: {}",
358                    "->".dimmed(),
359                    browser.name(),
360                    cookies_path.display()
361                );
362            }
363            match scan_browser_cookies_internal(&browser, &cookies_path, verbose) {
364                Ok(browser_results) => results.extend(browser_results),
365                Err(e) => {
366                    if verbose {
367                        println!("        {} Direct access failed: {}", "!".yellow(), e);
368                        println!("        {} Trying copy method...", "->".dimmed());
369                    }
370                    // Browser might be open and locking the database
371                    // Try copying to temp file
372                    match scan_browser_cookies_with_copy_internal(&browser, &cookies_path, verbose)
373                    {
374                        Ok(browser_results) => results.extend(browser_results),
375                        Err(e2) => {
376                            if verbose {
377                                println!("        {} Copy method also failed: {}", "x".red(), e2);
378                            }
379                        }
380                    }
381                }
382            }
383        }
384    }
385
386    results
387}
388
389/// Get list of installed browsers
390pub fn get_installed_browsers() -> Vec<BrowserType> {
391    let browsers = [
392        BrowserType::Edge,
393        BrowserType::Chrome,
394        BrowserType::Brave,
395        BrowserType::Firefox,
396        BrowserType::Vivaldi,
397        BrowserType::Opera,
398    ];
399
400    browsers
401        .into_iter()
402        .filter(|b| b.profile_path().is_some())
403        .collect()
404}
405
406/// Scan a browser's cookie database for LLM provider authentication
407#[allow(dead_code)]
408fn scan_browser_cookies(
409    browser: &BrowserType,
410    cookies_path: &PathBuf,
411) -> Result<Vec<BrowserAuthResult>> {
412    scan_browser_cookies_internal(browser, cookies_path, false)
413}
414
415fn scan_browser_cookies_internal(
416    browser: &BrowserType,
417    cookies_path: &PathBuf,
418    verbose: bool,
419) -> Result<Vec<BrowserAuthResult>> {
420    let mut results = Vec::new();
421
422    // Open database read-only
423    let conn = Connection::open_with_flags(
424        cookies_path,
425        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
426    )
427    .context("Failed to open cookie database")?;
428
429    // Get all cookies grouped by domain
430    let cookies = match *browser {
431        BrowserType::Firefox => get_firefox_cookies(&conn)?,
432        _ => get_chromium_cookies(&conn)?,
433    };
434
435    if verbose {
436        println!(
437            "        {} Found {} domains with cookies",
438            "->".dimmed(),
439            cookies.len()
440        );
441
442        // Show domains that might match our providers
443        let llm_domains: Vec<_> = cookies
444            .keys()
445            .filter(|d| {
446                let dl = d.to_lowercase();
447                dl.contains("openai")
448                    || dl.contains("claude")
449                    || dl.contains("anthropic")
450                    || dl.contains("google")
451                    || dl.contains("perplexity")
452                    || dl.contains("deepseek")
453                    || dl.contains("poe")
454                    || dl.contains("huggingface")
455                    || dl.contains("microsoft")
456                    || dl.contains("copilot")
457                    || dl.contains("mistral")
458                    || dl.contains("cohere")
459                    || dl.contains("groq")
460                    || dl.contains("phind")
461                    || dl.contains("character")
462            })
463            .collect();
464
465        if !llm_domains.is_empty() {
466            println!("        {} LLM-related domains found:", "->".dimmed());
467            for domain in &llm_domains {
468                let cookie_names = cookies
469                    .get(*domain)
470                    .map(|v| v.join(", "))
471                    .unwrap_or_default();
472                println!(
473                    "          {} {} -> [{}]",
474                    "*".dimmed(),
475                    domain,
476                    cookie_names.dimmed()
477                );
478            }
479        }
480    }
481
482    // Check each provider
483    for provider in WEB_LLM_PROVIDERS {
484        // Domain matching needs to handle:
485        // - Exact match: "chat.openai.com"
486        // - Dot-prefixed: ".openai.com"
487        // - Parent domain: "openai.com" matches ".openai.com"
488        let domain_cookies: Vec<&String> = cookies
489            .iter()
490            .filter(|(domain, _)| {
491                let domain_clean = domain.trim_start_matches('.');
492                let provider_domain = provider.domain.trim_start_matches('.');
493                domain_clean.ends_with(provider_domain) || provider_domain.ends_with(domain_clean)
494            })
495            .flat_map(|(_, names)| names)
496            .collect();
497
498        let found_auth_cookies: Vec<String> = provider
499            .auth_cookie_names
500            .iter()
501            .filter(|name| {
502                domain_cookies
503                    .iter()
504                    .any(|c| c == *name || c.contains(*name))
505            })
506            .map(|s| s.to_string())
507            .collect();
508
509        let authenticated = !found_auth_cookies.is_empty();
510
511        if verbose && !domain_cookies.is_empty() {
512            println!(
513                "        {} {}: domain cookies={:?}, auth cookies={:?}, authenticated={}",
514                "->".dimmed(),
515                provider.name,
516                domain_cookies.iter().take(5).collect::<Vec<_>>(),
517                found_auth_cookies,
518                authenticated
519            );
520        }
521
522        results.push(BrowserAuthResult {
523            browser: *browser,
524            provider: provider.name.to_string(),
525            authenticated,
526            cookies_found: found_auth_cookies,
527        });
528    }
529
530    Ok(results)
531}
532
533/// Copy cookie database to temp file and scan (for when browser has lock)
534#[allow(dead_code)]
535fn scan_browser_cookies_with_copy(
536    browser: &BrowserType,
537    cookies_path: &PathBuf,
538) -> Result<Vec<BrowserAuthResult>> {
539    scan_browser_cookies_with_copy_internal(browser, cookies_path, false)
540}
541
542fn scan_browser_cookies_with_copy_internal(
543    browser: &BrowserType,
544    cookies_path: &PathBuf,
545    verbose: bool,
546) -> Result<Vec<BrowserAuthResult>> {
547    let temp_dir = std::env::temp_dir();
548    let temp_path = temp_dir.join(format!("csm_cookies_{}.db", uuid::Uuid::new_v4()));
549
550    // Copy the database file
551    fs::copy(cookies_path, &temp_path).context("Failed to copy cookie database")?;
552
553    // Also copy the journal/wal files if they exist
554    let wal_path = cookies_path.with_extension("db-wal");
555    if wal_path.exists() {
556        let _ = fs::copy(&wal_path, temp_path.with_extension("db-wal"));
557    }
558    let shm_path = cookies_path.with_extension("db-shm");
559    if shm_path.exists() {
560        let _ = fs::copy(&shm_path, temp_path.with_extension("db-shm"));
561    }
562
563    // Firefox uses -wal and -shm without the .db prefix
564    let ff_wal = cookies_path.with_file_name(format!(
565        "{}-wal",
566        cookies_path
567            .file_name()
568            .unwrap_or_default()
569            .to_string_lossy()
570    ));
571    if ff_wal.exists() {
572        let _ = fs::copy(
573            &ff_wal,
574            temp_dir.join(format!("csm_cookies_{}.db-wal", uuid::Uuid::new_v4())),
575        );
576    }
577
578    if verbose {
579        println!(
580            "        {} Copied to temp: {}",
581            "->".dimmed(),
582            temp_path.display()
583        );
584    }
585
586    let result = scan_browser_cookies_internal(browser, &temp_path, verbose);
587
588    // Clean up temp files
589    let _ = fs::remove_file(&temp_path);
590    let _ = fs::remove_file(temp_path.with_extension("db-wal"));
591    let _ = fs::remove_file(temp_path.with_extension("db-shm"));
592
593    result
594}
595
596/// Get cookies from Chromium-based browser database
597fn get_chromium_cookies(conn: &Connection) -> Result<HashMap<String, Vec<String>>> {
598    let mut cookies: HashMap<String, Vec<String>> = HashMap::new();
599
600    // Query cookie names grouped by host
601    let mut stmt = conn.prepare(
602        "SELECT host_key, name FROM cookies WHERE host_key LIKE '%.%' GROUP BY host_key, name",
603    )?;
604
605    let rows = stmt.query_map([], |row| {
606        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
607    })?;
608
609    for row in rows.flatten() {
610        let (host, name) = row;
611        cookies.entry(host).or_default().push(name);
612    }
613
614    Ok(cookies)
615}
616
617/// Get cookies from Firefox database
618fn get_firefox_cookies(conn: &Connection) -> Result<HashMap<String, Vec<String>>> {
619    let mut cookies: HashMap<String, Vec<String>> = HashMap::new();
620
621    let mut stmt = conn
622        .prepare("SELECT host, name FROM moz_cookies WHERE host LIKE '%.%' GROUP BY host, name")?;
623
624    let rows = stmt.query_map([], |row| {
625        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
626    })?;
627
628    for row in rows.flatten() {
629        let (host, name) = row;
630        cookies.entry(host).or_default().push(name);
631    }
632
633    Ok(cookies)
634}
635
636/// Summary of authenticated providers across all browsers
637/// Reserved for future programmatic access to auth state
638#[allow(dead_code)]
639#[derive(Debug, Default)]
640pub struct AuthSummary {
641    pub browsers_checked: Vec<BrowserType>,
642    pub authenticated_providers: HashMap<String, Vec<BrowserType>>,
643    pub total_providers_authenticated: usize,
644}
645
646/// Extracted cookie with its value
647#[derive(Debug, Clone)]
648#[allow(dead_code)]
649pub struct ExtractedCookie {
650    pub name: String,
651    pub value: String,
652    pub domain: String,
653    pub browser: BrowserType,
654}
655
656/// Provider credentials extracted from browser cookies
657#[derive(Debug, Clone, Default)]
658#[allow(dead_code)]
659pub struct ProviderCredentials {
660    pub provider: String,
661    pub session_token: Option<String>,
662    pub cookies: HashMap<String, String>,
663    pub browser: Option<BrowserType>,
664}
665
666/// Extract actual cookie values for a specific provider
667pub fn extract_provider_cookies(provider_name: &str) -> Option<ProviderCredentials> {
668    let provider_auth = WEB_LLM_PROVIDERS
669        .iter()
670        .find(|p| p.name.eq_ignore_ascii_case(provider_name))?;
671
672    // For ChatGPT, try multiple domains since cookies can be on either
673    let domains_to_try: Vec<&str> = if provider_name.eq_ignore_ascii_case("chatgpt") {
674        vec!["chatgpt.com", "openai.com", "chat.openai.com"]
675    } else {
676        vec![provider_auth.domain]
677    };
678
679    let browsers = [
680        BrowserType::Edge,
681        BrowserType::Chrome,
682        BrowserType::Brave,
683        BrowserType::Firefox,
684        BrowserType::Vivaldi,
685        BrowserType::Opera,
686    ];
687
688    for browser in browsers {
689        if let Some(cookies_path) = browser.cookies_path() {
690            for domain in &domains_to_try {
691                // Try to extract cookies
692                if let Ok(cookies) = extract_cookies_for_domain(&browser, &cookies_path, domain) {
693                    if !cookies.is_empty() {
694                        let mut creds = ProviderCredentials {
695                            provider: provider_name.to_string(),
696                            session_token: None,
697                            cookies: HashMap::new(),
698                            browser: Some(browser),
699                        };
700
701                        for cookie in &cookies {
702                            // Check if this is a session token cookie
703                            if provider_auth
704                                .auth_cookie_names
705                                .iter()
706                                .any(|name| cookie.name.contains(name))
707                            {
708                                if cookie.name.contains("session") || cookie.name.contains("token")
709                                {
710                                    creds.session_token = Some(cookie.value.clone());
711                                }
712                            }
713                            creds
714                                .cookies
715                                .insert(cookie.name.clone(), cookie.value.clone());
716                        }
717
718                        if creds.session_token.is_some() || !creds.cookies.is_empty() {
719                            return Some(creds);
720                        }
721                    }
722                }
723            }
724        }
725    }
726
727    None
728}
729
730/// Extract all cookies for a domain from a browser
731fn extract_cookies_for_domain(
732    browser: &BrowserType,
733    cookies_path: &PathBuf,
734    domain: &str,
735) -> Result<Vec<ExtractedCookie>> {
736    // Try direct access first
737    match extract_cookies_internal(browser, cookies_path, domain) {
738        Ok(cookies) => Ok(cookies),
739        Err(_) => {
740            // Browser might be locking the file, try copy method
741            extract_cookies_with_copy(browser, cookies_path, domain)
742        }
743    }
744}
745
746fn extract_cookies_internal(
747    browser: &BrowserType,
748    cookies_path: &PathBuf,
749    domain: &str,
750) -> Result<Vec<ExtractedCookie>> {
751    let conn = Connection::open_with_flags(
752        cookies_path,
753        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
754    )
755    .context("Failed to open cookie database")?;
756
757    match browser {
758        BrowserType::Firefox => extract_firefox_cookie_values(&conn, domain, browser),
759        _ => extract_chromium_cookie_values(&conn, domain, browser),
760    }
761}
762
763fn extract_cookies_with_copy(
764    browser: &BrowserType,
765    cookies_path: &PathBuf,
766    domain: &str,
767) -> Result<Vec<ExtractedCookie>> {
768    let temp_dir = std::env::temp_dir();
769    let temp_path = temp_dir.join(format!("csm_cookies_extract_{}.db", uuid::Uuid::new_v4()));
770
771    fs::copy(cookies_path, &temp_path).context("Failed to copy cookie database")?;
772
773    // Copy journal files
774    let wal_path = cookies_path.with_extension("db-wal");
775    if wal_path.exists() {
776        let _ = fs::copy(&wal_path, temp_path.with_extension("db-wal"));
777    }
778    let shm_path = cookies_path.with_extension("db-shm");
779    if shm_path.exists() {
780        let _ = fs::copy(&shm_path, temp_path.with_extension("db-shm"));
781    }
782
783    let result = extract_cookies_internal(browser, &temp_path, domain);
784
785    // Clean up
786    let _ = fs::remove_file(&temp_path);
787    let _ = fs::remove_file(temp_path.with_extension("db-wal"));
788    let _ = fs::remove_file(temp_path.with_extension("db-shm"));
789
790    result
791}
792
793/// Extract cookie values from Firefox database
794fn extract_firefox_cookie_values(
795    conn: &Connection,
796    domain: &str,
797    browser: &BrowserType,
798) -> Result<Vec<ExtractedCookie>> {
799    let mut cookies = Vec::new();
800
801    let mut stmt =
802        conn.prepare("SELECT name, value, host FROM moz_cookies WHERE host LIKE ? OR host LIKE ?")?;
803
804    let domain_pattern = format!("%{}", domain);
805    let dot_domain_pattern = format!("%.{}", domain);
806
807    let rows = stmt.query_map([&domain_pattern, &dot_domain_pattern], |row| {
808        Ok((
809            row.get::<_, String>(0)?,
810            row.get::<_, String>(1)?,
811            row.get::<_, String>(2)?,
812        ))
813    })?;
814
815    for row in rows.flatten() {
816        let (name, value, host) = row;
817        if !value.is_empty() {
818            cookies.push(ExtractedCookie {
819                name,
820                value,
821                domain: host,
822                browser: *browser,
823            });
824        }
825    }
826
827    Ok(cookies)
828}
829
830/// Extract cookie values from Chromium-based browser database
831/// Note: Chromium encrypts cookie values, this returns encrypted values
832/// Full decryption requires platform-specific crypto APIs
833fn extract_chromium_cookie_values(
834    conn: &Connection,
835    domain: &str,
836    browser: &BrowserType,
837) -> Result<Vec<ExtractedCookie>> {
838    let mut cookies = Vec::new();
839
840    // Chromium stores encrypted values in encrypted_value column
841    // We try to get the plaintext value first, then fall back to encrypted
842    let mut stmt = conn.prepare(
843        "SELECT name, value, encrypted_value, host_key FROM cookies WHERE host_key LIKE ? OR host_key LIKE ?"
844    )?;
845
846    let domain_pattern = format!("%{}", domain);
847    let dot_domain_pattern = format!("%.{}", domain);
848
849    let rows = stmt.query_map([&domain_pattern, &dot_domain_pattern], |row| {
850        Ok((
851            row.get::<_, String>(0)?,
852            row.get::<_, String>(1)?,
853            row.get::<_, Vec<u8>>(2)?,
854            row.get::<_, String>(3)?,
855        ))
856    })?;
857
858    for row in rows.flatten() {
859        let (name, value, encrypted_value, host) = row;
860
861        // Try plaintext value first (older Chrome versions or some cookies)
862        let cookie_value = if !value.is_empty() {
863            value
864        } else if !encrypted_value.is_empty() {
865            // Try to decrypt the cookie value
866            match decrypt_chromium_cookie(&encrypted_value, browser) {
867                Ok(decrypted) => decrypted,
868                Err(_) => continue, // Skip cookies we can't decrypt
869            }
870        } else {
871            continue;
872        };
873
874        if !cookie_value.is_empty() {
875            cookies.push(ExtractedCookie {
876                name,
877                value: cookie_value,
878                domain: host,
879                browser: *browser,
880            });
881        }
882    }
883
884    Ok(cookies)
885}
886
887/// Decrypt Chromium cookie value
888/// On Windows, uses DPAPI. On macOS, uses Keychain. On Linux, may be stored in plain text or use secret service.
889#[cfg(windows)]
890fn decrypt_chromium_cookie(encrypted_value: &[u8], browser: &BrowserType) -> Result<String> {
891    use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
892
893    // Check for v10/v20 prefix (AES-GCM encrypted)
894    if encrypted_value.len() > 3 && &encrypted_value[0..3] == b"v10"
895        || &encrypted_value[0..3] == b"v20"
896    {
897        // Get the encryption key from Local State file
898        if let Some(key) = get_chromium_encryption_key(browser) {
899            return decrypt_aes_gcm(&encrypted_value[3..], &key);
900        }
901    }
902
903    // Try DPAPI decryption (older format)
904    unsafe {
905        let mut input = CRYPT_INTEGER_BLOB {
906            cbData: encrypted_value.len() as u32,
907            pbData: encrypted_value.as_ptr() as *mut u8,
908        };
909        let mut output = CRYPT_INTEGER_BLOB {
910            cbData: 0,
911            pbData: std::ptr::null_mut(),
912        };
913
914        let result = CryptUnprotectData(&mut input, None, None, None, None, 0, &mut output);
915
916        if result.is_ok() && !output.pbData.is_null() {
917            let slice = std::slice::from_raw_parts(output.pbData, output.cbData as usize);
918            let decrypted = String::from_utf8_lossy(slice).to_string();
919            // Note: We should free output.pbData with LocalFree, but it's a small leak
920            // for a short-lived operation. The windows crate doesn't expose LocalFree directly.
921            return Ok(decrypted);
922        }
923    }
924
925    Err(anyhow!("Failed to decrypt cookie"))
926}
927
928#[cfg(windows)]
929fn get_chromium_encryption_key(browser: &BrowserType) -> Option<Vec<u8>> {
930    use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
931    use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
932
933    let local_state_path = browser.local_state_path()?;
934    let local_state_content = fs::read_to_string(&local_state_path).ok()?;
935    let local_state: serde_json::Value = serde_json::from_str(&local_state_content).ok()?;
936
937    let encrypted_key_b64 = local_state
938        .get("os_crypt")?
939        .get("encrypted_key")?
940        .as_str()?;
941
942    let encrypted_key = BASE64.decode(encrypted_key_b64).ok()?;
943
944    // Remove "DPAPI" prefix (5 bytes)
945    if encrypted_key.len() <= 5 || &encrypted_key[0..5] != b"DPAPI" {
946        return None;
947    }
948
949    let encrypted_key = &encrypted_key[5..];
950
951    // Decrypt using DPAPI
952    unsafe {
953        let mut input = CRYPT_INTEGER_BLOB {
954            cbData: encrypted_key.len() as u32,
955            pbData: encrypted_key.as_ptr() as *mut u8,
956        };
957        let mut output = CRYPT_INTEGER_BLOB {
958            cbData: 0,
959            pbData: std::ptr::null_mut(),
960        };
961
962        let result = CryptUnprotectData(&mut input, None, None, None, None, 0, &mut output);
963
964        if result.is_ok() && !output.pbData.is_null() {
965            let key = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
966            // Note: We should free output.pbData with LocalFree, but it's a small leak
967            // for a short-lived operation. The windows crate doesn't expose LocalFree directly.
968            return Some(key);
969        }
970    }
971
972    None
973}
974
975#[cfg(windows)]
976fn decrypt_aes_gcm(encrypted_data: &[u8], key: &[u8]) -> Result<String> {
977    use aes_gcm::{
978        aead::{Aead, KeyInit},
979        Aes256Gcm, Nonce,
980    };
981
982    if encrypted_data.len() < 12 + 16 {
983        return Err(anyhow!("Encrypted data too short"));
984    }
985
986    // First 12 bytes are nonce
987    let nonce = Nonce::from_slice(&encrypted_data[0..12]);
988    let ciphertext = &encrypted_data[12..];
989
990    let cipher =
991        Aes256Gcm::new_from_slice(key).map_err(|e| anyhow!("Failed to create cipher: {}", e))?;
992
993    let plaintext = cipher
994        .decrypt(nonce, ciphertext)
995        .map_err(|e| anyhow!("Decryption failed: {}", e))?;
996
997    String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8 in decrypted cookie: {}", e))
998}
999
1000#[cfg(not(windows))]
1001fn decrypt_chromium_cookie(encrypted_value: &[u8], _browser: &BrowserType) -> Result<String> {
1002    // On macOS, would need to access Keychain
1003    // On Linux, cookies may be stored in plain text or use secret service
1004
1005    // Check if it's already plaintext
1006    if let Ok(s) = String::from_utf8(encrypted_value.to_vec()) {
1007        if s.chars()
1008            .all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace())
1009        {
1010            return Ok(s);
1011        }
1012    }
1013
1014    Err(anyhow!(
1015        "Cookie decryption not implemented for this platform"
1016    ))
1017}
1018
1019/// Get a summary of all authenticated web LLM providers
1020/// Reserved for future programmatic access to auth state
1021#[allow(dead_code)]
1022pub fn get_auth_summary() -> AuthSummary {
1023    let results = scan_browser_auth();
1024    let mut summary = AuthSummary::default();
1025
1026    // Track which browsers we checked
1027    let mut browsers_seen = std::collections::HashSet::new();
1028
1029    for result in results {
1030        browsers_seen.insert(result.browser);
1031
1032        if result.authenticated {
1033            summary
1034                .authenticated_providers
1035                .entry(result.provider)
1036                .or_default()
1037                .push(result.browser);
1038        }
1039    }
1040
1041    summary.browsers_checked = browsers_seen.into_iter().collect();
1042    summary.total_providers_authenticated = summary.authenticated_providers.len();
1043
1044    summary
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    #[test]
1052    fn test_browser_type_name() {
1053        assert_eq!(BrowserType::Chrome.name(), "Chrome");
1054        assert_eq!(BrowserType::Edge.name(), "Edge");
1055        assert_eq!(BrowserType::Firefox.name(), "Firefox");
1056    }
1057
1058    #[test]
1059    fn test_get_installed_browsers() {
1060        let browsers = get_installed_browsers();
1061        // Should return a list (may be empty if no browsers installed)
1062        assert!(browsers.len() <= 6);
1063    }
1064
1065    #[test]
1066    fn test_provider_auth_domains() {
1067        // Verify all providers have valid domains
1068        for provider in WEB_LLM_PROVIDERS {
1069            assert!(!provider.domain.is_empty());
1070            assert!(provider.domain.contains('.'));
1071            assert!(!provider.auth_cookie_names.is_empty());
1072        }
1073    }
1074
1075    #[test]
1076    fn test_auth_summary_default() {
1077        let summary = AuthSummary::default();
1078        assert!(summary.browsers_checked.is_empty());
1079        assert!(summary.authenticated_providers.is_empty());
1080        assert_eq!(summary.total_providers_authenticated, 0);
1081    }
1082}