apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs
This commit is contained in:
parent
2e1c7cdc3c
commit
779cbdfcfd
17 changed files with 829 additions and 42 deletions
45
cli/lib/keychain.ts
Normal file
45
cli/lib/keychain.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Code signing keychain for `storebound apple build` outside the Mac's logged-in GUI session. codesign in an SSH
|
||||
// session (or any session without the login keychain unlocked) fails late with errSecInternalComponent. The fixes:
|
||||
// - run from the GUI session (a .command file opened with `open -a Terminal`)
|
||||
// - a CI runner / Jenkins agent started in that session, or with an unlocked keychain
|
||||
// - a build keychain: --keychain <path>, unlocked with --keychain-unlock (password from STOREBOUND_KEYCHAIN_PASSWORD,
|
||||
// never printed) and its key partition list set so codesign may use the keys without a prompt.
|
||||
// Pure planning here; cli/apple/build.ts runs it.
|
||||
export const KEYCHAIN_PASSWORD_ENV = 'STOREBOUND_KEYCHAIN_PASSWORD';
|
||||
|
||||
/** The refusal for a build in an SSH / non-GUI session, naming every fix. */
|
||||
export function sshBuildRefusal(reason: string, o: { unlockFlag?: boolean } = {}): string {
|
||||
const unlock = o.unlockFlag !== false;
|
||||
return `apple build: ${reason}; codesign there fails with errSecInternalComponent (locked keychain). Fixes, pick one:\n`
|
||||
+ ` 1. Run it in the Mac's logged-in GUI session: write the command into a .command file and \`open -a Terminal <file>\` (unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside).\n`
|
||||
+ ` 2. A CI runner / Jenkins agent started in the GUI session, or one whose keychain is unlocked first (\`security unlock-keychain -p "$${KEYCHAIN_PASSWORD_ENV}" <keychain>\`, then \`security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$${KEYCHAIN_PASSWORD_ENV}" <keychain>\`).\n`
|
||||
+ (unlock ? ` 3. Let storebound do step 2: export ${KEYCHAIN_PASSWORD_ENV}=... (the keychain password; never printed) and add --keychain-unlock (the login keychain), or --keychain <path> --keychain-unlock for a build keychain that holds the Apple Distribution key + certificate (storebound apple signing ensure --keychain <path> imports them).\n`
|
||||
+ ` --allow-ssh skips this check when the keychain is already unlocked in this session.`
|
||||
: ' --allow-ssh skips this check when the keychain is already unlocked in this session.');
|
||||
}
|
||||
|
||||
export interface KeychainPlan { keychain: string; steps: string[][] }
|
||||
|
||||
/** The `security` calls --keychain-unlock runs, with PASSWORD as a placeholder (substituted by the caller, never logged). */
|
||||
export function unlockSteps(keychain: string, searchList: string[]): string[][] {
|
||||
const steps: string[][] = [['security', 'unlock-keychain', '-p', 'PASSWORD', keychain],
|
||||
['security', 'set-key-partition-list', '-S', 'apple-tool:,apple:,codesign:', '-s', '-k', 'PASSWORD', keychain]];
|
||||
if (!searchList.includes(keychain)) steps.push(['security', 'list-keychains', '-d', 'user', '-s', keychain, ...searchList]);
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** `security list-keychains` output -> paths (quotes stripped). */
|
||||
export const parseKeychainList = (out: string): string[] => out.split('\n').map(l => l.trim().replace(/^"|"$/g, '')).filter(Boolean);
|
||||
|
||||
/** The password from the environment, or an error that names the variable (the value is never in a message). */
|
||||
export function keychainPassword(env: Record<string, string | undefined>): string {
|
||||
const p = env[KEYCHAIN_PASSWORD_ENV];
|
||||
if (!p) throw new Error(`--keychain-unlock needs the keychain password in the environment variable ${KEYCHAIN_PASSWORD_ENV} (not an argument: arguments end up in shell history)`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A command line for logs with the password replaced. */
|
||||
export const showSecurity = (argv: string[]): string => argv.map(a => (a === 'PASSWORD' ? '$' + KEYCHAIN_PASSWORD_ENV : a)).join(' ');
|
||||
|
||||
/** Settings that make xcodebuild sign with a given keychain. */
|
||||
export const keychainSigningArgs = (keychain: string): string[] => [`OTHER_CODE_SIGN_FLAGS=--keychain ${keychain}`];
|
||||
|
|
@ -43,6 +43,20 @@ export interface AppleSigning {
|
|||
capabilities?: (CapabilityType | CapabilitySpec)[];
|
||||
/** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */
|
||||
extraBundleIds?: string[];
|
||||
/** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project
|
||||
* (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is
|
||||
* registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */
|
||||
extensions?: ExtensionSpec[];
|
||||
}
|
||||
|
||||
export interface ExtensionSpec {
|
||||
bundleId: string;
|
||||
/** A label for messages and the bundle id's name on the developer portal. */
|
||||
name?: string;
|
||||
/** Default: the capabilities of the target's entitlements file (none when there is none). */
|
||||
capabilities?: (CapabilityType | CapabilitySpec)[];
|
||||
/** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */
|
||||
appGroups?: string[];
|
||||
}
|
||||
|
||||
export function validateSigning(s: AppleSigning | undefined): string[] {
|
||||
|
|
@ -56,6 +70,14 @@ export function validateSigning(s: AppleSigning | undefined): string[] {
|
|||
const t = typeof c === 'string' ? c : c?.type;
|
||||
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`);
|
||||
}
|
||||
for (const x of s.extensions ?? []) {
|
||||
if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; }
|
||||
for (const c of x.capabilities ?? []) {
|
||||
const t = typeof c === 'string' ? c : c?.type;
|
||||
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`);
|
||||
}
|
||||
if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`);
|
||||
}
|
||||
for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`);
|
||||
return e;
|
||||
}
|
||||
|
|
@ -158,19 +180,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date
|
|||
export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string }
|
||||
|
||||
/** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */
|
||||
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan {
|
||||
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan {
|
||||
const now = o.now ?? Date.now();
|
||||
const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE'
|
||||
&& Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId))
|
||||
.sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!));
|
||||
if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
|
||||
if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
|
||||
const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed);
|
||||
const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed);
|
||||
const why = [
|
||||
stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '',
|
||||
managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '',
|
||||
].filter(Boolean).join('; ') || `no ${o.type} profile`;
|
||||
return { reuse: null, why, create: {
|
||||
const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why;
|
||||
return { reuse: null, why: why2, create: {
|
||||
method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`,
|
||||
body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type },
|
||||
relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } },
|
||||
|
|
|
|||
138
cli/lib/targets.ts
Normal file
138
cli/lib/targets.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an
|
||||
// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the
|
||||
// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts.
|
||||
// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a
|
||||
// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in
|
||||
// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline.
|
||||
export type TargetKind = 'app' | 'extension' | 'watch' | 'clip';
|
||||
|
||||
export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null }
|
||||
|
||||
/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test
|
||||
* bundles, tools and libraries are not. */
|
||||
export function targetKind(productType: string): TargetKind | null {
|
||||
const m = /^com\.apple\.product-type\.(.+)$/.exec(productType);
|
||||
const t = m?.[1];
|
||||
if (!t) return null;
|
||||
if (t === 'application.on-demand-install-capable') return 'clip';
|
||||
if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch';
|
||||
if (t === 'application') return 'app';
|
||||
if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */
|
||||
function pbxObjects(pbx: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm;
|
||||
for (let m = re.exec(pbx); m; m = re.exec(pbx)) {
|
||||
const open = m.index + m[0].length - 1;
|
||||
let depth = 0, q = false, i = open;
|
||||
for (; i < pbx.length; i++) {
|
||||
const ch = pbx[i];
|
||||
if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; }
|
||||
if (ch === '"') q = true;
|
||||
else if (ch === '{') depth++;
|
||||
else if (ch === '}' && --depth === 0) break;
|
||||
}
|
||||
out.set(m[1]!, pbx.slice(open + 1, i));
|
||||
re.lastIndex = i;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; };
|
||||
const field = (body: string, name: string): string | null => {
|
||||
const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body);
|
||||
return m ? unquote(m[1]!) : null;
|
||||
};
|
||||
|
||||
function buildSettings(body: string): Record<string, string> {
|
||||
const at = body.search(/buildSettings\s*=\s*\{/);
|
||||
if (at < 0) return {};
|
||||
const inner = body.slice(body.indexOf('{', at) + 1);
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier)
|
||||
* makes the value an identifier. Anything still containing $( afterwards is unresolved. */
|
||||
export function resolveSetting(value: string, vars: Record<string, string>): string {
|
||||
let v = value;
|
||||
for (let n = 0; n < 5 && /\$[({]/.test(v); n++) {
|
||||
v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => {
|
||||
const r = vars[name];
|
||||
if (r === undefined || r === `$(${name})`) return all;
|
||||
return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r;
|
||||
});
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** The signable targets of one project.pbxproj, in file order. */
|
||||
export function pbxTargets(pbx: string): PbxTarget[] {
|
||||
const objs = pbxObjects(pbx);
|
||||
const out: PbxTarget[] = [];
|
||||
for (const body of objs.values()) {
|
||||
if (field(body, 'isa') !== 'PBXNativeTarget') continue;
|
||||
const productType = field(body, 'productType') ?? '';
|
||||
const kind = targetKind(productType);
|
||||
if (!kind) continue;
|
||||
const name = field(body, 'name') ?? field(body, 'productName') ?? '?';
|
||||
const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!);
|
||||
const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!);
|
||||
const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b);
|
||||
const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1];
|
||||
const s = cfg ? buildSettings(cfg) : {};
|
||||
const vars: Record<string, string> = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) };
|
||||
const raw = s.PRODUCT_BUNDLE_IDENTIFIER;
|
||||
const bundleId = raw ? resolveSetting(raw, vars) : null;
|
||||
const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null;
|
||||
out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */
|
||||
export const profileAppGroups = (ent: Record<string, unknown> | undefined): string[] => {
|
||||
const g = ent?.['com.apple.security.application-groups'];
|
||||
return Array.isArray(g) ? g.map(String) : [];
|
||||
};
|
||||
|
||||
/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */
|
||||
export function appGroupGaps(want: string[], profileEnt: Record<string, unknown> | undefined): string[] {
|
||||
const have = profileAppGroups(profileEnt);
|
||||
if (have.includes('*')) return [];
|
||||
return want.filter(g => !have.includes(g));
|
||||
}
|
||||
|
||||
/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */
|
||||
export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`;
|
||||
|
||||
export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null }
|
||||
|
||||
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text. */
|
||||
export function appGroupSteps(g: AppGroupGap): string[] {
|
||||
const url = g.url ?? 'https://developer.apple.com/account/resources/identifiers/list';
|
||||
return [
|
||||
`${g.bundleId}: the profile lacks App Group(s) ${g.groups.join(', ')} (the API can enable the App Groups capability but cannot assign a group to a bundle id).`,
|
||||
`Open ${url} in the signed-in browser (agent-first: you drive it).`,
|
||||
'Capabilities list > App Groups: tick it if it is not ticked, press Configure, tick each group above (Continue if the group is new: register it first under Identifiers > App Groups), Continue, then Save at the top right (and Confirm in the dialog that says the profiles get invalidated).',
|
||||
'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button.',
|
||||
`Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */
|
||||
export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record<string, unknown> | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] {
|
||||
const out: AppGroupGap[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.appGroups.length) continue;
|
||||
const ent = profileEnt(t.bundleId);
|
||||
if (ent === null) continue; // no readable profile: unknown, not a gap
|
||||
const groups = appGroupGaps(t.appGroups, ent);
|
||||
if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -125,14 +125,14 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string }
|
|||
|
||||
const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []);
|
||||
|
||||
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null }
|
||||
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[] }
|
||||
|
||||
/** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */
|
||||
export function archiveArgs(i: ArchiveInput): string[] {
|
||||
return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]),
|
||||
'-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath,
|
||||
...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
|
||||
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : [])];
|
||||
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])];
|
||||
}
|
||||
|
||||
export function exportArgs(archivePath: string, plist: string, exportPath: string, auth: AscAuth | null): string[] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue