Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/linux-credentials.md
Original file line number Diff line number Diff line change
@@ -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).
171 changes: 170 additions & 1 deletion src-tauri/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,75 @@ fn platform_pairing_token_store(state_path: &Path) -> Box<dyn PairingTokenStore>
))
}

#[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<Box<dyn PairingTokenStore>>,
}

#[cfg(any(target_os = "linux", test))]
impl LinuxPairingTokenStore {
fn new(secure: Box<dyn PairingTokenStore>) -> 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<Option<String>, 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<dyn PairingTokenStore> {
Box::new(LinuxPairingTokenStore::new(
Box::<KeyringPairingTokenStore>::default(),
))
}

#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn platform_pairing_token_store(_state_path: &Path) -> Box<dyn PairingTokenStore> {
Box::<KeyringPairingTokenStore>::default()
}
Expand Down Expand Up @@ -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");
Expand Down