Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@
"test:version-detection:setup": "./test/fixtures/setup-test-projects.sh",
"test:platform-paths": "bun test/test-platform-paths.mjs",
"test:payload-split": "bun test/test-payload-split.mjs",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:ci-prompts && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split",
"test:macos-signing": "bun test/test-macos-signing.mjs",
"test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:ci-prompts && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:macos-signing && bun run test:apple-api-import-helpers",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs"
},
"dependencies": {
Expand Down
126 changes: 124 additions & 2 deletions cli/src/build/mobileprovision-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'

export interface MobileprovisionInfo {
Expand All @@ -8,6 +9,26 @@ export interface MobileprovisionInfo {
bundleId: string
}

/**
* Detail returned by {@link parseMobileprovisionDetailed} β€” extends
* {@link MobileprovisionInfo} with team/expiry/profile-type metadata and the
* SHA1 of each developer certificate embedded in the profile.
*
* The SHA1 list enables matching a profile against a Keychain identity
* returned by `security find-identity` (which reports identities by the same
* SHA1 hash).
*/
export interface MobileprovisionDetail extends MobileprovisionInfo {
/** Apple Team ID (10-char alphanumeric) β€” empty string if not present */
teamId: string
/** ISO timestamp string from the profile's ExpirationDate, or empty string */
expirationDate: string
/** High-level profile type derived from the profile's flags */
profileType: 'app_store' | 'ad_hoc' | 'development' | 'enterprise' | 'unknown'
/** SHA1 (40-char lowercase hex) of each DeveloperCertificate embedded in the profile */
certificateSha1s: string[]
}

export function parseMobileprovision(filePath: string): MobileprovisionInfo {
const data = readFileSync(filePath)
return parseMobileprovisionBuffer(data, filePath)
Expand All @@ -18,6 +39,18 @@ export function parseMobileprovisionFromBase64(base64Content: string): Mobilepro
return parseMobileprovisionBuffer(data, '<base64 input>')
}

/**
* Parse a mobileprovision file and return enriched metadata including:
* - team ID
* - expiration date
* - profile type (app_store / ad_hoc / development / enterprise)
* - SHA1 of each embedded developer certificate (used for cert↔profile matching)
*/
export function parseMobileprovisionDetailed(filePath: string): MobileprovisionDetail {
const data = readFileSync(filePath)
return parseMobileprovisionBufferDetailed(data, filePath)
}

function parseMobileprovisionBuffer(data: Buffer, source: string): MobileprovisionInfo {
const xmlStartMarker = '<?xml'
const xmlEndMarker = '</plist>'
Expand All @@ -44,8 +77,97 @@ function parseMobileprovisionBuffer(data: Buffer, source: string): Mobileprovisi
return { name, uuid, applicationIdentifier, bundleId }
}

function extractPlistValue(xml: string, key: string): string | null {
const regex = new RegExp(`<key>${escapeRegex(key)}</key>\\s*<string>([^<]*)</string>`)
function parseMobileprovisionBufferDetailed(data: Buffer, source: string): MobileprovisionDetail {
const base = parseMobileprovisionBuffer(data, source)

const xmlStartMarker = '<?xml'
const xmlEndMarker = '</plist>'
const xmlStartIdx = data.indexOf(xmlStartMarker)
const xmlEndIdx = data.indexOf(xmlEndMarker, xmlStartIdx)
const plistXml = data.slice(xmlStartIdx, xmlEndIdx + xmlEndMarker.length).toString('utf-8')

const teamId = extractTeamIdFromPlist(plistXml)
const expirationDate = extractPlistValue(plistXml, 'ExpirationDate', 'date') || ''
const profileType = deriveProfileType(plistXml)
const certificateSha1s = extractCertificateSha1s(plistXml)

return {
...base,
teamId,
expirationDate,
profileType,
certificateSha1s,
}
}

/**
* TeamIdentifier is an array; we take the first entry. Falls back to empty
* string if TeamIdentifier is missing.
*/
function extractTeamIdFromPlist(xml: string): string {
const arrayMatch = xml.match(/<key>TeamIdentifier<\/key>\s*<array>([\s\S]*?)<\/array>/)
if (arrayMatch) {
const stringMatch = arrayMatch[1].match(/<string>([^<]+)<\/string>/)
if (stringMatch)
return stringMatch[1]
}
return ''
}

/**
* Derive profile type from plist flags:
* - ProvisionsAllDevices=true β†’ enterprise
* - ProvisionedDevices present + get-task-allow=true β†’ development
* - ProvisionedDevices present (no get-task-allow) β†’ ad_hoc
* - else β†’ app_store
*/
function deriveProfileType(xml: string): MobileprovisionDetail['profileType'] {
const provisionsAllDevices = /<key>ProvisionsAllDevices<\/key>\s*<true\s*\/>/.test(xml)
if (provisionsAllDevices)
return 'enterprise'

const hasProvisionedDevices = /<key>ProvisionedDevices<\/key>\s*<array>/.test(xml)
// get-task-allow=true inside Entitlements is the dev indicator
const hasGetTaskAllowTrue = /<key>get-task-allow<\/key>\s*<true\s*\/>/.test(xml)

if (hasProvisionedDevices) {
if (hasGetTaskAllowTrue)
return 'development'
return 'ad_hoc'
}

return 'app_store'
}

/**
* Extract SHA1 of every DeveloperCertificate in the profile.
* The plist stores certs as base64-encoded DER inside a <data> element.
*/
function extractCertificateSha1s(xml: string): string[] {
const arrayMatch = xml.match(/<key>DeveloperCertificates<\/key>\s*<array>([\s\S]*?)<\/array>/)
if (!arrayMatch)
return []

const sha1s: string[] = []
for (const match of arrayMatch[1].matchAll(/<data>([\s\S]*?)<\/data>/g)) {
const base64 = match[1].replace(/\s+/g, '')
if (!base64)
continue
try {
const der = Buffer.from(base64, 'base64')
const sha1 = createHash('sha1').update(der).digest('hex').toLowerCase()
sha1s.push(sha1)
}
catch {
// Skip malformed entries silently β€” partial matches are still useful
}
}
return sha1s
}

function extractPlistValue(xml: string, key: string, valueTag: string = 'string'): string | null {
const tag = escapeRegex(valueTag)
const regex = new RegExp(`<key>${escapeRegex(key)}</key>\\s*<${tag}>([^<]*)</${tag}>`)
const match = xml.match(regex)
return match ? match[1] : null
}
Expand Down
100 changes: 97 additions & 3 deletions cli/src/build/onboarding/apple-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,118 @@ export async function verifyApiKey(token: string): Promise<{ valid: true, teamId
}
}

export interface AscDistributionCert {
id: string
name: string
serialNumber: string
expirationDate: string
/**
* Base64-encoded DER of the certificate. Populated when {@link listDistributionCerts}
* is called with `includeContent: true` β€” kept optional so existing callers don't pay
* the larger payload when they don't need it.
*/
certificateContent?: string
}

/**
* List all iOS distribution certificates.
*
* Set `includeContent: true` when you need to compute the cert's SHA1 for
* matching against a local Keychain identity ({@link findCertIdBySha1}).
*/
export async function listDistributionCerts(
token: string,
): Promise<Array<{ id: string, name: string, serialNumber: string, expirationDate: string }>> {
options: { includeContent?: boolean } = {},
): Promise<AscDistributionCert[]> {
const body = await ascFetch(
'/certificates?filter[certificateType]=IOS_DISTRIBUTION&limit=10',
token,
)
return (body.data || []).map((c: any) => ({
return (body.data || []).map((c: any): AscDistributionCert => ({
id: c.id,
name: c.attributes.name || c.attributes.displayName || 'iOS Distribution',
serialNumber: c.attributes.serialNumber || '',
expirationDate: c.attributes.expirationDate,
...(options.includeContent && c.attributes.certificateContent
? { certificateContent: c.attributes.certificateContent as string }
: {}),
}))
}

/**
* Compute the SHA1 hash of an ASC certificate's base64-DER content. Returns
* the lowercase 40-char hex string used elsewhere as the canonical identity
* key β€” matches the SHA1 reported by `security find-identity` on macOS.
*/
export function computeCertSha1(certificateContentBase64: string): string {
// Lazy require β€” keep crypto out of the import-time graph
// eslint-disable-next-line ts/no-require-imports
const { Buffer } = require('node:buffer') as typeof import('node:buffer')
// eslint-disable-next-line ts/no-require-imports
const { createHash } = require('node:crypto') as typeof import('node:crypto')
const der = Buffer.from(certificateContentBase64, 'base64')
return createHash('sha1').update(der).digest('hex').toLowerCase()
}

/**
* Match a local Keychain identity (by its SHA1) against an Apple-side
* certificate and return the Apple certificate ID needed for profile
* creation. Returns null if no Apple-side cert matches the SHA1.
*/
export async function findCertIdBySha1(token: string, sha1: string): Promise<string | null> {
const target = sha1.toLowerCase()
const certs = await listDistributionCerts(token, { includeContent: true })
for (const cert of certs) {
if (!cert.certificateContent)
continue
if (computeCertSha1(cert.certificateContent) === target)
return cert.id
}
return null
}

/**
* List all provisioning profiles linked to a specific Apple-side certificate.
* Used by the import-flow no-match-recovery menu to surface profiles that
* exist on Apple but haven't been downloaded to the user's Mac.
*/
export interface AscProfileSummary {
id: string
name: string
profileType: string
profileContent: string
expirationDate: string
bundleIdentifier: string
}

export async function listProfilesForCert(
token: string,
certificateId: string,
): Promise<AscProfileSummary[]> {
// The relationships filter does the server-side join for us
const body = await ascFetch(
`/profiles?filter[certificates]=${encodeURIComponent(certificateId)}&include=bundleId&limit=50`,
token,
)
const included: any[] = body.included || []
const bundleById = new Map<string, string>()
for (const item of included) {
if (item.type === 'bundleIds' && item.attributes?.identifier)
bundleById.set(item.id, item.attributes.identifier)
}
return (body.data || []).map((p: any): AscProfileSummary => {
const bundleRelId = p.relationships?.bundleId?.data?.id as string | undefined
return {
id: p.id,
name: p.attributes.name || '',
profileType: p.attributes.profileType || '',
profileContent: p.attributes.profileContent || '',
expirationDate: p.attributes.expirationDate || '',
bundleIdentifier: bundleRelId ? bundleById.get(bundleRelId) || '' : '',
}
})
}

/**
* Revoke (delete) a certificate by ID.
*/
Expand All @@ -129,7 +223,7 @@ export async function revokeCertificate(token: string, certId: string): Promise<
*/
export class CertificateLimitError extends Error {
constructor(
public readonly certificates: Array<{ id: string, name: string, serialNumber: string, expirationDate: string }>,
public readonly certificates: AscDistributionCert[],
) {
super(`Certificate limit reached. Found ${certificates.length} existing iOS distribution certificate(s).`)
this.name = 'CertificateLimitError'
Expand Down
Loading
Loading