Skip to content

Instantly share code, notes, and snippets.

@algesten
Created February 21, 2026 10:18
Show Gist options
  • Select an option

  • Save algesten/63adbcab07e968af844fd39918d1a3b2 to your computer and use it in GitHub Desktop.

Select an option

Save algesten/63adbcab07e968af844fd39918d1a3b2 to your computer and use it in GitHub Desktop.

macOS Data Protection Keychain: Helper Binary Inside .app Bundle

Date: 2026-02-21

Environment

Component Version
macOS 15.6.1 (24G90)
Xcode 26.2 (17C52)
rustc 1.91.1
cargo 1.91.1

Question

Can a plain Mach-O helper binary (mkp) access the macOS data protection keychain when:

  • It is codesigned with a keychain-access-groups entitlement
  • It lives inside a signed .app bundle that has a valid provisioning profile
  • The provisioning profile is embedded at MyApp.app/Contents/embedded.provisionprofile

Conclusion

No. The provisioning profile only covers the main app executable, not helper binaries placed alongside it.

  • Signing mkp with entitlements + provisioning profile in the bundle → Killed: 9 The kernel rejects the binary at launch. The restricted keychain-access-groups entitlement requires a provisioning profile that covers the specific binary, and embedded.provisionprofile does not extend to helper tools.

  • Signing mkp without entitlements → runs, but keychain access fails errSecMissingEntitlement ("A required entitlement isn't present.") when attempting to use the data protection keychain.

The data protection keychain is not usable from a helper binary inside an .app bundle unless the helper has its own provisioning profile (which plain Mach-O binaries cannot have).

Alternatives

  • Use the legacy login keychain — call PasswordOptions::new_generic_password() without .use_protected_keychain(). The login keychain does not require entitlements.
  • Perform keychain operations in the main app and relay results to the helper via IPC.

Proof

Setup

The app bundle structure:

MyApp.app/
  Contents/
    MacOS/
      MyApp          ← main executable (Xcode-built, has entitlements via profile)
      mkp            ← helper binary (plain Mach-O, cargo-built)
    embedded.provisionprofile

Entitlements file (MyApp.entitlements):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>keychain-access-groups</key>
    <array>
        <string>TEAM_ID.com.example.myapp</string>
    </array>
</dict>
</plist>

Makefile bundle target (simplified):

bundle: all
	cp target/release/mkp $(APP_BUNDLE)/Contents/MacOS/
	cp MyApp.provisionprofile $(APP_BUNDLE)/Contents/embedded.provisionprofile
	codesign --force --options runtime \
		--entitlements MyApp.entitlements \
		--sign "Developer ID Application: ..." \
		$(APP_BUNDLE)/Contents/MacOS/mkp
	codesign --force --options runtime \
		--entitlements MyApp.entitlements \
		--sign "Developer ID Application: ..." \
		$(APP_BUNDLE)

Test code

Added to mkp/src/main.rs — a --keychain-test flag that writes, reads, and deletes a test item from the data protection keychain, then exits:

if keychain_test {
    use security_framework::passwords::{
        delete_generic_password_options, generic_password,
        set_generic_password_options, PasswordOptions,
    };

    let service = "com.example.keychain-test";
    let account = "test";
    let test_data = b"hello-keychain";

    let opts = || {
        let mut o = PasswordOptions::new_generic_password(service, account);
        o.use_protected_keychain();
        o
    };

    // Write
    match set_generic_password_options(test_data, opts()) {
        Ok(()) => println!("WRITE: ok"),
        Err(e) => {
            println!("WRITE: FAILED — {e}");
            std::process::exit(1);
        }
    }

    // Read back
    match generic_password(opts()) {
        Ok(bytes) => {
            if bytes == test_data {
                println!("READ: ok (data matches)");
            } else {
                println!("READ: FAILED — data mismatch");
                std::process::exit(1);
            }
        }
        Err(e) => {
            println!("READ: FAILED — {e}");
            std::process::exit(1);
        }
    }

    // Cleanup
    match delete_generic_password_options(opts()) {
        Ok(()) => println!("DELETE: ok"),
        Err(e) => println!("DELETE: failed (non-fatal) — {e}"),
    }

    println!("Keychain test PASSED");
    std::process::exit(0);
}

Test 1: Signed with entitlements → Killed: 9

$ make bundle
cp target/release/mkp .../MyApp.app/Contents/MacOS/
cp MyApp.provisionprofile .../MyApp.app/Contents/embedded.provisionprofile
codesign --force --options runtime --entitlements MyApp.entitlements \
    --sign "Developer ID Application: ..." .../MyApp.app/Contents/MacOS/mkp
codesign --force --options runtime --entitlements MyApp.entitlements \
    --sign "Developer ID Application: ..." .../MyApp.app

$ codesign -d --entitlements - .../MyApp.app/Contents/MacOS/mkp
[Dict]
    [Key] keychain-access-groups
    [Value]
        [Array]
            [String] TEAM_ID.com.example.myapp

$ .../MyApp.app/Contents/MacOS/mkp --keychain-test
Killed: 9

The kernel kills the process at launch. The keychain-access-groups entitlement is restricted and requires a provisioning profile that covers this specific binary. The bundle's embedded.provisionprofile does not extend to helper executables.

Test 2: Signed without entitlements → runs, keychain fails

$ codesign --force --options runtime \
    --sign "Developer ID Application: ..." \
    .../MyApp.app/Contents/MacOS/mkp

$ .../MyApp.app/Contents/MacOS/mkp --keychain-test
WRITE: FAILED — A required entitlement isn't present.

The binary runs, but the data protection keychain rejects access because keychain-access-groups is not present.

Summary

Signing Entitlements Result
Developer ID + entitlements keychain-access-groups Killed: 9 (kernel rejects binary)
Developer ID, no entitlements none Runs, but errSecMissingEntitlement on keychain access

The data protection keychain is not accessible from a helper binary inside an .app bundle. Use the legacy login keychain or perform keychain operations in the main app process instead.

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