// Code signing through the App Store Connect API: bundle id capabilities, the distribution certificate and the // provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings. // storebound apple signing status [--json] read-only: capabilities, certificates (with / without a // private key on this machine), profiles, what is installed // storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json] // adds missing capabilities (apple.signing.capabilities), creates a distribution certificate when this machine // holds no key for any valid one (RSA 2048 key generated here, kept 0600 in .storebound/signing/, never printed; // --keychain also imports key + certificate into the login keychain or the given one), creates or reuses an // App Store profile per bundle id (apple.bundleId + apple.signing.extraBundleIds) and installs it for Xcode. // Every write is shown first (--dry shows only that) and asks once before anything is created. Developer ID // certificates need the Account Holder: the API's 403 is explained. Pure planning: cli/lib/signing.ts. import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; import { done, log, printJson, warn } from '../lib/log'; import { type ApiCert, type ApiProfile, type AppleSigning, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, readProfiles, } from '../lib/signing'; import { textTable } from '../lib/table'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute, type Req } from './jsonapi'; import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert'; import * as rt from '../lib/rt'; const TAG = 'signing'; const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]'; export const signingDir = (root: string): string => join(root, '.storebound', 'signing'); /** SPKIs of the private keys in .storebound/signing (file name = certificate id once created). */ function localKeys(root: string): Map { const dir = signingDir(root); const out = new Map(); if (!existsSync(dir)) return out; for (const f of readdirSync(dir).filter(f => f.endsWith('.key'))) { try { out.set(spkiOfKey(readFileSync(join(dir, f), 'utf8')), f); } catch { warn(TAG, `${f}: not a private key, ignored`); } } return out; } /** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */ function keychainIdentities(): Set { if (process.platform !== 'darwin' || !rt.which('security')) return new Set(); const r = rt.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' }); return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1)); } export async function findBundleId(c: Asc, identifier: string): Promise { const r = await c.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(identifier)}&include=bundleIdCapabilities&limit=200`); const hit = (r.data as J[]).find(b => b.attributes.identifier === identifier) ?? null; if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)) .map((x: J) => x.attributes.capabilityType as string); return hit; } const profilesOf = (c: Asc, bundleIdId: string, type: ProfileType): Promise => readProfiles(p => c.all(p), bundleIdId, type); const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []); function install(p: ApiProfile): string[] { const content = p.attributes.profileContent; if (!content || !p.attributes.uuid) throw new Error(`profile ${p.attributes.name}: no content to install`); const out: string[] = []; for (const d of profileDirs(homedir())) { mkdirSync(d, { recursive: true }); const f = join(d, `${p.attributes.uuid}.mobileprovision`); writeFileSync(f, Buffer.from(content, 'base64')); out.push(f); } return out; } function keychainImport(keyFile: string, cerFile: string, keychain: string | null): void { if (process.platform !== 'darwin') throw new Error('--keychain: macOS only (on other systems keep the key in .storebound/signing)'); const kc = keychain ? ['-k', keychain] : []; for (const cmd of [['security', 'import', keyFile, ...kc, '-t', 'priv', '-f', 'openssl', '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'], ['security', 'import', cerFile, ...kc, '-t', 'cert']]) { const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' }); const msg = (r.stdout.toString() + r.stderr.toString()).trim(); if (r.exitCode !== 0 && !/already exists/i.test(msg)) throw new Error(`security import failed: ${msg.slice(0, 300)}`); } log(TAG, `key and certificate imported into ${keychain ?? 'the default keychain'}`); } export interface SigningState { bundleId: string; teamId: string | null; registered: boolean; capabilities: { present: string[]; missing: string[]; extra: string[] }; certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null }; profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[]; } export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const sub = a._[0]; if (sub !== 'status' && sub !== 'ensure') throw new Error(USAGE); const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const s: AppleSigning = config.apple?.signing ?? {}; const certType: CertType = s.certificateType ?? 'DISTRIBUTION'; const profileType: ProfileType = s.profileType ?? 'IOS_APP_STORE'; const dry = a.has('dry'); const json = a.has('json'); const c = await ascClient(root, bundleId, config.apple?.appId); // --- read everything first (read-only) const ids = [bundleId, ...(s.extraBundleIds ?? [])]; const bundles = new Map(); for (const id of ids) bundles.set(id, await findBundleId(c, id)); const main = bundles.get(bundleId); const capPlan = main ? planCapabilities(main.id, main.capabilities ?? [], capabilitySpecs(s)) : { create: [], present: [], extra: [] }; const certs: ApiCert[] = await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`); const keys = localKeys(root); const identities = keychainIdentities(); const hasKey = (x: ApiCert): boolean => { const content = x.attributes.certificateContent; if (!content) return false; const spki = certSpki(content); return (!!spki && keys.has(spki)) || identities.has(certSha1(content)); }; const choice = pickCertificate(certs, certType, hasKey); const profiles = new Map | null }>(); for (const id of ids) { const b = bundles.get(id); if (!b) { profiles.set(id, { list: [], plan: null }); continue; } const list = await profilesOf(c, b.id, profileType); profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null }); } const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, capabilities: { present: capPlan.present, missing: capabilitySpecs(s).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, certificate: { type: certType, chosen: choice.cert?.attributes.name ?? choice.cert?.attributes.displayName ?? null, why: choice.why, onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null }, profiles: ids.map(id => { const p = profiles.get(id)!; if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' }; const r = p.plan?.reuse; const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE'); if (!r && managed && (s.style ?? 'automatic') === 'automatic') return { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0, why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'})` }; return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile', installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' }; }), }; if (sub === 'status') { if (json) { printJson(state); return; } log(TAG, `${bundleId}: ${main ? `registered, team ${state.teamId ?? '?'}` : 'NOT registered (storebound apple bundle)'}`); log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`); log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`); for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`); console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why]))); done(TAG, 'status read'); return; } // --- ensure: plan, show, ask once, then write if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`); const missingExtra = ids.filter(id => !bundles.get(id)); if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`); const needCert = !choice.cert; const plan: string[] = [ ...capPlan.create.map(r => describeReq(r)), ...(needCert ? [describeReq(certificateReq(certType, ''))] : []), ...ids.flatMap(id => { const p = profiles.get(id)!.plan; return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : []; }), ]; if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit) warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`); if (json && dry) { printJson({ ...state, plan }); return; } for (const line of plan) log(TAG, `${dry ? 'would ' : ''}${line}`); if (!plan.length) log(TAG, 'nothing to create on App Store Connect'); if (dry) { for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`); done(TAG, `signing checked (dry run): ${plan.length} write(s)`); return; } if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return; await execute(c.asc, capPlan.create, { dry: false, tag: TAG }); let cert = choice.cert; if (needCert) { const dir = signingDir(root); mkdirSync(dir, { recursive: true, mode: 0o700 }); const { key } = newSigningKey(); const pending = join(dir, `pending-${Date.now()}.key`); writeFileSync(pending, key.export({ type: 'pkcs1', format: 'pem' }).toString(), { mode: 0o600 }); chmodSync(pending, 0o600); let created: J; try { const csr = buildCsr(key, { commonName: `storebound ${config.app.name}`.slice(0, 64) }); created = (await c.asc('POST', '/v1/certificates', certificateReq(certType, csrContent(csr)).body)).data; } catch (e) { if (isAmbiguous(e)) { // the certificate may exist: it is the one whose public key is this pending key's. The key stays on disk either // way (a later run finds the certificate through it), so no certificate is ever orphaned or made twice. const mine = spkiOfKey(readFileSync(pending, 'utf8')); let found: J | undefined; for (let i = 0; i < 4 && !found; i++) { if (i) await upsertTiming.sleep(upsertTiming.waitMs * i); found = (await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`)).find((x: ApiCert) => !!x.attributes.certificateContent && certSpki(x.attributes.certificateContent) === mine); } if (!found) throw new Error(`${(e as Error).message}\n\nThe certificate request got no clear answer and App Store Connect does not list a certificate for the new key (yet). The key is kept in ${pending.replace(root + '/', '')}; run \`storebound apple signing ensure\` again in a minute: it finds the certificate through that key instead of making another one.`); created = found; log(TAG, 'the certificate request went through (found by its public key)'); } else { rmSync(pending, { force: true }); const why = explainCertError(e, certType); throw new Error(why ? `${(e as Error).message}\n\n${why}` : (e as Error).message); } } const keyFile = join(dir, `${created.id}.key`), cerFile = join(dir, `${created.id}.cer`); renameSync(pending, keyFile); writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 }); log(TAG, `certificate ${created.attributes.name ?? created.id} created (expires ${String(created.attributes.expirationDate).slice(0, 10)}); key and certificate in .storebound/signing/ (gitignored, back them up)`); if (a.has('keychain')) keychainImport(keyFile, cerFile, a.get('keychain') === 'true' ? null : a.get('keychain')!); else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)'); cert = created; } for (const id of ids) { const b = bundles.get(id)!; const list = profiles.get(id)!.list; const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); let prof: ApiProfile; if (p.create) { // an unclear answer is checked by reading the bundle id's profiles again (one active profile with this // certificate is reused), never re-sent blindly const r = (await createOnce({ what: `${id} profile`, tag: TAG, find: async () => { const again = planProfile(await profilesOf(c, b.id, profileType), { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); return again.reuse ? { id: again.reuse.id, attributes: (await c.asc('GET', `/v1/profiles/${again.reuse.id}`)).data.attributes } : null; }, create: async () => (await c.asc('POST', p.create!.path, p.create!.body)).data })).value; prof = { id: r.id, attributes: r.attributes }; log(TAG, `${id}: profile "${prof.attributes.name}" created (${prof.attributes.uuid})`); } else { prof = p.reuse!; if (!prof.attributes.profileContent) prof = { ...prof, attributes: (await c.asc('GET', `/v1/profiles/${prof.id}`)).data.attributes }; log(TAG, `${id}: ${p.why}`); } if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`); } if (json) printJson({ ...state, plan, done: true }); done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)'); }