From a1fba89e0708b60b3d9faff4cb0c4c9560a07d93 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Fri, 4 Sep 2026 11:41:42 +0800 Subject: [PATCH] feat: pin keychain item ACLs to the app's code signature (macOS) After every keychain write, replace the item's ACL with an explicit SecAccess whose trusted-application list is this app's code-signature requirement (SecTrustedApplicationCreateFromPath(NULL)): - New installs: items are ACL-pinned from the first save, so future upgrades never prompt (Developer ID requirement is stable). - Upgraders from ad-hoc builds: the first access shows the standard one-time prompt; 'Always Allow' now permanently rewrites the ACL instead of just appending, so stale ad-hoc entries are cleaned up. - Ref-only item lookup (kSecReturnRef) never prompts, and failures are swallowed so keychain writes never fail because of ACL work. --- AGENTS.md | 2 +- desktop/src-tauri/Cargo.lock | 3 + desktop/src-tauri/Cargo.toml | 9 + desktop/src-tauri/src/commands/keychain.rs | 14 +- desktop/src-tauri/src/keychain_acl.rs | 198 +++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 desktop/src-tauri/src/keychain_acl.rs diff --git a/AGENTS.md b/AGENTS.md index e7b05e7..d473e17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,7 +244,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite | | DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly | | DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) | -| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager | +| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts | | Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) | | SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields | | SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:` / `ssh_passphrase:`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel | diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e3513dc..0b2cb6c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1787,6 +1787,7 @@ version = "0.7.13" dependencies = [ "bytes", "chrono", + "core-foundation 0.10.1", "deadpool-postgres", "indexmap 2.14.0", "redis", @@ -1795,6 +1796,8 @@ dependencies = [ "rustls-native-certs", "rustls-pemfile 2.2.0", "rustls-pki-types", + "security-framework 3.7.0", + "security-framework-sys", "serde", "serde_json", "sqlx", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fa40fc1..d231f1d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -49,3 +49,12 @@ rustls-pemfile = "2" rustls-pki-types = "1" rustls-native-certs = "0.7" +# Keychain ACL normalization (explicit SecAccess on stored secrets so items +# survive app re-signs without permission prompts) — macOS only. +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3.7" +security-framework-sys = "2.17" +# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI +# (already a transitive dep of security-framework; pinned to the same 0.10 line) +core-foundation = "0.10" + diff --git a/desktop/src-tauri/src/commands/keychain.rs b/desktop/src-tauri/src/commands/keychain.rs index 1467ef8..a5e512e 100644 --- a/desktop/src-tauri/src/commands/keychain.rs +++ b/desktop/src-tauri/src/commands/keychain.rs @@ -1,3 +1,4 @@ +use crate::keychain_acl; use tauri_plugin_keyring_store::KeyringExt; /// Store a connection password in the OS keychain. @@ -11,7 +12,10 @@ pub fn save_connection_password( app.keyring() .store .set_password(&connection_id, &password) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + // Best-effort: pin the item's ACL to this app so re-signed builds never prompt. + keychain_acl::after_store(&app, &connection_id); + Ok(()) } /// Retrieve a connection password from the OS keychain. @@ -55,7 +59,9 @@ pub fn save_connection_ssh_password( app.keyring() .store .set_password(&ssh_account("password", &connection_id), &password) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + keychain_acl::after_store(&app, &ssh_account("password", &connection_id)); + Ok(()) } /// Retrieve an SSH tunnel password from the OS keychain. @@ -93,7 +99,9 @@ pub fn save_connection_ssh_passphrase( app.keyring() .store .set_password(&ssh_account("passphrase", &connection_id), &passphrase) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id)); + Ok(()) } /// Retrieve an SSH private-key passphrase from the OS keychain. diff --git a/desktop/src-tauri/src/keychain_acl.rs b/desktop/src-tauri/src/keychain_acl.rs new file mode 100644 index 0000000..123cef6 --- /dev/null +++ b/desktop/src-tauri/src/keychain_acl.rs @@ -0,0 +1,198 @@ +//! 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(); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6ec4e2d..2d7394b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod cancel; mod commands; mod db; +mod keychain_acl; mod models; mod store; -- 2.54.0