Problem
During commit (and artifact) signing, macOS shows two separate password prompts back-to-back:
auths-sign wants to use your confidential information stored in "dev.auths.agent" in your keychain
auths-sign wants to use your confidential information stored in "dev.auths.password" in your keychain
This feels redundant and confusing to users. There is no reason to prompt twice — both reads happen in the same signing operation for the same key alias.
Root Cause
Two independent SecItemCopyMatching calls are made against separate keychain services:
MacOsPassphraseCache::load() → dev.auths.passphrase (crates/auths-core/src/storage/passphrase_cache.rs)
MacOSKeychain::load_key() → dev.auths.agent (crates/auths-core/src/storage/macos_keychain.rs)
macOS prompts once per protected keychain item access, so two items = two prompts.
Proposed Fix
Use a shared LAContext (from LocalAuthentication.framework) evaluated once before both reads. Pass it to each SecItemCopyMatching call via kSecUseAuthenticationContext. macOS will recognise the context is already authorised and skip the second prompt.
Implementation outline
-
New AuthContext helper (crates/auths-core/src/storage/auth_context.rs, macOS only)
- Wraps an
LAContext ObjC object
AuthContext::evaluate(reason: &str) — calls evaluatePolicy:localizedReason:reply: synchronously via a dispatch semaphore, returning an authorised context or an error
- Exposes
as_cf_type_ref() for use as kSecUseAuthenticationContext
-
Context-aware variants on both keychain structs
MacOSKeychain::load_key_with_context(&self, alias, ctx: &AuthContext)
MacOsPassphraseCache::load_with_context(&self, alias, ctx: &AuthContext)
- Both inject
kSecUseAuthenticationContext into their CFDictionary query; existing no-context methods remain unchanged for CI / file-fallback paths
-
Single evaluation point in the signing workflow
In load_key_with_passphrase_retry (crates/auths-sdk/src/workflows/signing.rs):
#[cfg(target_os = "macos")]
let auth_ctx = AuthContext::evaluate("Auths commit signing").ok();
// Both subsequent reads receive auth_ctx
If evaluation fails (user cancels, item not biometric-protected), auth_ctx is None and both calls fall back to their existing behaviour — no regression for CI or non-biometric setups.
-
Propagate through KeychainPassphraseProvider
Add get_passphrase_with_context(prompt, Option<&AuthContext>) alongside the existing get_passphrase.
Dependencies
Check whether objc2 / objc2-local-authentication is already in the tree. If not, a small extern "C" FFI shim against LocalAuthentication.framework avoids adding a new dep entirely.
Files to change
| File |
Change |
crates/auths-core/src/storage/auth_context.rs |
New — AuthContext wrapper |
crates/auths-core/src/storage/macos_keychain.rs |
Add load_key_with_context |
crates/auths-core/src/storage/passphrase_cache.rs |
Add load_with_context |
crates/auths-core/src/storage/mod.rs |
Export auth_context |
crates/auths-core/src/signing.rs |
Add get_passphrase_with_context on KeychainPassphraseProvider |
crates/auths-sdk/src/workflows/signing.rs |
Create AuthContext once, pass to both reads |
Acceptance Criteria
- macOS shows exactly one keychain password dialog per signing operation
- CI environments (no keychain /
PrefilledPassphraseProvider) are unaffected
cargo nextest run --workspace passes on macOS, Linux, and Windows
Problem
During commit (and artifact) signing, macOS shows two separate password prompts back-to-back:
auths-sign wants to use your confidential information stored in "dev.auths.agent" in your keychainauths-sign wants to use your confidential information stored in "dev.auths.password" in your keychainThis feels redundant and confusing to users. There is no reason to prompt twice — both reads happen in the same signing operation for the same key alias.
Root Cause
Two independent
SecItemCopyMatchingcalls are made against separate keychain services:MacOsPassphraseCache::load()→dev.auths.passphrase(crates/auths-core/src/storage/passphrase_cache.rs)MacOSKeychain::load_key()→dev.auths.agent(crates/auths-core/src/storage/macos_keychain.rs)macOS prompts once per protected keychain item access, so two items = two prompts.
Proposed Fix
Use a shared
LAContext(fromLocalAuthentication.framework) evaluated once before both reads. Pass it to eachSecItemCopyMatchingcall viakSecUseAuthenticationContext. macOS will recognise the context is already authorised and skip the second prompt.Implementation outline
New
AuthContexthelper (crates/auths-core/src/storage/auth_context.rs, macOS only)LAContextObjC objectAuthContext::evaluate(reason: &str)— callsevaluatePolicy:localizedReason:reply:synchronously via a dispatch semaphore, returning an authorised context or an erroras_cf_type_ref()for use askSecUseAuthenticationContextContext-aware variants on both keychain structs
MacOSKeychain::load_key_with_context(&self, alias, ctx: &AuthContext)MacOsPassphraseCache::load_with_context(&self, alias, ctx: &AuthContext)kSecUseAuthenticationContextinto their CFDictionary query; existing no-context methods remain unchanged for CI / file-fallback pathsSingle evaluation point in the signing workflow
In
load_key_with_passphrase_retry(crates/auths-sdk/src/workflows/signing.rs):If evaluation fails (user cancels, item not biometric-protected),
auth_ctxisNoneand both calls fall back to their existing behaviour — no regression for CI or non-biometric setups.Propagate through
KeychainPassphraseProviderAdd
get_passphrase_with_context(prompt, Option<&AuthContext>)alongside the existingget_passphrase.Dependencies
Check whether
objc2/objc2-local-authenticationis already in the tree. If not, a smallextern "C"FFI shim againstLocalAuthentication.frameworkavoids adding a new dep entirely.Files to change
crates/auths-core/src/storage/auth_context.rsAuthContextwrappercrates/auths-core/src/storage/macos_keychain.rsload_key_with_contextcrates/auths-core/src/storage/passphrase_cache.rsload_with_contextcrates/auths-core/src/storage/mod.rsauth_contextcrates/auths-core/src/signing.rsget_passphrase_with_contextonKeychainPassphraseProvidercrates/auths-sdk/src/workflows/signing.rsAuthContextonce, pass to both readsAcceptance Criteria
PrefilledPassphraseProvider) are unaffectedcargo nextest run --workspacepasses on macOS, Linux, and Windows