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