Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

Keychain Items Belong to the App That Made Them

~15 min · bundle-signing, keychain, secrets, acl, security-framework

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"CMD+K stopped prompting. Opening Settings still prompted every single time."

Where Secrets Go on a Mac

An app that holds a durable secret — the family's apps keep a PIN for reaching their engine at home — stores it in the Keychain with the Security framework's SecItem API, never in a file or UserDefaults. On the Mac there are two keychains to know about. The login keychain (file-based) protects each item with an access control list that trusts particular applications by their code requirement. The data protection keychain (kSecUseDataProtectionKeychain) behaves like iOS and scopes access by keychain-access-group entitlements — so an unsigned or entitlement-less binary gets -34018 (errSecMissingEntitlement) instead of an item.

The Prompt That Survived Re-Signing

A family editor was moved from ad-hoc signing to the stable self-signed identity so its Keychain reads would stop prompting. One path went quiet; another — the Settings tab that re-read the PIN every time it opened — kept prompting on every read. The requirement was verified stable. The code was fine.

The items themselves were the problem. They had been created by the old ad-hoc build, so their access lists trusted a code requirement that no longer existed. To those items, the newly signed app was a different app, forever. The fast path looked fixed only because it cached a session token in memory and stopped reading the Keychain after the first unlock. The deterministic fix, with no "Always Allow" clicking: delete every item for the service and let the signed app save fresh ones, which it then owns and reads silently.

That deletion must happen on each Mac's own console session. From ssh, the login keychain answers "User interaction is not allowed": the remote session sits outside the GUI login, so the keychain cannot show its access prompt there, and unlocking it from ssh means handing the account password to a script. That is also why signing fails over ssh in the next track.

Missing, Locked, and Unavailable Are Three Answers

A keychain read that treats every failure as "no PIN saved" turns a locked keychain into a first-run experience, and a missing entitlement into a silent reset. The family's shared secret store returns three distinct outcomes, and callers show different things for each: missing asks the user to enter the PIN, locked says the keychain is locked (or the session cannot show UI), unavailable names the status code.

Code

A lookup that tells missing from locked from unavailable, and a save that re-owns the item·swift
import Foundation
import Security

enum KeychainLookup: Equatable {
    case found(Data)
    case missing                 // errSecItemNotFound: first run, or never saved
    case locked                  // errSecInteractionNotAllowed: locked, or no UI possible (ssh)
    case unavailable(OSStatus)   // anything else, e.g. -34018 missing entitlement
}

enum SparkSecrets {
    static let service = "com.example.spark.engine-pin"

    static func readPIN(account: String) -> KeychainLookup {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        switch status {
        case errSecSuccess: return (result as? Data).map(KeychainLookup.found) ?? .unavailable(status)
        case errSecItemNotFound: return .missing
        case errSecInteractionNotAllowed: return .locked
        default: return .unavailable(status)
        }
    }

    static func savePIN(_ pin: String, account: String) -> OSStatus {
        let base: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        SecItemDelete(base as CFDictionary)   // the new item is owned by THIS signed build
        var add = base
        add[kSecValueData as String] = Data(pin.utf8)
        return SecItemAdd(add as CFDictionary, nil)
    }
}

External links

Exercise

Add SparkSecrets to Spark's Mac app. Build it ad-hoc, save a PIN, and read it back. Now rebuild with your stable self-signed identity and read again — note whether macOS prompts. Delete the item with security delete-generic-password -s com.example.spark.engine-pin, save from the signed build, rebuild once more, and confirm reads are now silent. Finally, run the read over ssh from another Mac and record which of the three outcomes it returns.
Hint
Loop the delete until it fails (while security delete-generic-password -s … >/dev/null 2>&1; do :; done) — there can be more than one item for the same service with different accounts.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.