fix(desktop): switch macOS keychain to Data Protection Keychain

The keyring crate uses SecKeychainAddGenericPassword with a null ACL,
which creates items requiring user confirmation on every access — even
by the same app. This causes ~20 prompts on migration launch and 7 on
every subsequent launch.

Switch the macOS backend to the modern SecItem API with
kSecUseDataProtectionKeychain=true via security-framework's
PasswordOptions::use_protected_keychain(). Items stored this way use
kSecAttrAccessible instead of ACLs and never prompt after the first
device unlock.

Migration path: load() tries DPK first; on a miss it falls back to the
old keychain, writes the value to DPK, and deletes the old entry. First
launch after this fix prompts once per key (7 total), then zero forever.
Unsigned dev builds (tauri dev / cargo run) lack the hardened-runtime
entitlement required by DPK and fall back to the legacy keyring path
automatically (errSecMissingEntitlement, -34018). Windows and Linux
paths are unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-24 22:37:56 -04:00
co-authored by Will Pfleger
parent cee2c5f263
commit 7bfdf3e19d
2 changed files with 176 additions and 6 deletions
+1
View File
@@ -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"] }
+175 -6
View File
@@ -63,10 +63,76 @@ fn keyring_entry(service: &str, key: &str) -> Result<keyring::Entry, keyring::Er
keyring::Entry::new(service, key)
}
// macOS-specific imports for the Data Protection Keychain backend.
#[cfg(all(feature = "system-keyring", target_os = "macos"))]
use security_framework::base::Error as SFError;
#[cfg(all(feature = "system-keyring", target_os = "macos"))]
use security_framework::passwords::{
delete_generic_password_options, generic_password, set_generic_password_options,
PasswordOptions,
};
/// Returns true when the security-framework error is "item not found" (-25300).
#[cfg(all(feature = "system-keyring", target_os = "macos"))]
fn is_not_found(e: &SFError) -> 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<Option<String>, 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));
}
}