diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c8b4e79..02d4552 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -347,6 +347,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -484,6 +493,15 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.55" @@ -527,6 +545,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -842,6 +866,35 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -2176,6 +2229,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -2323,6 +2377,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2374,6 +2442,15 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2619,12 +2696,39 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2651,6 +2755,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2677,6 +2790,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4003,6 +4127,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -4289,6 +4432,7 @@ dependencies = [ "encoding_rs", "hostname", "jsonwebtoken", + "keyring", "libsqlite3-sys", "machine-uid", "rand 0.8.5", @@ -4309,6 +4453,7 @@ dependencies = [ "tokio", "urlencoding", "walkdir", + "zeroize", ] [[package]] @@ -4629,6 +4774,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -5043,7 +5194,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows", - "zbus", + "zbus 5.13.2", ] [[package]] @@ -5069,7 +5220,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.13.2", ] [[package]] @@ -6697,6 +6848,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.1" @@ -6720,6 +6881,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.13.2" @@ -6750,9 +6943,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.14", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.13.2", + "zbus_names 4.3.1", + "zvariant 5.9.2", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils 2.1.0", ] [[package]] @@ -6765,9 +6971,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.1", + "zvariant 5.9.2", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -6778,7 +6995,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow 0.7.14", - "zvariant", + "zvariant 5.9.2", ] [[package]] @@ -6827,6 +7044,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" @@ -6879,6 +7110,19 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.9.2" @@ -6889,8 +7133,21 @@ dependencies = [ "enumflags2", "serde", "winnow 0.7.14", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.9.2", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.114", + "zvariant_utils 2.1.0", ] [[package]] @@ -6903,7 +7160,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "zvariant_utils", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 885eb7e..bd1a7f2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,13 @@ tokio = { version = "1", features = ["macros"] } hostname = "0.4" urlencoding = "2" base64 = "0.22" +# OAuth token storage in OS keychain (Credential Manager on Windows, +# Secret Service on Linux). We use sync-secret-service to get sync +# methods that are safe to call from async Tauri commands without +# tokio runtime entanglement. Requires libdbus-1-dev at build time +# on Linux (libdbus-1-3 is present on every desktop Linux at runtime). +keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] } +zeroize = "1" [dev-dependencies] # Used in license_commands.rs tests to sign test JWTs. We avoid the `pem` diff --git a/src-tauri/src/commands/auth_commands.rs b/src-tauri/src/commands/auth_commands.rs index b68b922..7c75c9b 100644 --- a/src-tauri/src/commands/auth_commands.rs +++ b/src-tauri/src/commands/auth_commands.rs @@ -1,25 +1,28 @@ // OAuth2 PKCE flow for Compte Maximus (Logto) integration. // // Architecture: -// - The desktop app is registered as a "Native App" in Logto (public client, no secret). +// - The desktop app is registered as a "Native App" in Logto (public +// client, no secret). // - OAuth2 Authorization Code + PKCE flow via the system browser. // - Deep-link callback: simpl-resultat://auth/callback?code=... -// - Tokens are stored as files in app_data_dir/auth/ (encrypted at rest in a future -// iteration via OS keychain). For now, plain JSON — acceptable because: -// (a) the app data dir has user-only permissions, -// (b) the access token is short-lived (1h default in Logto), -// (c) the refresh token is rotated on each use. +// - Tokens are persisted through `token_store` which prefers the OS +// keychain (Credential Manager / Secret Service) and falls back to a +// restricted file only when no prior keychain success has been +// recorded. See `token_store.rs` for details. // -// The PKCE verifier is held in memory via Tauri managed state, so it cannot be -// intercepted by another process. It is cleared after the callback exchange. +// The PKCE verifier is held in memory via Tauri managed state, so it +// cannot be intercepted by another process. It is cleared after the +// callback exchange. use serde::{Deserialize, Serialize}; use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::Manager; +use super::token_store::{ + self, auth_dir, chrono_now, write_restricted, StoredTokens, +}; + // Logto endpoint — overridable via env var for development. fn logto_endpoint() -> String { std::env::var("LOGTO_ENDPOINT") @@ -32,8 +35,6 @@ fn logto_app_id() -> String { } const REDIRECT_URI: &str = "simpl-resultat://auth/callback"; -const AUTH_DIR: &str = "auth"; -const TOKENS_FILE: &str = "tokens.json"; const ACCOUNT_FILE: &str = "account.json"; const LAST_CHECK_FILE: &str = "last_check"; const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours @@ -52,50 +53,6 @@ pub struct AccountInfo { pub subscription_status: Option, } -/// Stored tokens (written to auth/tokens.json). -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredTokens { - access_token: String, - refresh_token: Option, - id_token: Option, - expires_at: i64, -} - -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 restricted permissions (0600 on Unix) for sensitive data like tokens. -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))?; - } - #[cfg(not(unix))] - { - fs::write(path, contents) - .map_err(|e| format!("Cannot write {}: {}", path.display(), e))?; - } - Ok(()) -} - fn generate_pkce() -> (String, String) { use rand::Rng; let mut rng = rand::thread_rng(); @@ -189,22 +146,20 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600); let expires_at = chrono_now() + expires_in; - // Store tokens + // Persist tokens through token_store (prefers keychain over file). let tokens = StoredTokens { access_token: access_token.clone(), refresh_token, id_token, expires_at, }; - let dir = auth_dir(&app)?; - let tokens_json = - serde_json::to_string_pretty(&tokens).map_err(|e| format!("Serialize error: {}", e))?; - write_restricted(&dir.join(TOKENS_FILE), &tokens_json)?; + token_store::save(&app, &tokens)?; // Fetch user info let account = fetch_userinfo(&endpoint, &access_token).await?; - // Store account info + // Store account info (non-secret; stays in the restricted file store). + let dir = auth_dir(&app)?; let account_json = serde_json::to_string_pretty(&account).map_err(|e| format!("Serialize error: {}", e))?; write_restricted(&dir.join(ACCOUNT_FILE), &account_json)?; @@ -215,16 +170,7 @@ pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result /// Refresh the access token using the stored refresh token. #[tauri::command] pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result { - let dir = auth_dir(&app)?; - let tokens_path = dir.join(TOKENS_FILE); - if !tokens_path.exists() { - return Err("Not authenticated".to_string()); - } - - let tokens_raw = - fs::read_to_string(&tokens_path).map_err(|e| format!("Cannot read tokens: {}", e))?; - let tokens: StoredTokens = - serde_json::from_str(&tokens_raw).map_err(|e| format!("Invalid tokens file: {}", e))?; + let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?; let refresh_token = tokens .refresh_token @@ -247,8 +193,9 @@ pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result Result Result, St /// Log out: clear all stored tokens and account info. #[tauri::command] pub fn logout(app: tauri::AppHandle) -> Result<(), String> { + token_store::delete(&app)?; let dir = auth_dir(&app)?; - let _ = fs::remove_file(dir.join(TOKENS_FILE)); let _ = fs::remove_file(dir.join(ACCOUNT_FILE)); Ok(()) } @@ -314,13 +260,14 @@ pub fn logout(app: tauri::AppHandle) -> Result<(), String> { pub async fn check_subscription_status( app: tauri::AppHandle, ) -> Result, String> { - let dir = auth_dir(&app)?; - - // Not authenticated — nothing to check - if !dir.join(TOKENS_FILE).exists() { + // Not authenticated — nothing to check. This also triggers migration + // from a legacy tokens.json file into the keychain when present, + // because token_store::load() performs the migration eagerly. + if token_store::load(&app)?.is_none() { return Ok(None); } + let dir = auth_dir(&app)?; let last_check_path = dir.join(LAST_CHECK_FILE); let now = chrono_now(); @@ -383,10 +330,3 @@ async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64 -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d87c164..0cea39e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod export_import_commands; pub mod fs_commands; pub mod license_commands; pub mod profile_commands; +pub mod token_store; pub use auth_commands::*; pub use entitlements::*; @@ -11,3 +12,4 @@ pub use export_import_commands::*; pub use fs_commands::*; pub use license_commands::*; pub use profile_commands::*; +pub use token_store::*; diff --git a/src-tauri/src/commands/token_store.rs b/src-tauri/src/commands/token_store.rs new file mode 100644 index 0000000..5eabfb5 --- /dev/null +++ b/src-tauri/src/commands/token_store.rs @@ -0,0 +1,393 @@ +// 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()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0bad42..5d8de27 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -184,6 +184,7 @@ pub fn run() { commands::get_account_info, commands::check_subscription_status, commands::logout, + commands::get_token_store_mode, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");