* fix(keychain): repair stale macOS keychain ACLs before reads macOS records the trusted application of a legacy keychain item as the executable *path* for an ad-hoc (dev/linker-signed) build, and as a code requirement + cdhash partition list for a signed bundle. When the path changes — the monorepo move (`src-tauri/` -> `desktop/src-tauri/`), a reinstalled app, running the installed app after a dev build — the ACL no longer admits the running binary, so macOS demands the keychain password on *every* access: once per secret, every time, with no "Always Allow". The pin only ran after writes, and writing to an inaccessible item needs the same authorization, so the ACL was never repaired. - keychain_acl::is_item_trusted() answers "am I already in this item's ACL?" with a metadata-only, prompt-free lookup (SecKeychainItemCopyAccess + SecAccessCopyACLList + comparison against this process's own SecTrustedApplication representation; a signed bundle's `cdhash:` partition lists count as trusted and are left alone). - ensure_item_acl() now no-ops when already trusted (no duplicate ACL entries, no redundant authorization) and otherwise pins exactly as before. - New before_read() hook, called from every keychain read path (DB password, SSH password, SSH passphrase — commands and internal helpers), so a stale item costs one authorization and is silent from then on. - ItemSearchOptions reports "nothing matched" as errSecItemNotFound, which is now handled as "no item" instead of a lookup error — previously every connection without a stored password logged a spurious failure. Verified: cargo test --lib (408 passed) plus the ignored live test (`cargo test --lib -- --ignored keychain_acl`), which asserts a fresh item is already trusted, repairs are idempotent and the secret still reads back. That test now finishes in 0.25s instead of ~8s because the redundant SecKeychainItemSetAccess is gone. * chore(release): bump 0.8.1 and document GitHub-only releases Version 0.8.0 -> 0.8.1 across every synced location: desktop/package.json, desktop/src-tauri/Cargo.toml (+ Cargo.lock gridline entry), tauri.conf.json, www/src/pages/index.astro, www/public/landing.js, both README download tables (+ the MAINTENANCE note), and the version/docs-coverage/bundle-config tests. Adds the 0.8.1 changelog entry (site + README Recent Changes). AGENTS.md: releases are GitHub-only — tag `prod` and `git push github vN.M.N` (origin/Gitea is a just-in-case mirror; never push branches, PRs or tags there), and the keychain row documents the self-healing ACL repair.
209 lines
6.5 KiB
Rust
209 lines
6.5 KiB
Rust
use crate::keychain_acl;
|
|
use tauri_plugin_keyring_store::KeyringExt;
|
|
|
|
/// Store a connection password in the OS keychain.
|
|
/// The connection ID is used as the keyring account name.
|
|
#[tauri::command]
|
|
pub fn save_connection_password(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
password: String,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.set_password(&connection_id, &password)
|
|
.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.
|
|
/// Returns None if no password was stored for this connection.
|
|
#[tauri::command]
|
|
pub fn get_connection_password(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
) -> Result<Option<String>, String> {
|
|
// Repair a stale item ACL first: after a rebuild/path change macOS would
|
|
// otherwise ask for the keychain password on every access (once per secret).
|
|
keychain_acl::before_read(&app, &connection_id);
|
|
app.keyring()
|
|
.store
|
|
.get_password(&connection_id)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Retrieve a connection password from the OS keychain (internal helper).
|
|
/// Returns None if no password was stored for this connection.
|
|
pub fn get_connection_password_internal(
|
|
app: &tauri::AppHandle,
|
|
connection_id: &str,
|
|
) -> Result<Option<String>, String> {
|
|
keychain_acl::before_read(app, connection_id);
|
|
app.keyring()
|
|
.store
|
|
.get_password(connection_id)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Build the keyring account name for an SSH secret (password or passphrase).
|
|
/// The `connection_id` namespaces each secret per connection.
|
|
pub fn ssh_account(kind: &str, connection_id: &str) -> String {
|
|
format!("ssh_{kind}:{connection_id}")
|
|
}
|
|
|
|
/// Store an SSH tunnel password in the OS keychain.
|
|
#[tauri::command]
|
|
pub fn save_connection_ssh_password(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
password: String,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.set_password(&ssh_account("password", &connection_id), &password)
|
|
.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.
|
|
/// Returns None if no SSH password was stored for this connection.
|
|
#[tauri::command]
|
|
pub fn get_connection_ssh_password(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
) -> Result<Option<String>, String> {
|
|
keychain_acl::before_read(&app, &ssh_account("password", &connection_id));
|
|
app.keyring()
|
|
.store
|
|
.get_password(&ssh_account("password", &connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete an SSH tunnel password from the OS keychain.
|
|
#[tauri::command]
|
|
pub fn delete_connection_ssh_password(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.delete(&ssh_account("password", &connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Store an SSH private-key passphrase in the OS keychain.
|
|
#[tauri::command]
|
|
pub fn save_connection_ssh_passphrase(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
passphrase: String,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
|
.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.
|
|
/// Returns None if no passphrase was stored for this connection.
|
|
#[tauri::command]
|
|
pub fn get_connection_ssh_passphrase(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
) -> Result<Option<String>, String> {
|
|
keychain_acl::before_read(&app, &ssh_account("passphrase", &connection_id));
|
|
app.keyring()
|
|
.store
|
|
.get_password(&ssh_account("passphrase", &connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete an SSH private-key passphrase from the OS keychain.
|
|
#[tauri::command]
|
|
pub fn delete_connection_ssh_passphrase(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.delete(&ssh_account("passphrase", &connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Retrieve an SSH tunnel password from the OS keychain (internal helper).
|
|
/// Returns None if no SSH password was stored for this connection.
|
|
pub fn get_connection_ssh_password_internal(
|
|
app: &tauri::AppHandle,
|
|
connection_id: &str,
|
|
) -> Result<Option<String>, String> {
|
|
keychain_acl::before_read(app, &ssh_account("password", connection_id));
|
|
app.keyring()
|
|
.store
|
|
.get_password(&ssh_account("password", connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete an SSH tunnel password from the OS keychain (internal helper).
|
|
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
|
pub fn delete_connection_ssh_password_internal(
|
|
app: &tauri::AppHandle,
|
|
connection_id: &str,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.delete(&ssh_account("password", connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete an SSH private-key passphrase from the OS keychain (internal helper).
|
|
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
|
pub fn delete_connection_ssh_passphrase_internal(
|
|
app: &tauri::AppHandle,
|
|
connection_id: &str,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.delete(&ssh_account("passphrase", connection_id))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete a connection password from the OS keychain.
|
|
#[tauri::command]
|
|
pub fn delete_connection_password(
|
|
app: tauri::AppHandle,
|
|
connection_id: String,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.delete(&connection_id)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Delete a connection password from the OS keychain (internal helper).
|
|
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
|
pub fn delete_connection_password_internal(
|
|
app: &tauri::AppHandle,
|
|
connection_id: &str,
|
|
) -> Result<(), String> {
|
|
app.keyring()
|
|
.store
|
|
.delete(connection_id)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ssh_account_namespaces_password() {
|
|
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
|
|
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
|
|
}
|
|
}
|