// OAuth token storage abstraction. // // This module centralises how OAuth2 tokens are persisted. It tries the OS // keychain first (Credential Manager on Windows, Secret Service on Linux // via libdbus) and falls back to a restricted file on disk if the keychain // is unavailable. // // Security properties: // - The keychain service name matches the Tauri bundle identifier // (com.simpl.resultat) so OS tools and future macOS builds can scope // credentials correctly. // - A `store_mode` flag is persisted next to the fallback file. Once the // keychain has been used successfully, the store refuses to silently // downgrade to the file fallback: a subsequent keychain failure is // surfaced as an error so the caller can force re-authentication // instead of leaving the user with undetected plaintext tokens. // - Migration from an existing `tokens.json` file zeros the file contents // and fsyncs before unlinking, reducing the window where the refresh // token is recoverable from unallocated disk blocks. use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use tauri::Manager; use zeroize::Zeroize; // Keychain identifiers. The service name matches tauri.conf.json's // `identifier` so credentials are scoped to the real app identity. const KEYCHAIN_SERVICE: &str = "com.simpl.resultat"; const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens"; pub(crate) const AUTH_DIR: &str = "auth"; const TOKENS_FILE: &str = "tokens.json"; const STORE_MODE_FILE: &str = "store_mode"; /// Where token material currently lives. Exposed via a Tauri command so /// the frontend can display a security banner when the app has fallen /// back to the file store. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum StoreMode { Keychain, File, } impl StoreMode { fn as_str(&self) -> &'static str { match self { StoreMode::Keychain => "keychain", StoreMode::File => "file", } } fn parse(raw: &str) -> Option { match raw.trim() { "keychain" => Some(StoreMode::Keychain), "file" => Some(StoreMode::File), _ => None, } } } /// Serialised OAuth token bundle. Owned by `token_store` because this is /// the only module that should reach for the persisted bytes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredTokens { pub access_token: String, pub refresh_token: Option, pub id_token: Option, pub expires_at: i64, } /// Resolve `/auth/`, creating it if needed. Shared with /// auth_commands.rs which still writes non-secret files (account info, /// last-check timestamp) in the same directory. pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result { let dir = app .path() .app_data_dir() .map_err(|e| format!("Cannot get app data dir: {}", e))? .join(AUTH_DIR); if !dir.exists() { fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?; } Ok(dir) } /// Write a file with 0600 permissions on Unix. Windows has no cheap /// equivalent here; callers that rely on this function for secrets should /// treat the Windows path as a last-resort fallback and surface the /// degraded state to the user (see StoreMode). pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> { #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; let mut file = fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path) .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; file.write_all(contents.as_bytes()) .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; file.sync_all() .map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?; } #[cfg(not(unix))] { fs::write(path, contents) .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; } Ok(()) } pub(crate) fn chrono_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64 } fn read_store_mode(dir: &Path) -> Option { let path = dir.join(STORE_MODE_FILE); let raw = fs::read_to_string(&path).ok()?; StoreMode::parse(&raw) } fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> { write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str()) } /// Overwrite the file contents with zeros, fsync, then unlink. Best-effort /// mitigation against recovery of the refresh token from unallocated /// blocks on copy-on-write filesystems where a plain unlink leaves the /// ciphertext recoverable. Not a substitute for proper disk encryption. fn zero_and_delete(path: &Path) -> Result<(), String> { if !path.exists() { return Ok(()); } let len = fs::metadata(path) .map(|m| m.len() as usize) .unwrap_or(0) .max(512); let mut zeros = vec![0u8; len]; { let mut f = fs::OpenOptions::new() .write(true) .truncate(false) .open(path) .map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?; f.write_all(&zeros) .map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?; f.sync_all() .map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?; } zeros.zeroize(); fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e)) } fn tokens_to_json(tokens: &StoredTokens) -> Result { serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e)) } fn tokens_from_json(raw: &str) -> Result { serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e)) } fn keychain_entry() -> Result { keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS) } fn keychain_save(json: &str) -> Result<(), keyring::Error> { keychain_entry()?.set_password(json) } fn keychain_load() -> Result, keyring::Error> { match keychain_entry()?.get_password() { Ok(v) => Ok(Some(v)), Err(keyring::Error::NoEntry) => Ok(None), Err(e) => Err(e), } } fn keychain_delete() -> Result<(), keyring::Error> { match keychain_entry()?.delete_credential() { Ok(()) => Ok(()), Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e), } } /// Persist the current OAuth token bundle. /// /// Tries the OS keychain first. If the keychain write fails AND the /// persisted `store_mode` flag shows the keychain has worked before, the /// caller receives an error instead of a silent downgrade — this /// prevents a hostile local process from forcing the app into the /// weaker file-fallback path. On a fresh install (no prior flag), the /// fallback is allowed but recorded so subsequent calls know the app is /// running in degraded mode. pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> { let json = tokens_to_json(tokens)?; let dir = auth_dir(app)?; let previous_mode = read_store_mode(&dir); match keychain_save(&json) { Ok(()) => { // Keychain succeeded. Clean up any residual file from a prior // fallback or migration source so nothing stays readable. let residual = dir.join(TOKENS_FILE); if residual.exists() { let _ = zero_and_delete(&residual); } write_store_mode(&dir, StoreMode::Keychain)?; Ok(()) } Err(err) => { if previous_mode == Some(StoreMode::Keychain) { // Refuse to downgrade after a prior success — surface the // failure so the caller can force re-auth instead of // silently leaking tokens to disk. return Err(format!( "Keychain unavailable after prior success — refusing to downgrade. \ Original error: {}", err )); } eprintln!( "token_store: keychain unavailable, falling back to file store ({})", err ); write_restricted(&dir.join(TOKENS_FILE), &json)?; write_store_mode(&dir, StoreMode::File)?; Ok(()) } } } /// Load the current OAuth token bundle. /// /// Tries the keychain first. If the keychain is empty but a legacy /// `tokens.json` file exists, this is a first-run migration: the tokens /// are copied into the keychain, the file is zeroed and unlinked, and /// `store_mode` is updated. If the keychain itself is unreachable, the /// function falls back to reading the file — unless the `store_mode` /// flag indicates the keychain has worked before, in which case it /// returns an error to force re-auth. pub fn load(app: &tauri::AppHandle) -> Result, String> { let dir = auth_dir(app)?; let previous_mode = read_store_mode(&dir); let residual = dir.join(TOKENS_FILE); match keychain_load() { Ok(Some(raw)) => { let tokens = tokens_from_json(&raw)?; // Defensive: if a leftover file is still around (e.g. a prior // crash between keychain write and file delete), clean it up. if residual.exists() { let _ = zero_and_delete(&residual); } if previous_mode != Some(StoreMode::Keychain) { write_store_mode(&dir, StoreMode::Keychain)?; } Ok(Some(tokens)) } Ok(None) => { // Keychain reachable but empty. Migrate from a legacy file if // one exists, otherwise report no stored session. if residual.exists() { let raw = fs::read_to_string(&residual) .map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?; let tokens = tokens_from_json(&raw)?; // Push into keychain and wipe the file. If the keychain // push fails here, we keep the file rather than losing // the user's session. match keychain_save(&raw) { Ok(()) => { let _ = zero_and_delete(&residual); write_store_mode(&dir, StoreMode::Keychain)?; } Err(e) => { eprintln!("token_store: migration to keychain failed ({})", e); write_store_mode(&dir, StoreMode::File)?; } } Ok(Some(tokens)) } else { Ok(None) } } Err(err) => { if previous_mode == Some(StoreMode::Keychain) { return Err(format!( "Keychain unavailable after prior success: {}", err )); } // No prior keychain success: honour the file fallback if any. eprintln!( "token_store: keychain unreachable, using file fallback ({})", err ); if residual.exists() { let raw = fs::read_to_string(&residual) .map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?; write_store_mode(&dir, StoreMode::File)?; Ok(Some(tokens_from_json(&raw)?)) } else { Ok(None) } } } } /// Delete the stored tokens from both the keychain and the file /// fallback. Both deletions are best-effort and ignore "no entry" /// errors to stay idempotent. pub fn delete(app: &tauri::AppHandle) -> Result<(), String> { let dir = auth_dir(app)?; if let Err(err) = keychain_delete() { eprintln!("token_store: keychain delete failed ({})", err); } let residual = dir.join(TOKENS_FILE); if residual.exists() { let _ = zero_and_delete(&residual); } // Leave the store_mode flag alone: it still describes what the app // should trust the next time `save` is called. Ok(()) } /// Current store mode, derived from the persisted flag. Returns `None` /// if no tokens have ever been written (no flag file). pub fn store_mode(app: &tauri::AppHandle) -> Result, String> { let dir = auth_dir(app)?; Ok(read_store_mode(&dir)) } /// Tauri command: expose the current store mode to the frontend. /// Returns `"keychain"`, `"file"`, or `null` if the app has no stored /// session yet. Used by the settings UI to show a security banner when /// the fallback is active. #[tauri::command] pub fn get_token_store_mode(app: tauri::AppHandle) -> Result, String> { Ok(store_mode(&app)?.map(|m| m.as_str().to_string())) } #[cfg(test)] mod tests { use super::*; #[test] fn store_mode_roundtrip() { assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain)); assert_eq!(StoreMode::parse("file"), Some(StoreMode::File)); assert_eq!(StoreMode::parse("other"), None); assert_eq!(StoreMode::Keychain.as_str(), "keychain"); assert_eq!(StoreMode::File.as_str(), "file"); } #[test] fn stored_tokens_serde_roundtrip() { let tokens = StoredTokens { access_token: "at".into(), refresh_token: Some("rt".into()), id_token: Some("it".into()), expires_at: 42, }; let json = tokens_to_json(&tokens).unwrap(); let decoded = tokens_from_json(&json).unwrap(); assert_eq!(decoded.access_token, "at"); assert_eq!(decoded.refresh_token.as_deref(), Some("rt")); assert_eq!(decoded.id_token.as_deref(), Some("it")); assert_eq!(decoded.expires_at, 42); } #[test] fn zero_and_delete_removes_file() { use std::io::Write as _; let tmp = std::env::temp_dir().join(format!( "simpl-resultat-token-store-test-{}", std::process::id() )); let mut f = fs::File::create(&tmp).unwrap(); f.write_all(b"sensitive").unwrap(); drop(f); assert!(tmp.exists()); zero_and_delete(&tmp).unwrap(); assert!(!tmp.exists()); } }