Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions cli/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,16 @@ Promise.all([buildCLI, buildSDK]).then(async (results) => {
writeFileSync('meta.json', JSON.stringify(metafile))

copyFileSync('package.json', 'dist/package.json')

// Ship the macOS keychain-export Swift helper alongside the bundle. The
// CLI compiles it on first use into an OS temp folder via `swiftc`. Source
// is shipped (not a precompiled binary) to keep the npm tarball Linux/Win-
// safe and to skip code-signing infrastructure for now.
copyFileSync(
'src/build/onboarding/keychain-export.swift',
'dist/keychain-export.swift',
)

console.warn('βœ… Built CLI and SDK successfully')
}).catch((err) => {
console.error('Build failed:', err)
Expand Down
4 changes: 3 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@
"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:macos-signing": "bun test/test-macos-signing.mjs",
"test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs",
"test:manifest-path-encoding": "bun test/test-manifest-path-encoding.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:build-needed && 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:manifest-path-encoding",
"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:build-needed && 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:manifest-path-encoding && 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
135 changes: 133 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,106 @@ 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.
*
* SECURITY NOTE on SHA1: this is NOT a security primitive. macOS itself
* reports code-signing identities as cert-DER SHA1 (via `security
* find-identity`), and we have to use the same hash to match a profile's
* embedded certs against a Keychain identity. SHA1 here is a non-secret
* identifier, not a message digest protecting any data. CodeQL's "weak
* cryptographic algorithm" rule is suppressed for this reason.
*/
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')
// lgtm[js/weak-cryptographic-algorithm] SHA1 required for compatibility
// with `security find-identity` output β€” see comment above.
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
109 changes: 106 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,127 @@ 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.
*
* SECURITY NOTE on SHA1: this is NOT a security primitive. macOS itself
* reports code-signing identities as cert-DER SHA1 (via `security
* find-identity`), and we have to use the same hash to look up an Apple-side
* cert by its on-Mac counterpart. SHA1 here is a non-secret identifier, not
* a message digest protecting any data. CodeQL's "weak cryptographic
* algorithm" rule is suppressed for this reason.
*/
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')
// lgtm[js/weak-cryptographic-algorithm] SHA1 is required for compatibility
// with `security find-identity` output β€” see comment above.
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 +232,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