use serde::Serialize; use std::sync::Mutex; use tauri::AppHandle; use crate::secret_store::{self, Backend}; /// Data returned from `load_credential`. #[derive(Serialize, Clone)] pub struct CredentialData { pub username: String, pub token: String, // Password is stored in the credential blob for re-authentication and is // serialized back to the frontend over IPC so the login form can prefill // it when the user ticked "Remember password". pub password: Option, } impl std::fmt::Debug for CredentialData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CredentialData") .field("username", &self.username) .field("token", &"[REDACTED]") .field("password", &self.password.as_ref().map(|_| "[REDACTED]")) .finish() } } // --------------------------------------------------------------------------- // Account naming // --------------------------------------------------------------------------- // // Both secrets live in the same credential-store service // (`secret_store::SERVICE`) and are told apart by their account name. Changing // either function orphans every credential already stored under the old name, // so they are pure and covered by tests. /// Account holding the login credential for `host`. fn login_account(host: &str) -> String { host.to_string() } /// Account holding the voice-E2EE identity private key for `host`. /// /// The `identity:` prefix keeps it distinct from the login credential for the /// same host; a collision would make one secret overwrite the other. fn identity_account(host: &str) -> String { format!("identity:{host}") } fn require_non_empty(value: &str, field: &str) -> Result<(), String> { if value.is_empty() { return Err(format!("{field} must not be empty")); } Ok(()) } // --------------------------------------------------------------------------- // Cross-command serialization // --------------------------------------------------------------------------- // // B4-3 moved every command below to `#[tauri::command(async)]` so the // blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of // freezing the UI on it. Before that, Tauri ran all (sync) commands one at a // time on that thread, so two overlapping invocations were always fully // serialized in arrival order. `async` dispatches each invocation onto the // async runtime's thread pool instead, so two overlapping calls can now // genuinely run concurrently and interleave their OS credential-store // operations. // // That is reachable, not hypothetical: `identity.ts`'s legacy-key migration // does a save-then-delete pair for two different accounts, and logging out // fires a fire-and-forget `delete_credential` for a host whose connect-page // auto-login can immediately issue `load_credential` for the very same host. // Nothing upstream awaits the delete before the read can start. // // This mutex restores the "only one credential-store operation in flight at // a time" property that made ordering safe pre-`async`, without giving back // the perf win: it guards the whole command body (not just the raw OS call), // so the fallback file's read-modify-write in `secret_store::set_with` is // still atomic with respect to a concurrent read or delete for the same or a // different account. static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(()); /// Run `f` with every other credential-store command excluded. Poisoning is /// recovered from (the guarded value is `()`, so there is nothing to /// distrust) rather than propagated, so a panic inside one command cannot /// permanently wedge every credential operation for the rest of the process. fn with_credential_lock(f: impl FnOnce() -> T) -> T { let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); f() } // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- /// Save a credential (username + token + optional password) to the system /// credential store. /// /// Credential key: service=`com.owncord.client`, account=`host` /// Secret: JSON `{"username":"...","token":"...","password":"..."}` /// /// On Windows the secret is protected by DPAPI via Windows Credential Manager. /// On Linux it is stored in the Secret Service (GNOME Keyring / KWallet). /// On macOS it is stored in the system Keychain. The write is read back before /// this returns — see [`crate::secret_store`] for what happens when it does not /// come back. #[tauri::command(async)] pub fn save_credential( app: AppHandle, host: String, username: String, token: String, password: Option, ) -> Result<(), String> { with_credential_lock(|| { require_non_empty(&host, "host")?; require_non_empty(&token, "token")?; require_non_empty(&username, "username")?; let mut payload = serde_json::json!({ "username": username, "token": token, }); if let Some(ref pw) = password { payload["password"] = serde_json::Value::String(pw.clone()); } secret_store::set(&app, &login_account(&host), &payload.to_string()) .map_err(|e| format!("save_credential failed: {e}"))?; Ok(()) }) } /// Load a credential from the system credential store. /// /// Returns `None` when no credential exists for the given host. #[tauri::command(async)] pub fn load_credential(app: AppHandle, host: String) -> Result, String> { with_credential_lock(|| { require_non_empty(&host, "host")?; let Some(json_str) = secret_store::get(&app, &login_account(&host)) .map_err(|e| format!("load_credential failed: {e}"))? else { return Ok(None); }; parse_credential_blob(&json_str).map(Some) }) } /// Parse the stored credential JSON blob. /// /// Split out from the command so the blob contract is testable without a /// credential store. fn parse_credential_blob(json_str: &str) -> Result { let parsed: serde_json::Value = serde_json::from_str(json_str) .map_err(|e| format!("credential blob is not valid JSON: {e}"))?; let username = parsed .get("username") .and_then(|v| v.as_str()) .ok_or("credential blob missing 'username' field")? .to_string(); let token = parsed .get("token") .and_then(|v| v.as_str()) .ok_or("credential blob missing 'token' field")? .to_string(); let password = parsed .get("password") .and_then(|v| v.as_str()) .map(|s| s.to_string()); Ok(CredentialData { username, token, password, }) } /// Delete a credential from the system credential store. /// /// Deleting a non-existent credential is not treated as an error. #[tauri::command(async)] pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> { with_credential_lock(|| { require_non_empty(&host, "host")?; secret_store::delete(&app, &login_account(&host)) .map_err(|e| format!("delete_credential failed: {e}")) }) } // --------------------------------------------------------------------------- // Identity-key commands (F3: voice E2EE TOFU long-term identity keypair) // --------------------------------------------------------------------------- // // Mirrors save/load/delete_credential, but the secret is a single opaque // key blob (base64 JWK private key) rather than a JSON credential struct, // and it is stored under account `identity:{host}` to keep it distinct from // the login credential entry (account `{host}`) in the same service. /// Save the long-term identity private key for `host`. /// /// The write is read back before this returns. A machine whose credential store /// accepts writes without keeping them falls through to the encrypted fallback /// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also /// unavailable this returns an error rather than reporting a success that would /// leave peers rejecting the user's voice announce after a restart. #[tauri::command(async)] pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> { with_credential_lock(|| { require_non_empty(&host, "host")?; require_non_empty(&key, "key")?; secret_store::set(&app, &identity_account(&host), &key) .map_err(|e| format!("save_identity_key failed: {e}"))?; Ok(()) }) } /// Load the identity private key for `host`. /// /// Returns `None` when no identity key exists for the given host. #[tauri::command(async)] pub fn load_identity_key(app: AppHandle, host: String) -> Result, String> { with_credential_lock(|| { require_non_empty(&host, "host")?; secret_store::get(&app, &identity_account(&host)) .map_err(|e| format!("load_identity_key failed: {e}")) }) } /// Delete the identity private key for `host`. /// /// Deleting a non-existent key is not treated as an error. #[tauri::command(async)] pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> { with_credential_lock(|| { require_non_empty(&host, "host")?; secret_store::delete(&app, &identity_account(&host)) .map_err(|e| format!("delete_identity_key failed: {e}")) }) } // --------------------------------------------------------------------------- // Diagnostics // --------------------------------------------------------------------------- /// Result of [`probe_credential_store`]. #[derive(Serialize, Debug)] pub struct CredentialStoreProbe { /// Whether a write/read/delete cycle completed with the value intact. pub ok: bool, /// Which store served the probe, when it succeeded. pub backend: Option, /// Failure detail, for the log and the support bundle. pub error: Option, } /// Write, read back and delete a throwaway secret to prove the credential store /// works on this machine. /// /// This is the check to run when a user reports peers rejecting their voice /// announce: it distinguishes "the credential store is fine" from "writes are /// accepted and dropped" without touching any real credential. The probe /// account is removed again whatever the outcome. #[tauri::command(async)] pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe { with_credential_lock(|| { // Underscores are not legal in DNS hostnames, so this cannot collide // with a real `{host}` or `identity:{host}` account. const PROBE_ACCOUNT: &str = "__diagnostic_probe__"; const PROBE_SECRET: &str = "owncord-credential-store-probe"; let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| { match secret_store::get(&app, PROBE_ACCOUNT)? { Some(ref got) if got == PROBE_SECRET => Ok(backend), Some(_) => Err("read back a different value than was written".into()), None => Err("the store reported a successful write but returned no entry".into()), } }); // Always clean up, including when the probe failed part-way through. if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) { log::warn!("failed to remove credential store probe entry: {e}"); } match result { Ok(backend) => { log::info!("credential store probe succeeded (backend: {backend:?})"); CredentialStoreProbe { ok: true, backend: Some(backend), error: None, } } Err(e) => { log::error!("credential store probe failed: {e}"); CredentialStoreProbe { ok: false, backend: None, error: Some(e), } } } }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn require_non_empty_rejects_empty_and_names_the_field() { let err = require_non_empty("", "host").unwrap_err(); assert_eq!(err, "host must not be empty"); assert_eq!( require_non_empty("", "token").unwrap_err(), "token must not be empty" ); assert_eq!( require_non_empty("", "username").unwrap_err(), "username must not be empty" ); assert_eq!( require_non_empty("", "key").unwrap_err(), "key must not be empty" ); } #[test] fn require_non_empty_accepts_a_value() { assert!(require_non_empty("chat.example.com", "host").is_ok()); } #[test] fn login_and_identity_accounts_never_collide() { // Both secrets share one credential-store service, so a collision would // silently overwrite one with the other. let host = "chat.example.com"; assert_eq!(login_account(host), "chat.example.com"); assert_eq!(identity_account(host), "identity:chat.example.com"); assert_ne!(login_account(host), identity_account(host)); } #[test] fn account_names_keep_the_port_that_distinguishes_hosts() { // Two servers on one machine differ only by port; dropping it would // make them share an identity key. assert_ne!(login_account("localhost:8443"), login_account("localhost:9443")); assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443"); } #[test] fn parse_credential_blob_reads_all_fields() { let data = parse_credential_blob(r#"{"username":"alice","token":"tok","password":"pw"}"#).unwrap(); assert_eq!(data.username, "alice"); assert_eq!(data.token, "tok"); assert_eq!(data.password.as_deref(), Some("pw")); } #[test] fn parse_credential_blob_allows_missing_password() { let data = parse_credential_blob(r#"{"username":"alice","token":"tok"}"#).unwrap(); assert_eq!(data.password, None); } #[test] fn parse_credential_blob_rejects_malformed_input() { assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON")); assert!(parse_credential_blob(r#"{"token":"tok"}"#) .unwrap_err() .contains("missing 'username'")); assert!(parse_credential_blob(r#"{"username":"alice"}"#) .unwrap_err() .contains("missing 'token'")); } #[test] fn credential_data_debug_redacts_sensitive_fields() { let data = CredentialData { username: "alice".into(), token: "secret-token".into(), password: Some("hunter2".into()), }; let debug = format!("{data:?}"); assert!(debug.contains("alice")); assert!(!debug.contains("secret-token")); assert!(!debug.contains("hunter2")); assert!(debug.contains("[REDACTED]")); } #[test] fn credential_data_serializes_password_for_prefill() { let data = CredentialData { username: "alice".into(), token: "tok".into(), password: Some("pw".into()), }; let json = serde_json::to_string(&data).unwrap(); assert!(json.contains("password")); assert!(json.contains("pw")); } /// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`, /// which runs each invocation on the async runtime's thread pool instead /// of Tauri's single IPC main thread. Two overlapping invocations (e.g. /// `identity.ts`'s save-then-delete legacy-key migration, or a logout's /// `delete_credential` racing a connect-page auto-login's /// `load_credential` for the same host) can now genuinely run /// concurrently. `with_credential_lock` must serialize them: this proves /// no two holders of the lock ever run their critical section at the /// same time, regardless of which OS thread the runtime schedules them /// on. #[test] fn credential_lock_serializes_overlapping_commands() { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; use std::time::Duration; let concurrent = Arc::new(AtomicUsize::new(0)); let max_concurrent = Arc::new(AtomicUsize::new(0)); let handles: Vec<_> = (0..8) .map(|_| { let concurrent = Arc::clone(&concurrent); let max_concurrent = Arc::clone(&max_concurrent); thread::spawn(move || { with_credential_lock(|| { let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1; max_concurrent.fetch_max(now, Ordering::SeqCst); thread::sleep(Duration::from_millis(5)); concurrent.fetch_sub(1, Ordering::SeqCst); }); }) }) .collect(); for h in handles { h.join().unwrap(); } assert_eq!( max_concurrent.load(Ordering::SeqCst), 1, "two credential-store commands ran their critical section concurrently" ); } /// `with_credential_lock`'s doc comment promises that poisoning is /// recovered from rather than propagated, so a panic inside one /// credential command cannot permanently wedge every later credential /// operation for the rest of the process. Prove it: panic while holding /// the lock on a spawned thread (which poisons `CREDENTIAL_LOCK`), then /// confirm a later `with_credential_lock` call still runs its closure /// instead of panicking on the poisoned mutex. #[test] fn with_credential_lock_recovers_from_a_poisoned_guard() { use std::thread; let poisoning = thread::spawn(|| { with_credential_lock(|| { panic!("boom"); }); }); assert!( poisoning.join().is_err(), "expected the spawned thread to panic while holding the lock" ); assert_eq!( with_credential_lock(|| 42), 42, "with_credential_lock must recover from a poisoned mutex, not propagate it" ); } }