# Code signing

> Prepare bundle IDs, certificates, private keys, and provisioning profiles for local or CI builds.

A downloaded certificate is not a signing identity. The certificate contains a public key; Xcode also needs the matching private key. App Store Connect can return certificates and provisioning profiles, but it cannot return a private key after the certificate request has been created.

Use the [signing command reference](/commands/signing) for every flag, profile-type table, encrypted repository format, encryption guarantees, multi-target manifest, CI example, rotation and revocation sequence, and troubleshooting case. This guide explains which workflow to choose.

## Existing identity on one Mac

If the matching private key already exists in the selected macOS keychain, fetch the public certificate and provisioning profile:

```bash  theme={null}
asc signing fetch \
  --app APP_ID \
  --bundle-id com.example.app \
  --profile-type IOS_APP_STORE \
  --output .asc/signing/app-store \
  --format json \
  --pretty
```

Install the profile by file rather than copying it to a guessed Xcode directory:

```bash  theme={null}
asc profiles local install \
  --path .asc/signing/app-store/PROFILE.mobileprovision \
  --output json
```

The `.cer` file is useful for inspection and verification. Importing it without its private key does not make the machine able to sign.

## New certificate

Generate the private key and certificate request locally, then send the request to App Store Connect:

```bash  theme={null}
mkdir -p .asc/signing .asc/secrets
chmod 700 .asc/signing .asc/secrets
certificate_response=.asc/signing/distribution-certificate.json

asc certificates create \
  --certificate-type IOS_DISTRIBUTION \
  --generate-csr \
  --key-out .asc/secrets/distribution.key \
  --csr-out .asc/signing/distribution.csr \
  --output json \
  --pretty > "$certificate_response"
```

The private key remains in `.asc/secrets/distribution.key`. Do not commit it, print it, or upload it as a CI artifact. Back it up through encrypted signing sync before removing the bootstrap copy.

The key and CSR are written before the API request. After a timeout or ambiguous failure, first list certificates and check whether the request created one whose public key matches the preserved private key. Do not rerun `--generate-csr` or use `--force`:

```bash  theme={null}
asc certificates list \
  --certificate-type IOS_DISTRIBUTION \
  --paginate \
  --output json \
  --pretty > .asc/signing/distribution-certificates-after-ambiguous-create.json
```

When a matching certificate exists, retrieve that resource into a fresh response file:

```bash  theme={null}
certificate_response=.asc/signing/distribution-certificate-recovered.json
asc certificates view \
  --id MATCHING_CERTIFICATE_ID \
  --output json \
  --pretty > "$certificate_response"
```

If no matching certificate exists, retry with the preserved CSR and a fresh response file:

```bash  theme={null}
certificate_response=.asc/signing/distribution-certificate-retry.json
asc certificates create \
  --certificate-type IOS_DISTRIBUTION \
  --csr .asc/signing/distribution.csr \
  --output json \
  --pretty > "$certificate_response"
```

## Shared App Store identity

Read the certificate resource ID and public certificate from the same create response. `signing sync push` uses the SHA-256 fingerprint to bind the generated private key, while profile reconciliation uses the resource ID to select that same certificate:

```bash  theme={null}
CERTIFICATE_ID="$(
  jq -r '.data.id' "$certificate_response"
)"

CERTIFICATE_SHA256="$(
  jq -r '.data.attributes.certificateContent' \
    "$certificate_response" \
  | openssl base64 -d -A \
  | openssl x509 -inform DER -outform DER \
  | shasum -a 256 \
  | awk '{print toupper($1)}'
)"
```

`signing sync pull` does not transport the App Store Connect certificate resource ID. Save `CERTIFICATE_ID` as a protected, non-secret CI variable named `ASC_DISTRIBUTION_CERTIFICATE_ID`, scoped to the environment that uses this signing repository. Keep that variable paired with the synced identity and update both during rotation.

Push that verified private key, certificate, and profile into a private encrypted Git repository:

```bash  theme={null}
chmod 600 \
  ~/.config/asc/signing-sync-password \
  .asc/secrets/distribution.key

asc signing sync push \
  --bundle-id com.example.app \
  --profile-type IOS_APP_STORE \
  --repo git@github.com:team/signing.git \
  --password-file ~/.config/asc/signing-sync-password \
  --private-key .asc/secrets/distribution.key \
  --identity-sha256 "$CERTIFICATE_SHA256" \
  --create-missing \
  --output json \
  --pretty
```

For an app with embedded targets, use one strict target manifest so the repository update covers all bundle identifiers in one commit:

```json
{
  "schemaVersion": 1,
  "targets": [
    {"bundleId": "com.example.app"},
    {"bundleId": "com.example.app.widget"}
  ]
}
```

```bash  theme={null}
asc signing sync push \
  --targets-file ./signing-targets.json \
  --profile-type IOS_APP_STORE \
  --repo git@github.com:team/signing.git \
  --password-file ~/.config/asc/signing-sync-password \
  --private-key .asc/secrets/distribution.key \
  --identity-sha256 "$CERTIFICATE_SHA256" \
  --create-missing \
  --output json
```

The same profile type, certificate filter, identity, and device set apply to every target. A batch can create profiles before the final repository push, so a later failure may leave those immutable App Store Connect profiles in place.

What AES-256-GCM, PBKDF2, scrypt, and authenticated metadata do and do not guarantee is in [What the encrypted store protects](/commands/signing#what-the-encrypted-store-protects).

## Pull in CI

Write the repository password from the CI secret manager to a runner-private file, then pull the signing assets. `signing sync` already forces noninteractive Git (`GIT_TERMINAL_PROMPT=0` and, unless you overrode SSH, `GIT_SSH_COMMAND=ssh -o BatchMode=yes`); keep any custom SSH wrapper equally noninteractive so a prompt cannot hang the runner. The full CI example lives in the [signing command reference](/commands/signing#ci-example).

```bash  theme={null}
set +x
umask 077
signing_tmp="$(mktemp -d "$RUNNER_TEMP/asc-signing.XXXXXX")"
password_file="$signing_tmp/repository-password"
cleanup_signing_pull() {
  rm -f "$password_file"
  rmdir "$signing_tmp" >/dev/null 2>&1 || true
}
trap cleanup_signing_pull EXIT
printf '%s' "$SIGNING_SYNC_PASSWORD" > "$password_file"
mkdir -p .asc/signing
chmod 700 .asc/signing

asc signing sync pull \
  --repo git@github.com:team/signing.git \
  --password-file "$password_file" \
  --output-dir .asc/signing/pulled \
  --output json > .asc/signing/pull-result.json
```

The result lists private identities under `sensitiveFiles`. Install the pulled
PKCS#12 into a new dedicated keychain when separate archive and export commands
need the identity to persist:

> [experimental] Persistent keychain installation can change before it reaches stable lifecycle status.

```bash  theme={null}
identity_path="$(
  jq -er '
    [.sensitiveFiles[]? | select(endswith(".p12"))]
    | if length == 1 then .[0]
      else error("expected exactly one signing identity")
      end
  ' .asc/signing/pull-result.json
)"
keychain_path="$signing_tmp/release.keychain-db"
keychain_password_file="$signing_tmp/keychain-password"
cleanup_signing() {
  /usr/bin/security delete-keychain "$keychain_path" >/dev/null 2>&1 || true
  rm -f "$password_file" "$keychain_password_file"
  rmdir "$signing_tmp" >/dev/null 2>&1 || true
}
trap cleanup_signing EXIT
openssl rand -base64 32 > "$keychain_password_file"

asc signing keychain install \
  --identity ".asc/signing/pulled/$identity_path" \
  --identity-password-file "$password_file" \
  --keychain "$keychain_path" \
  --keychain-password-file "$keychain_password_file" \
  --add-to-search-list \
  --confirm \
  --output json

jq -r '.files[] | select(endswith(".mobileprovision"))' \
  .asc/signing/pull-result.json \
| while IFS= read -r profile_path; do
    asc profiles local install \
      --path ".asc/signing/pulled/$profile_path" \
      --output json
  done
```

The receipt supplies the identity's complete relative path. This example
requires exactly one pulled PKCS#12 identity and stops if that invariant is not
met. The identity password is the encrypted-repository password because sync
normalizes the pulled PKCS#12 under that secret. The keychain password is a
separate generated secret. The install command is macOS-only, requires a
cgo-enabled build, refuses an existing destination, and rolls back the new
keychain if import, codesigning verification, or search-list activation fails.
It does not change the default keychain. The run-owned temporary directory
ensures the cleanup trap cannot delete a pre-existing keychain if installation
fails.

The keychain remains available after the command succeeds. Keep archive and
export inside the lifetime of the cleanup trap on disposable CI workers; for a
long-lived machine, omit the trap only when the dedicated keychain is intended
to remain. Do not upload the decrypted directory, password files, or keychain.

## Registered-device release testing

For an iOS archive with extensions or other embedded targets, reconcile the archive against an exact protected device file:

```bash  theme={null}
: "${ASC_DISTRIBUTION_CERTIFICATE_ID:?configure the resource ID paired with the synced identity}"
chmod 600 .asc/distribution/devices.json

asc signing reconcile plan \
  --archive-path .asc/artifacts/Example.xcarchive \
  --devices-file .asc/distribution/devices.json \
  --certificate "$ASC_DISTRIBUTION_CERTIFICATE_ID" \
  --minimum-validity-days 14 \
  --output json \
  --pretty

asc signing reconcile apply \
  --plan .asc/distribution/signing/plan.json \
  --confirm \
  --output json > .asc/distribution/signing/apply-result.json
```

Planning is read-only and can return a successful `ready: false` result with blockers. Apply performs only the additive actions recorded in a ready plan; it does not delete profiles, create certificates, or change capabilities.

Planning creates `plan.json` without replacing an existing file. If a plan reports blockers, review it and fix them first. Then rerun the same plan command with `--overwrite`, or choose a new `--state-dir` when the blocked plan must remain on disk.

`signing run` accepts exactly one profile. Use the temporary-keychain export below only for a single-target archive whose main app uses that profile. When this runs in a later CI step than the pull, recreate the runner-private password file because the pull step's cleanup trap removed its copy:

```bash  theme={null}
set +x
umask 077
password_file="$RUNNER_TEMP/asc-signing-password"
trap 'rm -f "$password_file"' EXIT
printf '%s' "$SIGNING_SYNC_PASSWORD" > "$password_file"

signing_command=(
  asc xcode export
  --archive-path .asc/artifacts/Example.xcarchive
  --ipa-path .asc/artifacts/Example.ipa
  --method release-testing
  --signing-style manual
  --team-id TEAM_ID
)

asc signing run \
  --identity .asc/signing/pulled/identities/distribution/IDENTITY.p12 \
  --identity-password-file "$password_file" \
  --profile .asc/distribution/signing/profiles/PROFILE.mobileprovision \
  --receipt .asc/distribution/signing-run.json \
  -- "${signing_command[@]}"
```

`signing run` accepts iOS ad hoc profiles with registered devices. It rejects App Store, development, enterprise, macOS, and tvOS profiles.

For an archive with extensions, App Clips, Watch apps, or other embedded targets, don't use this single-profile wrapper for export. On a disposable macOS worker, use `asc signing keychain install` for the job-scoped identity, install each completed profile action's `outputPath` from `.asc/distribution/signing/apply-result.json`, export, and discard the worker state when the job ends.

## What the encrypted store does not do

The current store uses Git. It does not renew certificates, delete stale encrypted assets, assign different settings per target, or manage the later lifecycle of installed persistent keychains. Treat those as separate reviewed operations. Renew first, push the successor identity, verify a clean pull, then revoke or deactivate; the ordering is under [Rotation and expiry](/commands/signing#rotation-and-expiry).

Read the [signing command reference](/commands/signing) before the first live push; it includes file-permission rules, exact output fields, failure boundaries, profile maintenance, and recovery behavior.
