Skip to content

keyring provider: macOS prompts for password after every secretspec upgrade (adhoc/Nix builds), and items are not interoperable with security(1) #438

Description

@worldofgeese

Summary

On macOS the keyring provider stores items in the legacy file-based keychain, whose default ACL binds each item to the code signature of the binary that created it. For secretspec installed via Nix (or any non-Team-ID-signed build), that identity is an adhoc cdhash that changes on every version bump, so after each upgrade macOS shows a GUI password prompt on every secretspec get until the secret is re-written with secretspec set.

This is not documented, and the provider is labelled "Recommended" for local development with no caveats.

Environment

  • secretspec 0.20.0, installed via Nix (/nix/store/<hash>-secretspec-0.20.0/bin/secretspec)
  • macOS (Apple Silicon), login.keychain-db, keychain confirmed unlocked (SecKeychainGetStatus = 0x7)
  • Binary signature: Signature=adhoc, linker-signed, TeamIdentifier=not set
  • Designated requirement is purely cdhash-based:
    $ codesign -d -r- $(readlink -f $(which secretspec))
    # designated => cdhash H"6c98cf2f9dc022abfa749b2cd1bd8eeea847e911"
    

Evidence

I measured this rather than inferred it. Reads were timed, and accessibility was probed with
SecKeychainSetUserInteractionAllowed(false) so a restricted item returns an error instead of
silently blocking on a GUI dialog.

1. The ACL binds to cdhash, not to the filesystem path.

Binary cdhash secretspec get
original 6c98cf2f… 0.02 s, silent
byte-identical copy at a different path 6c98cf2f… 0.94 s, silent
same binary re-signed (codesign -f -s - --identifier …) 6a5d05ef… blocked → GUI prompt

So a Nix store path change alone is harmless; a content change (i.e. any real upgrade) is not.

2. Items are not interoperable with Apple's own security(1).
An item created by /usr/bin/security add-generic-password cannot be read by secretspec without a
prompt, and vice versa — security is Apple-signed, secretspec is adhoc-signed, and each item's
default ACL trusts only its creator. Re-creating the item with secretspec set restored silent
0.02 s reads.

3. The item's ACL partition list pins it to the creating binary's cdhash.

Dumping the ACLs with SecKeychainItemCopyAccessSecAccessCopyACLListSecACLCopyContents
and decoding the partition plist shows exactly what is happening:

Item created by Partitions
control security add-generic-password ['apple-tool:']
control + -A + set-generic-password-partition-list -S 'teamid:,apple:,apple-tool:' security ['teamid:', 'apple:', 'apple-tool:']
real secret secretspec set ['cdhash:6c98cf2f9dc022abfa749b2cd1bd8eeea847e911']

That cdhash: value is byte-for-byte the codesign -d -r- designated requirement above. The item is
pinned, in macOS's own data structure, to one exact build of the binary.

There are therefore two independent gates, and -A only opens the first:

  1. Trusted-application listsecurity add-generic-password -A does work here; the dump
    confirms trustedApps = NULL ("any application").
  2. ACL partition list (macOS Sierra and later) — still enforced. An adhoc-signed binary has no
    Team ID and is not Apple-signed, so it matches none of teamid: / apple: / apple-tool:.
    Verified: even after -A and a successful partition-list update, a different adhoc-signed
    reader still received errSecAuthFailed, against a positive control that returned rc=0
    (a process reading an item it created itself).

So there is no upgrade-stable workaround available through security(1): the only partition that
admits an adhoc binary is its own cdhash:, which by definition changes on the next build. Adding
the new cdhash after each upgrade also requires the login keychain password
(SecKeychainItemSetAccessWithPassword), making it strictly worse than simply re-running
secretspec set, which needs no password.

Root cause

secretspec depends on keyring = "4.1.6" with default features, so the v1 compatibility layer
hardcodes the legacy store:

// keyring-4.1.6/src/v1.rs:110-111
#[cfg(target_os = "macos")]
let store = apple_native_keyring_store::keychain::Store::new()?;

which uses SecKeychainAddGenericPassword / SecKeychainFindGenericPassword
(security-framework/src/os/macos/passwords.rs). Apple documents the consequence directly:

"This function sets the initial access rights for the new keychain item so that the application
creating the item is given trusted access.
"
SecKeychainAddGenericPassword (deprecated since 10.10)

No SecAccess / ACL / trusted-application object is constructed anywhere in the chain, and the
protected (data-protection keychain) backend is compiled out. KeyringConfig exposes only
folder_prefix, so none of this is reachable from secretspec configuration.

Upstream context

This is already understood one layer down, and is currently open and unfixed:

  • apple-native-keyring-store#24 — filed 2026-09-03 by a fellow Nix user: "after every rebuild, macOS prompts to re-allow access to keychain… Prod builds are created with nix build." Maintainer response: "This binding is by app signature, which changes every build in the case of non-codesigned apps." He notes an entry can be marked "unrestricted" at creation time, proposed adding that option, and is currently on leave.
  • keyring-rs#272 — same symptom; maintainer: "those settings aren't available via keyring" and "you really shouldn't be using the legacy keychain."

Suggested actions

  1. Document it now (cheap, unblocks users): note in docs/.../providers/keyring.md that on macOS the item is bound to the creating binary's code signature, that unsigned/adhoc/Nix builds will re-prompt after upgrades, and that the fix is to re-run secretspec set.
  2. Adopt the "unrestricted" ACL option once apple-native-keyring-store#24 lands, ideally opt-in
    via KeyringConfig (e.g. unrestricted = true), since it trades ACL binding for "any process
    running as this user".
    ⚠️ Important implementation note for that fix: clearing the trusted-application list alone
    is not sufficient. As measured above, -A already yields trustedApps = NULL and access is
    still denied, because the ACL partition list independently pins the item to
    cdhash:<creating binary>. An "unrestricted" option must also clear or widen the partition list,
    otherwise it will appear to work for the creating build and still re-prompt after every upgrade —
    exactly the bug it is meant to fix.
  3. Consider the protected (data-protection) store behind a feature flag — the upstream maintainer's own recommendation — noting it requires real code-signing/entitlements.
  4. Suggest age/sops/dotenv for macOS local dev where upgrade churn is frequent.

Documentation nits spotted while investigating

  • docs/src/content/docs/concepts/providers.mdx:86 states macOS Keychain "uses Apple's Secure Enclave rather than a TPM" — misleading, since the legacy file-based keychain used here does not keep generic passwords in the Secure Enclave.
  • docs/src/content/docs/concepts/providers.mdx:44 links to Apple's keychain-data-protection page, i.e. the store secretspec does not use.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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