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
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,19 @@ public struct DeveloperServicesAddAppOperation: DeveloperServicesOperation {
signingInfo: signingInfo
).perform()

// the profile is created last and doesn't necessarily grant everything we're about to claim
let profile = try mobileprovision.digest()
let reconciled = try EntitlementsReconciler.reconcile(
entitlements, with: profile.entitlements, profileName: profile.name
)
entitlements = reconciled.entitlements
for key in reconciled.removed {
FileHandle.standardError.write(Data((
"warning: dropping the entitlement '\(key)' from \(bundleID): it only applies to "
+ "distribution builds and the development profile doesn't grant it\n"
).utf8))
}

return ProvisioningInfo(
newBundleID: newBundleID,
entitlements: entitlements,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import Foundation

/// Brings the entitlements an app is about to be signed with in line with what its provisioning
/// profile actually grants.
///
/// installd rejects an app whose signature claims an entitlement the profile doesn't grant, with the
/// unhelpful `0xe8008015 A valid provisioning profile for this executable was not found`. The profile
/// is created through the API after the entitlements are computed, and it doesn't necessarily end up with
/// all of them (for instance, App Groups can't be managed with an App Store Connect API key). This
/// catches the difference before it reaches the device.
public enum EntitlementsReconciler {

public enum Error: LocalizedError, CustomStringConvertible, Equatable {
case appGroupsNotGranted(groups: [String], profile: String)
case notGranted(key: String, profile: String)
case valueNotGranted(key: String, profile: String)

private var message: String {
switch self {
case .appGroupsNotGranted(let groups, let profile):
"The provisioning profile '\(profile)' does not include the app groups "
+ "\(groups.joined(separator: ", ")). App Groups can only be created when signed in with an "
+ "Apple ID, not with an App Store Connect API key: run `xtool auth login --mode password`, "
+ "or remove the com.apple.security.application-groups entitlement."
case .notGranted(let key, let profile):
"The app requests the entitlement '\(key)', which the provisioning profile '\(profile)' "
+ "does not grant. Remove it from the app's entitlements, or enable the matching "
+ "capability for this app ID."
case .valueNotGranted(let key, let profile):
"The value of the entitlement '\(key)' is not allowed by the provisioning profile '\(profile)'."
}
}

// the CLI prints errors with string interpolation, which ignores `errorDescription`
public var description: String { message }
public var errorDescription: String? { message }
}

/// Entitlements only meaningful for a distribution build (an Xcode-exported or xtool-distribution-signed
/// IPA carries them). A development profile never grants them, so re-signing for development drops them.
private static let distributionOnlyKeys: Set<String> = [
"beta-reports-active",
"com.apple.developer.icloud-container-environment",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]

public struct Result {
public var entitlements: Entitlements
/// Distribution-only entitlements that were dropped because the profile doesn't grant them
public var removed: [String]
}

private static let appGroupsKey = AppGroupEntitlement.identifier
private static let apsEnvironmentKey = APSEnvironmentEntitlement.identifier

/// Entitlements that are derived from the profile and the bundle, never requested by the app
private static let derivedKeys: Set<String> = [
ApplicationIdentifierEntitlement.identifier,
TeamIdentifierEntitlement.identifier,
GetTaskAllowEntitlement.identifier,
KeychainAccessGroupsEntitlement.identifier,
]

private static let knownKeys: Set<String> = Set(
EntitlementContainer.supportedTypes.compactMap { ($0 as? any Entitlement.Type)?.identifier }
).subtracting(derivedKeys)

/// - Parameters:
/// - entitlements: What the app is going to be signed with.
/// - granted: The entitlements of the provisioning profile.
/// - Returns: `entitlements`, with the values the profile dictates replaced and the distribution-only
/// entitlements it doesn't grant removed.
/// - Throws: ``Error`` if the app claims something the profile doesn't grant.
///
/// Only entitlements xtool knows how to provision are checked. Others are passed through
/// untouched, since we can't tell whether the profile is expected to list them.
public static func reconcile(
_ entitlements: Entitlements,
with granted: Entitlements,
profileName: String
) throws -> Result {
let grants = try granted.plistDictionary()
var result = try entitlements.plistDictionary()

let removed = distributionOnlyKeys.filter { result[$0] != nil && grants[$0] == nil }.sorted()
removed.forEach { result[$0] = nil }

for key in result.keys.sorted() where knownKeys.contains(key) {
guard let request = result[key] else { continue }
guard let grant = grants[key] else {
throw key == appGroupsKey
? Error.appGroupsNotGranted(groups: request as? [String] ?? [], profile: profileName)
: Error.notGranted(key: key, profile: profileName)
}

if key == apsEnvironmentKey {
// a development profile only allows the sandbox push environment, whatever the
// app was built with; Xcode makes the same substitution
result[key] = grant
} else if !permits(grant: grant, request: request) {
if key == appGroupsKey {
// the profile may list the entitlement with no (or other) groups, which is
// what happens when groups can't be assigned
let missing = (request as? [String] ?? []).filter {
!permits(grant: grant, request: [$0])
}
throw Error.appGroupsNotGranted(groups: missing, profile: profileName)
}
throw Error.valueNotGranted(key: key, profile: profileName)
}
}

return Result(entitlements: try Entitlements(plistDictionary: result), removed: removed)
}

/// Whether `grant` (a value from a profile, possibly with wildcards) allows `request`
static func permits(grant: Any, request: Any) -> Bool {
switch (grant, request) {
case let (grant as [String], request as [String]):
return request.allSatisfy { item in
grant.contains { $0 == item || ($0.hasSuffix("*") && item.hasPrefix(String($0.dropLast()))) }
}
case let (grant as String, request as String):
return grant == request || (grant.hasSuffix("*") && request.hasPrefix(String(grant.dropLast())))
case let (grant as Bool, request as Bool):
return grant || !request
default:
return (grant as? NSObject)?.isEqual(request) ?? false
}
}

}

extension Entitlements {

init(plistDictionary: [String: Any]) throws {
let data = try PropertyListSerialization.data(fromPropertyList: plistDictionary, format: .xml, options: 0)
self = try PropertyListDecoder().decode(Entitlements.self, from: data)
}

func plistDictionary() throws -> [String: Any] {
let encoder = PropertyListEncoder()
encoder.outputFormat = .xml
let data = try encoder.encode(self)
return try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] ?? [:]
}

}
132 changes: 132 additions & 0 deletions Tests/XToolTests/EntitlementsReconcilerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import Foundation
import Testing
@testable import XKit

@Suite struct EntitlementsReconcilerTests {
private func entitlements(_ dictionary: [String: Any]) throws -> Entitlements {
try Entitlements(plistDictionary: dictionary)
}

private func reconcile(
_ requested: [String: Any],
granted: [String: Any]
) throws -> [String: Any] {
try EntitlementsReconciler.reconcile(
entitlements(requested), with: entitlements(granted), profileName: "dev profile"
).entitlements.plistDictionary()
}

/// What a development profile created through the ASC API grants an app with push but no groups
private let developmentGrants: [String: Any] = [
"application-identifier": "TEAM.XTL-1.app",
"com.apple.developer.team-identifier": "TEAM",
"get-task-allow": true,
"aps-environment": "development",
"com.apple.security.application-groups": [String](),
"keychain-access-groups": ["TEAM.*"],
]

@Test func pushEnvironmentComesFromTheProfile() throws {
let result = try reconcile(
["aps-environment": "production", "get-task-allow": true], granted: developmentGrants
)
#expect(result["aps-environment"] as? String == "development")
#expect(result["get-task-allow"] as? Bool == true)
}

@Test func appGroupsTheProfileLacksAreAnActionableError() throws {
let error = try #require(throws: EntitlementsReconciler.Error.self) {
try reconcile(
["com.apple.security.application-groups": ["group.com.example.app"]],
granted: developmentGrants
)
}
#expect(error == .appGroupsNotGranted(groups: ["group.com.example.app"], profile: "dev profile"))
let message = try #require(error.errorDescription)
#expect(message.contains("group.com.example.app"))
#expect(message.contains("xtool auth login --mode password"))
// what the CLI prints for `"\(error)"`
#expect("\(error)" == message)
#expect(String(describing: error as any Swift.Error) == message)
}

@Test func appGroupsAreKeptWhenTheProfileHasThem() throws {
var granted = developmentGrants
granted["com.apple.security.application-groups"] = ["group.XTL-1.example"]
let result = try reconcile(
["com.apple.security.application-groups": ["group.XTL-1.example"]], granted: granted
)
#expect(result["com.apple.security.application-groups"] as? [String] == ["group.XTL-1.example"])
}

@Test func missingGrantNamesTheEntitlement() throws {
let granted = developmentGrants.filter { $0.key != "aps-environment" }
let error = try #require(throws: EntitlementsReconciler.Error.self) {
try reconcile(["aps-environment": "development"], granted: granted)
}
#expect(error == .notGranted(key: "aps-environment", profile: "dev profile"))
}

@Test func onlyTheMissingGroupsAreReported() throws {
var granted = developmentGrants
granted["com.apple.security.application-groups"] = ["group.a"]
#expect(throws: EntitlementsReconciler.Error.appGroupsNotGranted(groups: ["group.b"], profile: "dev profile")) {
try reconcile(["com.apple.security.application-groups": ["group.a", "group.b"]], granted: granted)
}
}

@Test func valueOutsideTheGrantIsRejected() throws {
var granted = developmentGrants
granted["com.apple.developer.networking.multipath"] = false
#expect(throws: EntitlementsReconciler.Error.valueNotGranted(
key: "com.apple.developer.networking.multipath", profile: "dev profile"
)) {
try reconcile(["com.apple.developer.networking.multipath": true], granted: granted)
}
}

@Test func derivedAndUnknownEntitlementsAreLeftAlone() throws {
let requested: [String: Any] = [
"application-identifier": "TEAM.XTL-1.app",
"keychain-access-groups": ["TEAM.XTL-1.app"],
"com.example.not-a-capability-we-manage": true,
]
let result = try reconcile(requested, granted: developmentGrants)
#expect(result["application-identifier"] as? String == "TEAM.XTL-1.app")
#expect(result["keychain-access-groups"] as? [String] == ["TEAM.XTL-1.app"])
#expect(result["com.example.not-a-capability-we-manage"] as? Bool == true)
}

@Test func entitlementsWithoutIssuesAreUnchanged() throws {
let requested: [String: Any] = ["get-task-allow": true, "aps-environment": "development"]
let result = try reconcile(requested, granted: developmentGrants)
#expect(result["aps-environment"] as? String == "development")
#expect(Set(result.keys) == Set(requested.keys))
}

@Test func distributionOnlyEntitlementsAreDroppedWhenNotGranted() throws {
let outcome = try EntitlementsReconciler.reconcile(
entitlements([
"beta-reports-active": true,
"aps-environment": "production",
"get-task-allow": false,
]),
with: entitlements(developmentGrants),
profileName: "dev profile"
)
let result = try outcome.entitlements.plistDictionary()
#expect(outcome.removed == ["beta-reports-active"])
#expect(result["beta-reports-active"] == nil)
#expect(result["aps-environment"] as? String == "development")
}

@Test func distributionOnlyEntitlementsAreKeptWhenGranted() throws {
var granted = developmentGrants
granted["beta-reports-active"] = true
let outcome = try EntitlementsReconciler.reconcile(
entitlements(["beta-reports-active": true]), with: entitlements(granted), profileName: "p"
)
#expect(outcome.removed.isEmpty)
#expect(try outcome.entitlements.plistDictionary()["beta-reports-active"] as? Bool == true)
}
}