diff --git a/docs/linux-credential-worker.md b/docs/linux-credential-worker.md new file mode 100644 index 00000000..41ed9193 --- /dev/null +++ b/docs/linux-credential-worker.md @@ -0,0 +1,11 @@ +# Linux credential worker foundation + +Issue #722, dependent on #721. This component is not started by the unavailable Linux runtime. It does not activate Bluetooth, approve pairing, inject input or change existing synchronous model startup. Production ownership and startup migration remain separate work. + +One worker owns one dedicated blocking thread and admits at most one outstanding load, save or delete. Overlapping callers receive `Busy`; there is no retry queue or per-request thread spawning. A caller-supplied timeout must be greater than zero and at most 30 seconds. Device identifiers are limited to 128 bytes and tokens to 4096 bytes; empty identifiers/tokens are rejected. Backend errors become the fixed `StorageUnavailable` result, with no raw native error or credential logging. + +Timeout and dropping a future invalidate its result. Generation invalidation discards stale results; queued work checks cancellation before calling the backend. Cancellation can race with starting a native call and cannot interrupt it or roll back a save/delete already underway. A timed-out write may still commit. Callers must never publish/approve a credential from a cancelled or failed operation and must reconcile persisted state before retrying a replacement. + +Admission remains busy until the native call returns, even after caller timeout. Shutdown stops admission, invalidates results and closes the channel without joining the thread. A stuck native call may therefore leave a detached thread until process exit. The future runtime must own exactly one worker and must not recreate workers on timeout; otherwise repeated recreation would defeat the thread bound. Restart is the recovery path for a permanently stuck worker. + +Fake-store tests cover save/load/delete, request and response bounds, sanitized errors, timeout while a call remains blocked, busy admission, discarded stale results, recovery, dropped futures and non-blocking shutdown. They perform no real keyring operations. Physical Secret Service qualification and subscriber isolation are still required before enabling secure Linux pairing; see [credential storage gates](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/Cargo.toml b/src-tauri/Cargo.toml index 02f1f449..b3c64016 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,5 +66,8 @@ windows = { version = "0.62.2", features = [ ] } winreg = "0.55" +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } + [patch.crates-io] corebluetooth-rs = { path = "../vendor/corebluetooth-rs" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6f286be..92170dcc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,10 @@ mod grid3; #[cfg_attr(target_os = "linux", allow(dead_code))] mod input; #[cfg(target_os = "linux")] +// Prepared for runtime integration; not started by the unavailable Linux runtime. +#[allow(dead_code)] +mod linux_credential_worker; +#[cfg(target_os = "linux")] mod linux_runtime; #[cfg(target_os = "macos")] mod macos; diff --git a/src-tauri/src/linux_credential_worker.rs b/src-tauri/src/linux_credential_worker.rs new file mode 100644 index 00000000..460b3c90 --- /dev/null +++ b/src-tauri/src/linux_credential_worker.rs @@ -0,0 +1,376 @@ +//! One blocking credential worker; no native operation is started until requested. +//! Caller cancellation invalidates results, not side effects of an in-flight write. +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{sync_channel, SyncSender}, + Arc, +}; +use std::time::Duration; + +use tokio::sync::oneshot; + +use crate::storage::AppStorage; + +const MAX_DEVICE_ID_BYTES: usize = 128; +const MAX_TOKEN_BYTES: usize = 4096; + +pub(crate) trait CredentialBackend: Send + Sync + 'static { + fn load(&self, id: &str) -> Result, String>; + fn save(&self, id: &str, token: &str) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +impl CredentialBackend for AppStorage { + fn load(&self, id: &str) -> Result, String> { + self.load_pairing_token(id) + } + fn save(&self, id: &str, token: &str) -> Result<(), String> { + self.save_pairing_token(id, token) + } + fn delete(&self, id: &str) -> Result<(), String> { + self.delete_pairing_token(id) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkerError { + Busy, + TimedOut, + Cancelled, + Stopped, + InvalidRequest, + StorageUnavailable, +} + +enum Operation { + Load(String), + Save(String, String), + Delete(String), +} +// Intentionally no Debug: replies and operations may hold credential material. +enum Reply { + Loaded(Option), + Done, +} + +struct State { + busy: AtomicBool, + stopped: AtomicBool, + generation: AtomicU64, +} + +struct Request { + operation: Operation, + generation: u64, + cancelled: Arc, + reply: oneshot::Sender>, +} + +struct BusyGuard(Arc); +impl Drop for BusyGuard { + fn drop(&mut self) { + self.0.busy.store(false, Ordering::SeqCst); + } +} + +struct CancellationGuard(Arc); +impl Drop for CancellationGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +pub(crate) struct CredentialWorker { + sender: Option>, + state: Arc, + wait: Duration, +} + +impl CredentialWorker { + pub(crate) fn start( + backend: Arc, + wait: Duration, + ) -> Result { + if wait.is_zero() || wait > Duration::from_secs(30) { + return Err(WorkerError::InvalidRequest); + } + let (sender, receiver) = sync_channel::(1); + let state = Arc::new(State { + busy: AtomicBool::new(false), + stopped: AtomicBool::new(false), + generation: AtomicU64::new(0), + }); + let worker_state = state.clone(); + std::thread::Builder::new() + .name("linux-credentials".into()) + .spawn(move || { + while let Ok(request) = receiver.recv() { + let _busy = BusyGuard(worker_state.clone()); + let current = || { + !worker_state.stopped.load(Ordering::SeqCst) + && worker_state.generation.load(Ordering::SeqCst) == request.generation + && !request.cancelled.load(Ordering::SeqCst) + }; + if !current() { + drop(_busy); + let _ = request.reply.send(Err(WorkerError::Cancelled)); + continue; + } + let result = match &request.operation { + Operation::Load(id) => backend.load(id).map(Reply::Loaded), + Operation::Save(id, token) => backend.save(id, token).map(|()| Reply::Done), + Operation::Delete(id) => backend.delete(id).map(|()| Reply::Done), + } + .map_err(|_| WorkerError::StorageUnavailable); + let result = match result { + Ok(Reply::Loaded(Some(ref token))) + if token.is_empty() || token.len() > MAX_TOKEN_BYTES => + { + Err(WorkerError::StorageUnavailable) + } + other => other, + }; + let result = if current() { + result + } else { + Err(WorkerError::Cancelled) + }; + // Release admission before notifying the next caller. + drop(_busy); + let _ = request.reply.send(result); + } + worker_state.stopped.store(true, Ordering::SeqCst); + }) + .map_err(|_| WorkerError::Stopped)?; + Ok(Self { + sender: Some(sender), + state, + wait, + }) + } + + pub(crate) fn invalidate(&self) { + self.state.generation.fetch_add(1, Ordering::SeqCst); + } + + pub(crate) fn shutdown(&mut self) { + self.state.stopped.store(true, Ordering::SeqCst); + self.invalidate(); + self.sender.take(); + // Never join here: a synchronous Secret Service call may not be cancellable. + } + + async fn request(&self, operation: Operation) -> Result { + if self.state.stopped.load(Ordering::SeqCst) { + return Err(WorkerError::Stopped); + } + if self + .state + .busy + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return Err(WorkerError::Busy); + } + let generation = self.state.generation.load(Ordering::SeqCst); + let cancelled = Arc::new(AtomicBool::new(false)); + let _cancel = CancellationGuard(cancelled.clone()); + let (reply, receive) = oneshot::channel(); + let request = Request { + operation, + generation, + cancelled, + reply, + }; + if self + .sender + .as_ref() + .is_none_or(|sender| sender.try_send(request).is_err()) + { + self.state.busy.store(false, Ordering::SeqCst); + return Err(WorkerError::Stopped); + } + let result = tokio::time::timeout(self.wait, receive) + .await + .map_err(|_| WorkerError::TimedOut)? + .map_err(|_| WorkerError::Stopped)?; + if self.state.stopped.load(Ordering::SeqCst) + || self.state.generation.load(Ordering::SeqCst) != generation + { + return Err(WorkerError::Cancelled); + } + result + } + + fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= MAX_DEVICE_ID_BYTES + } + + pub(crate) async fn load(&self, id: &str) -> Result, WorkerError> { + if !Self::valid_id(id) { + return Err(WorkerError::InvalidRequest); + } + match self.request(Operation::Load(id.into())).await? { + Reply::Loaded(token) => Ok(token), + Reply::Done => Err(WorkerError::Stopped), + } + } + + pub(crate) async fn save(&self, id: &str, token: &str) -> Result<(), WorkerError> { + if !Self::valid_id(id) || token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return Err(WorkerError::InvalidRequest); + } + self.request(Operation::Save(id.into(), token.into())) + .await + .map(|_| ()) + } + + pub(crate) async fn delete(&self, id: &str) -> Result<(), WorkerError> { + if !Self::valid_id(id) { + return Err(WorkerError::InvalidRequest); + } + self.request(Operation::Delete(id.into())).await.map(|_| ()) + } +} + +impl Drop for CredentialWorker { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Condvar, Mutex}; + + #[derive(Default)] + struct Fake { + token: Mutex>, + gate: (Mutex, Condvar), + entered: AtomicBool, + fail: AtomicBool, + } + impl Fake { + fn release(&self) { + *self.gate.0.lock().unwrap() = false; + self.gate.1.notify_all(); + } + } + impl CredentialBackend for Fake { + fn load(&self, _: &str) -> Result, String> { + self.entered.store(true, Ordering::SeqCst); + let mut blocked = self.gate.0.lock().unwrap(); + while *blocked { + blocked = self.gate.1.wait(blocked).unwrap(); + } + if self.fail.load(Ordering::SeqCst) { + return Err("private native detail".into()); + } + Ok(self.token.lock().unwrap().clone()) + } + fn save(&self, _: &str, token: &str) -> Result<(), String> { + *self.token.lock().unwrap() = Some(token.into()); + Ok(()) + } + fn delete(&self, _: &str) -> Result<(), String> { + *self.token.lock().unwrap() = None; + Ok(()) + } + } + async fn wait_until(mut predicate: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(2), async { + while !predicate() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + #[tokio::test] + async fn success_validation_and_sanitized_errors() { + let fake = Arc::new(Fake::default()); + let worker = CredentialWorker::start(fake.clone(), Duration::from_secs(1)).unwrap(); + worker.save("id", "test-token").await.unwrap(); + assert_eq!( + worker.load("id").await.unwrap().as_deref(), + Some("test-token") + ); + worker.delete("id").await.unwrap(); + assert_eq!(worker.load("id").await.unwrap(), None); + assert_eq!(worker.load("").await, Err(WorkerError::InvalidRequest)); + assert_eq!( + worker.delete(&"x".repeat(MAX_DEVICE_ID_BYTES + 1)).await, + Err(WorkerError::InvalidRequest) + ); + assert_eq!( + worker.save("id", &"x".repeat(MAX_TOKEN_BYTES + 1)).await, + Err(WorkerError::InvalidRequest) + ); + for token in [String::new(), "x".repeat(MAX_TOKEN_BYTES + 1)] { + *fake.token.lock().unwrap() = Some(token); + assert_eq!( + worker.load("id").await, + Err(WorkerError::StorageUnavailable) + ); + } + fake.fail.store(true, Ordering::SeqCst); + assert_eq!( + worker.load("id").await, + Err(WorkerError::StorageUnavailable) + ); + } + #[tokio::test(start_paused = true)] + async fn timeout_keeps_single_worker_busy_until_backend_returns() { + let fake = Arc::new(Fake::default()); + *fake.gate.0.lock().unwrap() = true; + let worker = CredentialWorker::start(fake.clone(), Duration::from_millis(50)).unwrap(); + let request = worker.load("id"); + tokio::pin!(request); + tokio::select! { + _ = &mut request => panic!("request must remain blocked"), + _ = wait_until(|| fake.entered.load(Ordering::SeqCst)) => (), + } + tokio::time::advance(Duration::from_millis(51)).await; + assert_eq!(request.await, Err(WorkerError::TimedOut)); + assert_eq!(worker.load("id").await, Err(WorkerError::Busy)); + tokio::time::resume(); + fake.release(); + wait_until(|| !worker.state.busy.load(Ordering::SeqCst)).await; + assert_eq!(worker.load("id").await.unwrap(), None); + } + #[tokio::test] + async fn invalidation_discards_inflight_result_and_allows_recovery() { + let fake = Arc::new(Fake::default()); + *fake.gate.0.lock().unwrap() = true; + let worker = CredentialWorker::start(fake.clone(), Duration::from_secs(1)).unwrap(); + let request = worker.load("id"); + tokio::pin!(request); + tokio::select! { + _ = &mut request => panic!("request must remain blocked"), + _ = wait_until(|| fake.entered.load(Ordering::SeqCst)) => (), + } + worker.invalidate(); + fake.release(); + assert_eq!(request.await, Err(WorkerError::Cancelled)); + assert_eq!(worker.load("id").await.unwrap(), None); + } + #[tokio::test] + async fn dropped_future_and_shutdown_do_not_wait_for_blocked_backend() { + let fake = Arc::new(Fake::default()); + *fake.gate.0.lock().unwrap() = true; + let mut worker = CredentialWorker::start(fake.clone(), Duration::from_secs(1)).unwrap(); + { + let request = worker.load("id"); + tokio::pin!(request); + tokio::select! { + _ = &mut request => panic!("request must remain blocked"), + _ = wait_until(|| fake.entered.load(Ordering::SeqCst)) => (), + } + } + assert_eq!(worker.load("id").await, Err(WorkerError::Busy)); + worker.shutdown(); + assert_eq!(worker.load("id").await, Err(WorkerError::Stopped)); + fake.release(); + } +} 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");