199 lines
8.1 KiB
Rust
199 lines
8.1 KiB
Rust
//! Keychain ACL normalization for stored secrets (macOS).
|
|
//!
|
|
//! # Why this exists
|
|
//!
|
|
//! Generic-password items stored via the legacy keychain API get a default ACL
|
|
//! that grants access to "the application that created the item". When a Tauri
|
|
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
|
|
//! the identity differs on every build), and the new binary no longer matches
|
|
//! the item's ACL — macOS then shows the "Gridline wants to access your
|
|
//! keychain" prompt.
|
|
//!
|
|
//! The fix: after storing a secret, replace the item's ACL with an explicit
|
|
//! `SecAccess` whose trusted-application list contains exactly *this app*,
|
|
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
|
|
//! process's code-signature requirement. For a Developer ID-signed release
|
|
//! build that requirement is stable across versions (anchored to the signing
|
|
//! certificate), so items stay accessible without prompts through upgrades.
|
|
//!
|
|
//! # Prompt behavior
|
|
//!
|
|
//! - Item created by this build → the ACL set is silent.
|
|
//! - Item created by an older/differently-signed build → macOS shows the
|
|
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
|
|
//! and the prompt never reappears (the same dialog the app would show on the
|
|
//! first read anyway — this just makes the result permanent and clean).
|
|
//!
|
|
//! All operations are best-effort: `after_store` swallows errors so keychain
|
|
//! writes never fail because of ACL normalization.
|
|
|
|
/// Best-effort ACL normalization after a successful keychain write.
|
|
///
|
|
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
|
|
/// (the Tauri bundle identifier) and never fails the caller's operation.
|
|
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
|
let service = app.config().identifier.clone();
|
|
if let Err(e) = ensure_item_acl(&service, account) {
|
|
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
|
}
|
|
}
|
|
|
|
/// Grant this app (by its code-signature requirement) access to a keychain item.
|
|
#[cfg(target_os = "macos")]
|
|
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
|
use core_foundation::array::CFArray;
|
|
use core_foundation::base::{CFTypeRef, TCFType};
|
|
use core_foundation::string::CFString;
|
|
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
|
use security_framework::os::macos::access::SecAccess;
|
|
use security_framework_sys::base::SecAccessRef;
|
|
|
|
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
|
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
|
// prompt, so merely probing an item we can't yet access is safe.
|
|
let found = ItemSearchOptions::new()
|
|
.class(ItemClass::generic_password())
|
|
.service(service)
|
|
.account(account)
|
|
.load_refs(true)
|
|
.load_data(false)
|
|
.load_attributes(false)
|
|
.search()
|
|
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
|
|
|
|
let item = match found.first() {
|
|
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
|
|
// No item (yet) or an unexpected result shape — nothing to normalize.
|
|
_ => return Ok(()),
|
|
};
|
|
|
|
fn cvt(status: i32) -> Result<(), String> {
|
|
if status == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(format!("security framework error {status}"))
|
|
}
|
|
}
|
|
|
|
// Build an access object whose trusted list is exactly this app. A NULL
|
|
// path resolves the trusted application to the current process's code
|
|
// signature requirement.
|
|
unsafe {
|
|
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
|
|
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
|
|
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
|
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
|
|
|
|
let descriptor = CFString::new("Gridline");
|
|
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
|
cvt(SecAccessCreate(
|
|
descriptor.as_concrete_TypeRef() as CFTypeRef,
|
|
trusted_list.as_concrete_TypeRef() as CFTypeRef,
|
|
&mut access_ref,
|
|
))?;
|
|
let access = SecAccess::wrap_under_create_rule(access_ref);
|
|
|
|
// Replace the item's ACL. Silent when we already own the item;
|
|
// one-time authorization dialog when the item was created by a
|
|
// differently-signed build.
|
|
cvt(SecKeychainItemSetAccess(
|
|
item.as_concrete_TypeRef(),
|
|
access.as_concrete_TypeRef(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod macos_ffi {
|
|
use core_foundation::base::{CFTypeRef, CFTypeID};
|
|
|
|
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
|
/// security-framework crate exposes no high-level API for it.
|
|
#[repr(C)]
|
|
pub struct OpaqueSecTrustedApplication {
|
|
_priv: [u8; 0],
|
|
}
|
|
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
|
|
|
|
use core_foundation::{declare_TCFType, impl_TCFType};
|
|
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
|
|
impl_TCFType!(
|
|
SecTrustedApplication,
|
|
SecTrustedApplicationRef,
|
|
SecTrustedApplicationGetTypeID
|
|
);
|
|
unsafe impl Send for SecTrustedApplication {}
|
|
unsafe impl Sync for SecTrustedApplication {}
|
|
|
|
extern "C" {
|
|
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
|
|
// but remain fully functional (the legacy keychain store this app uses
|
|
// via tauri-plugin-keyring-store relies on the same API family).
|
|
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
|
|
pub fn SecTrustedApplicationCreateFromPath(
|
|
path: CFTypeRef,
|
|
app: *mut SecTrustedApplicationRef,
|
|
) -> i32;
|
|
pub fn SecAccessCreate(
|
|
descriptor: CFTypeRef,
|
|
trustedlist: CFTypeRef,
|
|
access: *mut security_framework_sys::base::SecAccessRef,
|
|
) -> i32;
|
|
pub fn SecKeychainItemSetAccess(
|
|
item: security_framework_sys::base::SecKeychainItemRef,
|
|
access: security_framework_sys::base::SecAccessRef,
|
|
) -> i32;
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
use macos_ffi::{
|
|
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
|
|
SecTrustedApplicationCreateFromPath,
|
|
};
|
|
|
|
#[cfg(all(test, target_os = "macos"))]
|
|
mod tests {
|
|
use super::*;
|
|
use security_framework::os::macos::keychain::SecKeychain;
|
|
use security_framework::os::macos::passwords::find_generic_password;
|
|
|
|
/// Real-keychain integration test: creates a generic-password item the
|
|
/// same way the keyring store does, normalizes its ACL, verifies the
|
|
/// secret is still readable, and cleans up.
|
|
///
|
|
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
|
/// keychain. Run explicitly with:
|
|
/// `cargo test -- --ignored keychain_acl`
|
|
#[test]
|
|
#[ignore = "touches the real login keychain"]
|
|
fn keychain_acl_normalizes_item() {
|
|
let keychain = SecKeychain::default().expect("default keychain");
|
|
let service = "com.adrianbonpin.gridline.acl-test";
|
|
let account = format!("acl-test-{}", std::process::id());
|
|
|
|
// Create the item exactly like apple-native-keyring-store does
|
|
// (legacy SecKeychain generic-password API).
|
|
keychain
|
|
.set_generic_password(service, &account, b"test-secret")
|
|
.expect("create test item");
|
|
|
|
// ACL normalization must succeed on an item we just created.
|
|
ensure_item_acl(service, &account).expect("normalize ACL");
|
|
|
|
// The item must remain readable with the correct secret.
|
|
let (password, item) =
|
|
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
|
|
assert_eq!(password.as_ref(), b"test-secret");
|
|
|
|
// Cleanup: delete the test item.
|
|
item.delete();
|
|
}
|
|
}
|