mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
cee2c5f263
commit
7bfdf3e19d
@@ -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"] }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user