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

A Self-Signed Signing Identity, On Purpose

~16 min · bundle-signing, self-signed, code-signing-identity, tcc, keychain

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Self-signed local identity: commit it. Trusted Apple certificate: never."

The Problem It Solves

An ad-hoc signed app has a requirement made of its own hash, so every rebuild is a new app to TCC and the Keychain: Accessibility prompts again, and "Always Allow" on a Keychain item never sticks. The family's answer for Mac apps that never leave its own machines is one self-signed code-signing identity, shared by every native app, launcher and permission-bearing helper, imported into the login keychain of every Mac that builds. Signed with it, an app's designated requirement becomes identifier "…" and certificate leaf = H"…" — identical across rebuilds, and identical across every Mac that signs with the same certificate.

Why Not an Apple Certificate?

An Apple Developer ID certificate would also give a stable requirement, and it is what you need to distribute to other people (the last lesson of this track). But a trusted, Gatekeeper-accepted, notarization-capable certificate is a credential: if it leaks, someone can ship software that macOS treats as signed by you. A self-signed identity opens nothing beyond the Macs that trust it: Gatekeeper rejects it, and it unlocks no account and no server. On those Macs, though, it is exactly what TCC grants and Keychain access lists name, so whoever holds its private key can sign a binary with the same identifier and inherit them. Its blast radius is the private repository plus a way to run code on these Macs, and anyone with both already has what those grants protect. That is why the family keeps its self-signed identity in its private repository, and why a trusted certificate must never be committed anywhere.

The Costs, Stated Plainly

  • Gatekeeper does not trust it. The apps run on Macs where the certificate is installed and where you launch them yourself — not on a stranger's Mac.
  • Each Mac's keychain asks once, the first time codesign uses the key: "codesign wants to use key …". Click Always Allow once per Mac.
  • After moving an app from ad-hoc to the stable identity, reset its old grants once (tccutil reset Accessibility com.example.spark), because grants pinned to the old cdhash will not match the new requirement. Grant once more, and it lasts.
  • Regenerating the certificate changes the leaf hash, so every Mac re-grants once and every app is rebuilt.

The Detection Trap

A build script that checks for the identity with security find-identity -v -p codesigning will conclude it is missing. -v filters to valid identities — ones that chain to a trusted root — and a self-signed identity is reported as CSSMERR_TP_NOT_TRUSTED, so it is hidden. Leave out -v. This one trap kept a family dictation app on ad-hoc signing, re-prompting on every build, until it was found.

Code

Create, import and detect a self-signed code-signing identity (once per Mac for import)·bash
# Create (once, then keep the .p12 in your PRIVATE repository):
openssl req -x509 -newkey rsa:2048 -sha256 -nodes \
  -keyout spark-signing.key -out spark-signing.crt -days 3650 \
  -subj "/CN=Spark Local Signing" \
  -addext "basicConstraints=critical,CA:false" \
  -addext "keyUsage=critical,digitalSignature" \
  -addext "extendedKeyUsage=critical,codeSigning"
# OpenSSL 3 (Homebrew openssl@3): macOS's /usr/bin/openssl is LibreSSL and rejects -legacy.
# macOS 'security import' needs the legacy MAC algorithm and a non-empty password:
openssl pkcs12 -export -legacy -macalg sha1 \
  -inkey spark-signing.key -in spark-signing.crt \
  -name "Spark Local Signing" -out spark-signing.p12 -passout pass:local-import
rm spark-signing.key

# Import on each build Mac:
security import spark-signing.p12 -k ~/Library/Keychains/login.keychain-db \
  -P local-import -T /usr/bin/codesign

# Detect it — WITHOUT -v, which hides untrusted identities:
security find-identity -p codesigning | grep '"Spark Local Signing"'
#   1) 3B81...  "Spark Local Signing" (CSSMERR_TP_NOT_TRUSTED)

# After the first stable-signed build of an app that used to be ad-hoc:
tccutil reset Accessibility com.example.spark

External links

Exercise

Create a self-signed code-signing identity for Spark with the commands above, import it into your login keychain, and confirm that security find-identity -v -p codesigning does not list it while the command without -v does. Point SPARK_SIGN_IDENTITY at it, run your build script (with the certificate check in verify restored), and record the new designated requirement. Rebuild after a source change and confirm the requirement text is identical.
Hint
The first signing will show a keychain prompt; choose Always Allow. If codesign fails with errSecInternalComponent, you are probably running it over ssh — signing needs the GUI login session, which is a lesson in the next track. Run the commands with OpenSSL 3; /usr/bin/openssl version printing LibreSSL means pkcs12 -legacy will stop with "unknown option".

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.