From db48185852c4adeb2ee5bdab68a2fd3392a65693 Mon Sep 17 00:00:00 2001 From: enaboapps <60785457+enaboapps@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:54:29 +0100 Subject: [PATCH] fix: harden Linux pairing credential storage --- docs/linux-credentials.md | 21 +++++ src-tauri/src/storage.rs | 171 +++++++++++++++++++++++++++++++++++++- 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 docs/linux-credentials.md diff --git a/docs/linux-credentials.md b/docs/linux-credentials.md new file mode 100644 index 00000000..98cd28d4 --- /dev/null +++ b/docs/linux-credentials.md @@ -0,0 +1,21 @@ +# Linux credential storage foundation + +Issue #720. This is storage hardening on `linux-support`, not an enabled secure Linux runtime. + +The pinned keyring 4.1.6 default `v1` adapter selects Secret Service on Linux, Windows Credential Manager on Windows, and Keychain Services on macOS. Linux continues to use the existing service `com.enaboapps.switchify.pc.pairing` and device-ID lookup keys. No plaintext, kernel-keyring or memory fallback is added. Dependencies, state schema and other platforms' storage selection are unchanged. + +The Linux wrapper serializes operations on each storage instance. Save succeeds only when a nonempty token is written and read back unchanged. Missing credentials remain distinct from an inaccessible store; empty stored values fail closed. Backend errors are replaced with a fixed remediation message, never raw D-Bus text, credential attributes or tokens. Unlock the desktop Secret Service keyring; ensure a Secret Service provider is installed/running, then restart Switchify PC. The pinned keyring adapter caches initial store initialization, so merely retrying after an initial service failure may not recover in the same process. + +A failed verification does not delete the credential: a write may have succeeded before a read failed, and destructive rollback could erase existing access. A replacement write can therefore change the backend even when verification reports failure; it is not a transaction or a durability guarantee. Future pairing integration must not approve/publish a token on any storage error and must account for replacement failure recovery. + +The existing model restores pairing tokens transactionally into the protocol engine. If any load fails, no tokens are activated, and saved pairing metadata is preserved when settings are persisted. Restarting after storage recovery can restore access. Ordinary missing entries retain existing behavior; this wrapper does not infer that every missing entry means the whole store failed. + +## Validation and remaining gates + +Fake-store tests cover successful write verification, recreated wrapper reads, empty credentials, mismatched read-back, save/load/delete failures, non-destructive verification failure and model metadata preservation/recovery. They never access a real keyring or Bluetooth device. Recreating a fake adapter is not physical persistence evidence. + +Before enabling Linux pairing, manually qualify an unlocked store, locked/missing provider, permission denial, application restart, logout/login, failed replacement and explicit forgetting on each supported desktop. No new readiness probe writes test credentials or prompts at startup. Bounded/off-main-thread credential operations and recovery UX belong to production runtime integration; this wrapper retains the synchronous storage API. + +Subscriber isolation remains a separate unresolved gate. This change cannot approve Linux pairing or inject input. + +Reference: [keyring 4.1.6 v1 adapter](https://docs.rs/keyring/4.1.6/keyring/v1/index.html). diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index d409ac53..9544c4d8 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -225,7 +225,75 @@ fn platform_pairing_token_store(state_path: &Path) -> Box )) } -#[cfg(not(target_os = "macos"))] +#[cfg(any(target_os = "linux", test))] +const LINUX_CREDENTIAL_ERROR: &str = "Linux credential storage is unavailable. Unlock the desktop Secret Service keyring and restart Switchify PC. Saved pairing records have been preserved."; + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug)] +struct LinuxPairingTokenStore { + secure: std::sync::Mutex>, +} + +#[cfg(any(target_os = "linux", test))] +impl LinuxPairingTokenStore { + fn new(secure: Box) -> Self { + Self { + secure: std::sync::Mutex::new(secure), + } + } +} + +#[cfg(any(target_os = "linux", test))] +impl PairingTokenStore for LinuxPairingTokenStore { + fn save(&self, device_id: &str, token: &str) -> Result<(), String> { + if token.is_empty() { + return Err(LINUX_CREDENTIAL_ERROR.into()); + } + let secure = self + .secure + .lock() + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())?; + secure + .save(device_id, token) + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())?; + // A successful backend write alone must not acknowledge durable access. + // Do not delete on read-back failure: the write may have succeeded and an + // existing credential must not be erased by an attempted rollback. + match secure.load(device_id) { + Ok(Some(stored)) if stored == token => Ok(()), + _ => Err(LINUX_CREDENTIAL_ERROR.into()), + } + } + + fn load(&self, device_id: &str) -> Result, String> { + let secure = self + .secure + .lock() + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())?; + match secure.load(device_id) { + Ok(Some(token)) if token.is_empty() => Err(LINUX_CREDENTIAL_ERROR.into()), + Ok(token) => Ok(token), + Err(_) => Err(LINUX_CREDENTIAL_ERROR.into()), + } + } + + fn delete(&self, device_id: &str) -> Result<(), String> { + self.secure + .lock() + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string())? + .delete(device_id) + .map_err(|_| LINUX_CREDENTIAL_ERROR.to_string()) + } +} + +#[cfg(target_os = "linux")] +fn platform_pairing_token_store(_state_path: &Path) -> Box { + Box::new(LinuxPairingTokenStore::new( + Box::::default(), + )) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn platform_pairing_token_store(_state_path: &Path) -> Box { Box::::default() } @@ -492,6 +560,107 @@ mod tests { } } + #[test] + fn linux_credentials_verify_writes_and_allow_recreated_adapter_reads() { + let secure = SharedPairingTokenStore::default(); + let store = LinuxPairingTokenStore::new(Box::new(secure.clone())); + store.save("device", "test-token").unwrap(); + drop(store); + let restored = LinuxPairingTokenStore::new(Box::new(secure)); + assert_eq!( + restored.load("device").unwrap().as_deref(), + Some("test-token") + ); + restored.delete("device").unwrap(); + restored.delete("device").unwrap(); + assert_eq!(restored.load("device").unwrap(), None); + } + + #[test] + fn linux_credentials_fail_closed_without_raw_errors_or_destructive_rollback() { + let secure = SharedPairingTokenStore::default(); + let store = LinuxPairingTokenStore::new(Box::new(secure.clone())); + store.save("device", "test-token").unwrap(); + secure.set_failure("save", true); + assert_eq!( + store.save("device", "replacement").unwrap_err(), + LINUX_CREDENTIAL_ERROR + ); + assert_eq!(secure.token("device").as_deref(), Some("test-token")); + secure.set_failure("save", false); + secure.set_failure("load", true); + assert_eq!(store.load("device").unwrap_err(), LINUX_CREDENTIAL_ERROR); + assert_eq!( + store.save("device", "replacement").unwrap_err(), + LINUX_CREDENTIAL_ERROR + ); + assert_eq!(secure.token("device").as_deref(), Some("replacement")); + secure.set_failure("load", false); + assert_eq!( + store.load("device").unwrap().as_deref(), + Some("replacement") + ); + secure.set_failure("delete", true); + assert_eq!(store.delete("device").unwrap_err(), LINUX_CREDENTIAL_ERROR); + assert_eq!(secure.token("device").as_deref(), Some("replacement")); + } + + #[test] + fn linux_credentials_reject_corrupt_readback_and_empty_tokens() { + let secure = SharedPairingTokenStore::default(); + let store = LinuxPairingTokenStore::new(Box::new(secure.clone())); + assert!(store.save("device", "").is_err()); + assert_eq!(secure.token("device"), None); + secure.set_corrupt_save(true); + assert_eq!( + store.save("device", "test-token").unwrap_err(), + LINUX_CREDENTIAL_ERROR + ); + secure.set_corrupt_save(false); + secure.save("device", "").unwrap(); + assert_eq!(store.load("device").unwrap_err(), LINUX_CREDENTIAL_ERROR); + } + + #[test] + fn linux_storage_failure_preserves_metadata_and_restores_after_recovery() { + let secure = SharedPairingTokenStore::default(); + secure.save("device", "test-token").unwrap(); + let root = std::env::temp_dir().join(format!( + "switchify-linux-credentials-{}", + uuid::Uuid::new_v4() + )); + let storage = AppStorage { + path: root.join(STATE_FILE), + pairing_tokens: Box::new(LinuxPairingTokenStore::new(Box::new(secure.clone()))), + }; + storage + .save(&PersistedState { + paired_devices: vec![PairedDeviceView { + device_id: "device".into(), + device_name: "Test phone".into(), + paired_at: 1, + last_seen_at: None, + }], + ..PersistedState::default() + }) + .unwrap(); + secure.set_failure("load", true); + let model = crate::state::AppModel::with_storage_for_test(storage); + assert!(model.snapshot().paired_devices.is_empty()); + model + .persist_settings(&crate::state::AppSettings::default()) + .unwrap(); + assert_eq!(model.storage.load().unwrap().paired_devices.len(), 1); + secure.set_failure("load", false); + let restored = crate::state::AppModel::with_storage_for_test(model.storage); + assert_eq!(restored.snapshot().paired_devices.len(), 1); + assert_eq!( + restored.shared.lock().unwrap().engine.token_for("device"), + Some("test-token") + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn application_storage_uses_the_promoted_identity() { assert_eq!(APP_NAME, "Switchify PC");