pub use crypto::{decrypt_cookie_value, derive_cookie_key};
mod crypto;
mod db;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::process::Command;
use anyhow::{Context, Result};
use tracing::{debug, info, warn};
use super::{Credential, OnePasswordAuth};
use db::{copy_db_to_temp, decrypt_rows, has_domain_tag, query_cookie_db};
#[derive(Debug, Clone, Copy)]
pub enum CookieSource {
Brave,
Chrome,
Firefox,
Safari,
}
impl CookieSource {
#[must_use]
pub fn from_browser_name(browser: &str) -> Self {
match browser.to_lowercase().as_str() {
"brave" => Self::Brave,
"firefox" => Self::Firefox,
"safari" => Self::Safari,
_ => Self::Chrome,
}
}
fn cookie_path(self) -> Option<std::path::PathBuf> {
platform_cookie_path(self)
}
pub(super) fn keychain_service(self) -> &'static str {
match self {
CookieSource::Brave => "Brave Safe Storage",
CookieSource::Chrome => "Chrome Safe Storage",
CookieSource::Firefox | CookieSource::Safari => "",
}
}
fn get_keychain_key(self) -> Result<Vec<u8>> {
let service = self.keychain_service();
if service.is_empty() {
anyhow::bail!("Browser does not use Keychain encryption");
}
let password = Self::read_keychain_password(service)?;
crypto::derive_cookie_key(&password)
}
#[cfg(target_os = "macos")]
fn read_keychain_password(service: &str) -> Result<Vec<u8>> {
use security_framework::passwords::get_generic_password;
let account = service.strip_suffix(" Safe Storage").unwrap_or(service);
get_generic_password(service, account)
.with_context(|| format!("Keychain access denied for service '{service}'"))
}
#[cfg(not(target_os = "macos"))]
fn read_keychain_password(_service: &str) -> Result<Vec<u8>> {
anyhow::bail!(
"Native keychain lookup is only supported on macOS; using Python cookie fallback"
)
}
pub fn get_cookies(&self, domain: &str) -> Result<HashMap<String, String>> {
debug!("Getting cookies for {} from {:?}", domain, self);
match self.get_cookies_native(domain) {
Ok(cookies) if !cookies.is_empty() => {
info!(
"Native cookie extraction succeeded: {} cookies",
cookies.len()
);
return Ok(cookies);
}
Ok(_) => debug!("Native extraction returned empty, trying Python fallback"),
Err(e) => debug!("Native extraction failed: {}, trying Python fallback", e),
}
self.get_cookies_via_python(domain)
}
fn get_cookies_native(self, domain: &str) -> Result<HashMap<String, String>> {
let cookie_path = self
.cookie_path()
.context("Could not determine cookie path")?;
if !cookie_path.exists() {
warn!("Cookie database not found: {:?}", cookie_path);
return Ok(HashMap::new());
}
let temp_db = copy_db_to_temp(&cookie_path)?;
let domain_tag = has_domain_tag(&temp_db);
let rows = query_cookie_db(&temp_db, domain)?;
if let Some(parent) = temp_db.parent() {
let _ = std::fs::remove_dir_all(parent);
}
let key = self.get_keychain_key().ok();
let cookies = decrypt_rows(rows, key.as_deref(), domain_tag);
if cookies.is_empty() {
debug!("Native extraction: 0 cookies for {}", domain);
} else {
info!(
"Native extraction: {} cookies for {}",
cookies.len(),
domain
);
}
Ok(cookies)
}
fn get_cookies_via_python(self, domain: &str) -> Result<HashMap<String, String>> {
let browser_fn = match self {
CookieSource::Brave => "brave",
CookieSource::Chrome => "chrome",
CookieSource::Firefox => "firefox",
CookieSource::Safari => "safari",
};
let script = format!(
r#"
import json
try:
import browser_cookie3 as bc
cj = bc.{browser_fn}()
def matches_cookie_domain(cookie_domain, request_domain):
if cookie_domain.startswith('.'):
parent = cookie_domain[1:]
return request_domain == parent or request_domain.endswith('.' + parent)
else:
return cookie_domain == request_domain
cookies = {{c.name: c.value for c in cj if matches_cookie_domain(c.domain, '{domain}')}}
print(json.dumps(cookies))
except Exception as e:
print(json.dumps({{"__error__": str(e)}}))
"#
);
let output = Command::new("python3")
.args(["-c", &script])
.output()
.context("Failed to run Python cookie extraction")?;
if !output.status.success() {
return Ok(HashMap::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let cookies: HashMap<String, String> = serde_json::from_str(&stdout).unwrap_or_default();
if cookies.contains_key("__error__") {
return Ok(HashMap::new());
}
Ok(cookies)
}
pub fn get_cookie_header(&self, domain: &str) -> Result<String> {
let cookies = self.get_cookies(domain)?;
let header = cookies
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ");
Ok(header)
}
}
#[cfg(target_os = "macos")]
fn platform_cookie_path(source: CookieSource) -> Option<std::path::PathBuf> {
let app_support = dirs::config_dir()?;
let home = dirs::home_dir()?;
Some(match source {
CookieSource::Brave => app_support.join("BraveSoftware/Brave-Browser/Default/Cookies"),
CookieSource::Chrome => app_support.join("Google/Chrome/Default/Cookies"),
CookieSource::Firefox => app_support.join("Firefox/Profiles"),
CookieSource::Safari => home.join("Library/Cookies/Cookies.binarycookies"),
})
}
#[cfg(target_os = "linux")]
fn platform_cookie_path(source: CookieSource) -> Option<std::path::PathBuf> {
let config_dir = dirs::config_dir()?;
let home = dirs::home_dir()?;
match source {
CookieSource::Brave => Some(config_dir.join("BraveSoftware/Brave-Browser/Default/Cookies")),
CookieSource::Chrome => Some(config_dir.join("google-chrome/Default/Cookies")),
CookieSource::Firefox => Some(home.join(".mozilla/firefox")),
CookieSource::Safari => None,
}
}
#[cfg(target_os = "windows")]
fn platform_cookie_path(source: CookieSource) -> Option<std::path::PathBuf> {
let local_data = dirs::data_local_dir()?;
let config_dir = dirs::config_dir()?;
match source {
CookieSource::Brave => {
Some(local_data.join("BraveSoftware/Brave-Browser/User Data/Default/Cookies"))
}
CookieSource::Chrome => Some(local_data.join("Google/Chrome/User Data/Default/Cookies")),
CookieSource::Firefox => Some(config_dir.join("Mozilla/Firefox/Profiles")),
CookieSource::Safari => None,
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn platform_cookie_path(_source: CookieSource) -> Option<std::path::PathBuf> {
None
}
#[derive(Debug, Clone, Copy)]
pub enum CredentialSource {
Keychain,
OnePassword,
BravePasswords,
ChromePasswords,
}
pub struct CredentialRetriever;
impl CredentialRetriever {
pub fn get_credential_for_url(url: &str) -> Result<Option<Credential>> {
let domain = url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(std::string::ToString::to_string))
.unwrap_or_default();
if domain.is_empty() {
return Ok(None);
}
if OnePasswordAuth::is_available() {
let auth = OnePasswordAuth::new(None);
if let Ok(Some(cred)) = auth.get_credential_for_url(url) {
info!("Found credential in 1Password: {}", cred.title);
return Ok(Some(cred));
}
}
if let Some(cred) = Self::get_keychain_credential(&domain)? {
info!("Found credential in Keychain");
return Ok(Some(cred));
}
if let Some(cred) = Self::get_browser_credential(&domain)? {
info!("Found credential in browser");
return Ok(Some(cred));
}
Ok(None)
}
#[allow(clippy::unnecessary_wraps)]
fn get_keychain_credential(domain: &str) -> Result<Option<Credential>> {
let output = Command::new("security")
.args(["find-internet-password", "-s", domain, "-g"])
.output();
if let Ok(output) = output
&& output.status.success()
{
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let username = stdout
.lines()
.find(|l| l.contains("\"acct\""))
.and_then(|l| l.split('"').nth(3))
.map(String::from);
let password = stderr
.lines()
.find(|l| l.starts_with("password:"))
.and_then(|l| {
if l.contains('"') {
l.split('"').nth(1).map(String::from)
} else {
None
}
});
if username.is_some() || password.is_some() {
return Ok(Some(Credential {
title: format!("Keychain: {domain}"),
username,
password,
url: Some(format!("https://{domain}")),
totp: None,
has_totp: false,
passkey_credential_id: None,
}));
}
}
Ok(None)
}
fn get_browser_credential(domain: &str) -> Result<Option<Credential>> {
for browser in ["brave", "chrome"] {
if let Some(cred) = Self::get_chromium_password(browser, domain)? {
return Ok(Some(cred));
}
}
Ok(None)
}
fn get_chromium_password(browser: &str, domain: &str) -> Result<Option<Credential>> {
let home = dirs::home_dir().context("No home directory")?;
let login_data_path = match browser {
"brave" => home
.join("Library/Application Support/BraveSoftware/Brave-Browser/Default/Login Data"),
"chrome" => home.join("Library/Application Support/Google/Chrome/Default/Login Data"),
_ => return Ok(None),
};
if !login_data_path.exists() {
return Ok(None);
}
let temp_dir = std::env::temp_dir().join(format!("nab_logins_{}", std::process::id()));
std::fs::create_dir_all(&temp_dir)?;
let temp_db = temp_dir.join("Login Data");
std::fs::copy(&login_data_path, &temp_db)?;
let query = format!(
"SELECT origin_url, username_value FROM logins WHERE origin_url LIKE '%{domain}%' LIMIT 1"
);
let temp_db_str = temp_db
.to_str()
.ok_or_else(|| anyhow::anyhow!("Invalid temp database path"))?;
let output = Command::new("sqlite3")
.args(["-separator", "\t", temp_db_str, &query])
.output();
let _ = std::fs::remove_dir_all(&temp_dir);
if let Ok(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
return Ok(Some(Credential {
title: format!("{browser} password: {domain}"),
username: Some(parts[1].to_string()),
password: None,
url: Some(parts[0].to_string()),
totp: None,
has_totp: false,
passkey_credential_id: None,
}));
}
}
}
Ok(None)
}
}