Signing Infrastructure
September 22, 2026 · View on GitHub
This directory contains the macOS code signing and notarization scaffolding for the Kiro Crew desktop app, using an enterprise code-signing service.
Why identifiers are committed here
Kiro Crew is distributed as a signed desktop application under a shared Apple
Developer identity. The bundle identifier and team ID are required by Apple's
code signing infrastructure and are not secrets — they're embedded in every
signed .app bundle users download.
These files are gated behind CDSIGNER_API_ENDPOINT and AWS_SIGNER_ROLE_ARN
secrets that only the upstream repository has. Forks without these secrets
skip signing entirely (the workflow produces unsigned builds that work but
trigger macOS Gatekeeper warnings).
Files
-
Entitlements.entitlements— macOS entitlements for the Electron app. JIT + disable-library-validation are required for V8/Node.js + native addons. -
manifest-template.json— signing manifest with embedded requirements for all Electron helper processes and frameworks. -
sign.sh— CI script that packages, uploads, submits to the signing service, polls, downloads, and verifies the signed artifact. -
notarize.sh— submits a file to the Apple notary service and pollsnotarytool infofor the verdict, retrying transient request failures (NSURLErrorDomain timeouts, 5xx) with exponential backoff inside a 30m hard budget; every backoff sleep and everynotarytoolinvocation is bounded by the budget left, so a request that hangs instead of erroring cannot run past it. Replacesnotarytool submit --wait, whose in-process poll made a single timed-out status request fail the job even after Apple had Accepted the submission. Fail-closed:Invalid/Rejectedpulls the itemized Apple log and exits non-zero; the Gatekeeperspctlgates in the workflow are unchanged. Used for both the app zip and the DMG. -
build-dmg.sh— replaces the unsigned app inside electron-builder's branded DMG layout template with the signed/stapled app, then shrinks and recompresses the image before the DMG signing and notarization stages.The mounted-volume phase races Spotlight and XProtect, which start reading the freshly-copied app and can hold the volume against ejection ("Resource busy") — the same transient class electron-builder retries on. The script layers its defenses:
-nobrowsekeeps the volume out of Finder, and the eject gets bounded retries with a synced force fallback (seehdiutil-detach.sh). hdiutil calls run without-quiet, because that flag suppresses stderr too and previously reduced failures of this script to bare exit codes. -
hdiutil-detach.sh— the detach retry loopbuild-dmg.shsources. It addresses the device node (/dev/diskN, read fromhdiutil attach -plist) rather than the mount path, and after every failed attempt askshdiutil infowhether the device is still attached instead of trusting the exit status.hdiutil detachunmounts and then ejects, and reports "Resource busy" when only the eject is held — at which point the mount path is already gone, so a path-addressed retry can only ever answer "No such file or directory". A device that is no longer attached counts as detached however that came about; a device that survives-forceis still a hard failure.test/test_hdiutil_detach.pydrills the loop against a scripted fakehdiutil.The branded background is a volume-bound alias recorded inside
.DS_Store, which is why the image is reused rather than rebuilt from a folder: recreating it drops the layout. The script fingerprints the template's.DS_Storebefore the swap and requires the final image to carry the same bytes, which is stronger than checking the files exist — a broken alias leaves every file in place. It still cannot prove Finder resolves the alias, since that also depends on the volume identity the alias binds to, and nothing on a CI runner re-renders the window.So two things are worth doing rather than assuming green CI covers them. First, watch the next real signing run: the unsigned-DMG S3 round trip, the sector-resize arithmetic and the app swap all execute for the first time there, not in PR CI. Second, know the fallback — if the alias ever stops surviving, run
dmgbuildagainst the stapled app inside the notarize job. That writes a fresh, correct.DS_Storeand removes the template round trip, the resize arithmetic and the survival question in one move; the only reason it is not the default is that it re-derives the layout on every release instead of preserving the one the build already produced.
Prerequisites
Access to the signing service must be onboarded (a security review plus
sign-off). See docs/build/release.md for the full onboarding runbook.
CLI artifact manifests (separate trust domain)
The wheel installer does not reuse Apple/CDSigner. publish-cli.yml signs a
canonical JSON artifact manifest with an asymmetric AWS KMS key and publishes the
same signed JSON at both:
cli/<channel>/<version>/cli-manifest.json(immutable, used by--version)feed/<channel>/latest-cli.json(mutable channel pointer)
The legacy channel, version, wheel_url, sha256, python_requires, and
pub_date fields remain top-level for older installers. Schema v1 adds
schema, algorithm, key_id, and signature. The signature is RSA
PKCS#1 v1.5 with SHA-256 over canonical JSON containing every field except
signature. cli.sh reconstructs those exact bytes, verifies them with the
offline public key embedded in the installer, validates the authenticated URL,
channel, version, and digest, and only then downloads the wheel. The SHA-256
check remains a second fail-closed check over the downloaded bytes. There is no
SHA256SUMS or unsigned-feed fallback in the strict installer.
Threat model: this protects against unauthorized mutation of distribution
objects or channel feeds while the installer trust root and signing-enabled
publisher role remain trusted. The publisher role holds kms:Sign, so its
compromise can produce a valid manifest and is explicitly out of scope; the
signature does not create a separate trust boundary from that role.
The same key, algorithm and canonical form sign the feature-videos release
manifest (scripts/feature-videos/, schema
kirocrew-feature-videos-manifest-v1). That tool loads cli-manifest.py by path
and uses its canonical JSON, key-id derivation, runners and kms_sign_digest
rather than carrying copies, so there is one signer to audit. The two schemas
keep the two verifiers apart; the key grant is one grant, and a principal that
may sign videos may sign a CLI manifest.
Trust-root configuration
The upstream repository carries a configured public key in
cli-manifest-public.pem and matching CLI_MANIFEST_* constants in cli.sh.
The installer still handles an UNCONFIGURED trust root fail-closed by exiting
before network I/O. The publication workflow likewise fails before upload when
only one of the role/key settings is configured; forks with neither setting skip
publication.
No private key should be generated, exported, committed, pasted into CI, or handled by an agent. Initial enablement for a new distribution is a human/infrastructure step:
- Create a non-exportable asymmetric KMS key in
us-west-2with key usageSIGN_VERIFYand key specRSA_3072orRSA_4096. - Grant the existing CLI publication role only
kms:GetPublicKeyandkms:Signon that one key. Keep the existing OIDC subject/environment restriction; do not grant decrypt or broadkms:*access. - Retrieve the public key with
kms:GetPublicKey, convert its SubjectPublicKeyInfo DER bytes to PEM, and replacepackaging/signing/cli-manifest-public.pem. - Run
python3 packaging/signing/cli-manifest.py key-info --public-key packaging/signing/cli-manifest-public.pem. Copy the returned publickey_idandpublic_key_pem_base64values into the matching constants incli.sh. Commit the public-key pin normally. - Set the protected
prodenvironment variableCLI_MANIFEST_SIGNING_KEY_ARNto the key ARN. The workflow compares KMSGetPublicKeyoutput byte-for-byte with the committed key before every sign, and verifies the returned signature locally before publishing. - Run
test/test_cli_manifest_signature.py, then dispatch a publish and verify the immutable manifest is present. Publish the strictcli.shonly after a signed channel feed exists. Because the added fields are backward-compatible, the signed feed may safely go live before the strict installer. This ordering is enforced mechanically:publish-installer.ymlrefuses to publish whilecli.shstill pinsCLI_MANIFEST_KEY_ID="UNCONFIGURED", and — once a key is pinned — refuses unless every LIVE channel feed verifies against that key (cli-manifest.py verify), so neither the pin commit nor any later merge can replace the live installer with one that refuses the feeds it is pointed at. That gate is a SEPARATE implementation of the installer's contract, so the direction between them is what is guaranteed rather than equality: whatever the gate accepts,cli.shmust also accept. The gate is deliberately stricter in three places (a 16 KiB payload cap against the installer's 64 KiB, a 2048-character cap per field, and refusing amin_versionabove the shipped version), each of which costs a publisher one loud failure instead of shipping a feed nobody can install. It may never be laxer, andtest_cli_manifest_signature.pydrives one shared fixture set (valid, wrong-channel, wrong-host, tampered, legacy) through the gate AND through a realcli.shrun so the two cannot drift apart silently.
Pinned versions released before enablement have no immutable signed manifest and
therefore fail closed under the new installer. The project's disposition is a
documented cutoff rather than a backfill: 0.1.0 and 0.1.1 are the only
published releases without a manifest, the minimum pinnable release is 0.1.2,
and the user-facing statement of that is docs/guides/install.md ("Pinning an
exact version"). A backfill would sign an already-published digest with the
operational key, minting an attestation for bytes that no signing pipeline
produced. Reversing that decision is a release operation, not a code change: the
only code-side facts are the floor stated in that section and the pinned
examples that cite it. Do not replace the KMS key in place: schema v1 pins
one key. For rotation, first ship an installer revision that trusts both old and
new public keys, then switch the publisher, and retire the old key only after the
overlap window. The same key also verifies the gateway's feature-video manifest
(src/kiro_crew/platform/feed_trust.py), so the overlap window has a third
party in it: the feature-video publisher re-signs every hosted manifest a
shipped release still reads back before the old key is retired.