Skip to content

UX: consolidate dual macOS keychain prompts into a single password request during signing #160

@bordumb

Description

@bordumb

Problem

During commit (and artifact) signing, macOS shows two separate password prompts back-to-back:

  1. auths-sign wants to use your confidential information stored in "dev.auths.agent" in your keychain
  2. 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

  1. 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
  2. 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
  3. 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.

  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions