diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 662f99a2f..3aced7026 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ ctrlc = { version = "3", features = ["termination"] } [target.'cfg(target_os = "macos")'.dependencies] objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } +security-framework = { version = "3.7.0", features = ["OSX_10_15"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] } diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index b19be2955..93b35838a 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -63,10 +63,76 @@ fn keyring_entry(service: &str, key: &str) -> Result bool { + e.code() == -25300 +} + +/// Returns true when DPK is unavailable because the binary lacks the required +/// entitlement (`errSecMissingEntitlement`, -34018). This happens for unsigned +/// dev builds (`tauri dev` / `cargo run`). The caller should fall back to the +/// legacy `keyring` crate path, which uses the old-style keychain and does not +/// require hardened-runtime entitlements. +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +fn is_dpk_unavailable(e: &SFError) -> bool { + e.code() == -34018 +} + +/// Build a `PasswordOptions` for the Data Protection Keychain. +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +fn dpk_opts(service: &str, key: &str) -> PasswordOptions { + let mut opts = PasswordOptions::new_generic_password(service, key); + opts.use_protected_keychain(); + opts +} + impl SecretStore { /// Probe whether `key` exists and whether the backend is reachable. pub fn probe(&self, key: &str) -> KeyringProbe { - #[cfg(feature = "system-keyring")] + // macOS: probe the Data Protection Keychain first. If DPK is + // unavailable (unsigned dev build), fall back to the legacy keyring + // crate path. Items still in the old keychain will be migrated on the + // first `load` call. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + { + match generic_password(dpk_opts(&self.service, key)) { + Ok(_) => KeyringProbe::Present, + Err(ref e) if is_not_found(e) => KeyringProbe::ReachableButEmpty, + Err(ref e) if is_dpk_unavailable(e) => { + // DPK unavailable (unsigned build) — fall back to keyring. + match keyring_entry(&self.service, key) { + Ok(entry) => match entry.get_password() { + Ok(_) => KeyringProbe::Present, + Err(keyring::Error::NoEntry) => KeyringProbe::ReachableButEmpty, + Err(e) if is_keyring_availability_error(&e.to_string()) => { + KeyringProbe::Unreachable + } + Err(_) => KeyringProbe::ReachableButEmpty, + }, + Err(e) if is_keyring_availability_error(&e.to_string()) => { + KeyringProbe::Unreachable + } + Err(_) => KeyringProbe::Unreachable, + } + } + Err(ref e) if is_keyring_availability_error(&e.to_string()) => { + KeyringProbe::Unreachable + } + Err(_) => KeyringProbe::ReachableButEmpty, + } + } + // Non-macOS system-keyring path (Windows, Linux). + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] { match keyring_entry(&self.service, key) { Ok(entry) => match entry.get_password() { @@ -75,8 +141,6 @@ impl SecretStore { Err(e) if is_keyring_availability_error(&e.to_string()) => { KeyringProbe::Unreachable } - // A non-availability per-entry error (e.g. bad attributes) - // means the backend is reachable but the entry is unusable. Err(_) => KeyringProbe::ReachableButEmpty, }, Err(e) if is_keyring_availability_error(&e.to_string()) => { @@ -95,7 +159,47 @@ impl SecretStore { /// Load the secret for `key`. `Ok(None)` when there is no entry; `Err` only /// when the backend errored in a way that is not "missing". pub fn load(&self, key: &str) -> Result, String> { - #[cfg(feature = "system-keyring")] + // macOS: try Data Protection Keychain first; fall back to old keychain + // and migrate on a miss (one-time per item). If DPK is unavailable + // (unsigned dev build, errSecMissingEntitlement), use the legacy + // keyring crate path directly — no migration needed in that case. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + { + match generic_password(dpk_opts(&self.service, key)) { + Ok(bytes) => String::from_utf8(bytes) + .map(Some) + .map_err(|e| format!("keyring utf8: {e}")), + Err(ref e) if is_not_found(e) => { + // Not in DPK — check old keychain and migrate if found. + let entry = keyring_entry(&self.service, key) + .map_err(|e| format!("keyring entry: {e}"))?; + match entry.get_password() { + Ok(old_val) => { + // Migrate to DPK. + self.store(key, &old_val)?; + // Best-effort cleanup from old keychain. + let _ = entry.delete_credential(); + Ok(Some(old_val)) + } + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keyring get: {e}")), + } + } + Err(ref e) if is_dpk_unavailable(e) => { + // DPK unavailable (unsigned build) — use keyring directly. + let entry = keyring_entry(&self.service, key) + .map_err(|e| format!("keyring entry: {e}"))?; + match entry.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keyring get: {e}")), + } + } + Err(e) => Err(format!("keyring get: {e}")), + } + } + // Non-macOS system-keyring path (Windows, Linux). + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] { let entry = keyring_entry(&self.service, key).map_err(|e| format!("keyring entry: {e}"))?; @@ -115,7 +219,26 @@ impl SecretStore { /// Store `value` for `key`. Reports `Err` on availability failures — callers /// decide whether to fall back to file storage. pub fn store(&self, key: &str, value: &str) -> Result<(), String> { - #[cfg(feature = "system-keyring")] + // macOS: write directly to the Data Protection Keychain. If DPK is + // unavailable (unsigned dev build), fall back to the legacy keyring + // crate path. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + { + match set_generic_password_options(value.as_bytes(), dpk_opts(&self.service, key)) { + Ok(()) => Ok(()), + Err(ref e) if is_dpk_unavailable(e) => { + // DPK unavailable (unsigned build) — use keyring directly. + let entry = keyring_entry(&self.service, key) + .map_err(|e| format!("keyring entry: {e}"))?; + entry + .set_password(value) + .map_err(|e| format!("keyring set: {e}")) + } + Err(e) => Err(format!("keyring set: {e}")), + } + } + // Non-macOS system-keyring path (Windows, Linux). + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] { let entry = keyring_entry(&self.service, key).map_err(|e| format!("keyring entry: {e}"))?; @@ -132,7 +255,39 @@ impl SecretStore { /// Delete the secret for `key`. A missing entry is not an error. pub fn delete(&self, key: &str) -> Result<(), String> { - #[cfg(feature = "system-keyring")] + // macOS: delete from both DPK and old keychain (best-effort on old). + // If DPK is unavailable (unsigned dev build), fall back to the legacy + // keyring crate path. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + { + // Delete from Data Protection Keychain; missing is fine. + match delete_generic_password_options(dpk_opts(&self.service, key)) { + Ok(()) => {} + Err(ref e) if is_not_found(e) => {} + Err(ref e) if is_dpk_unavailable(e) => { + // DPK unavailable (unsigned build) — use keyring directly. + let entry = keyring_entry(&self.service, key) + .map_err(|e| format!("keyring entry: {e}"))?; + return match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("keyring delete: {e}")), + }; + } + Err(e) => return Err(format!("keyring delete: {e}")), + } + // Best-effort cleanup from old keychain. + if let Ok(entry) = keyring_entry(&self.service, key) { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) => { + eprintln!("buzz-desktop: old-keychain delete for {key}: {e}"); + } + } + } + Ok(()) + } + // Non-macOS system-keyring path (Windows, Linux). + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] { let entry = keyring_entry(&self.service, key).map_err(|e| format!("keyring entry: {e}"))?; @@ -166,4 +321,18 @@ mod tests { // A plain "not found" is per-entry, not an availability failure. assert!(!is_keyring_availability_error("entry not found")); } + + #[cfg(target_os = "macos")] + #[test] + fn dpk_unavailable_discriminator() { + // errSecMissingEntitlement = -34018 signals unsigned dev build. + let e = SFError::from_code(-34018); + assert!(is_dpk_unavailable(&e)); + // errSecItemNotFound = -25300 is not a DPK-unavailable error. + let e = SFError::from_code(-25300); + assert!(!is_dpk_unavailable(&e)); + // errSecDuplicateItem = -25299 is not a DPK-unavailable error. + let e = SFError::from_code(-25299); + assert!(!is_dpk_unavailable(&e)); + } }