diff --git a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift index d726e91e..0848673f 100644 --- a/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift +++ b/Sources/XKit/DeveloperServices/App IDs/DeveloperServicesAddAppOperation.swift @@ -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, diff --git a/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift new file mode 100644 index 00000000..c674283c --- /dev/null +++ b/Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift @@ -0,0 +1,159 @@ +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 = [ + "beta-reports-active", + ] + + /// Entitlements that are checked against the profile although xtool has no type for them. Dropping + /// these silently would change behavior without telling (the iCloud container environment decides + /// which CloudKit database the app talks to), so a profile that doesn't grant one is an error. + private static let checkedKeys: Set = [ + "com.apple.developer.icloud-container-environment", + ] + + 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 = [ + ApplicationIdentifierEntitlement.identifier, + TeamIdentifierEntitlement.identifier, + GetTaskAllowEntitlement.identifier, + KeychainAccessGroupsEntitlement.identifier, + ] + + private static let knownKeys: Set = Set( + EntitlementContainer.supportedTypes.compactMap { ($0 as? any Entitlement.Type)?.identifier } + ).subtracting(derivedKeys).union(checkedKeys) + + /// - 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, plus the ones in `checkedKeys`, are checked + /// against the profile. 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): + // a profile lists every value it allows, e.g. [Development, Production] + return permits(grant: grant, request: [request]) + case let (grant as String, request as [String]): + return permits(grant: [grant], request: 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] ?? [:] + } + +} diff --git a/Tests/XToolTests/EntitlementsReconcilerTests.swift b/Tests/XToolTests/EntitlementsReconcilerTests.swift new file mode 100644 index 00000000..ccaf751f --- /dev/null +++ b/Tests/XToolTests/EntitlementsReconcilerTests.swift @@ -0,0 +1,153 @@ +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) + } + + private let environmentKey = "com.apple.developer.icloud-container-environment" + + @Test func iCloudContainerEnvironmentTheProfileLacksIsAnError() throws { + let error = try #require(throws: EntitlementsReconciler.Error.self) { + try reconcile([environmentKey: "Production"], granted: developmentGrants) + } + #expect(error == .notGranted(key: environmentKey, profile: "dev profile")) + } + + @Test func iCloudContainerEnvironmentMustBeOneTheProfileAllows() throws { + var granted = developmentGrants + granted[environmentKey] = ["Development", "Production"] + let result = try reconcile([environmentKey: "Development"], granted: granted) + #expect(result[environmentKey] as? String == "Development") + + granted[environmentKey] = ["Development"] + #expect(throws: EntitlementsReconciler.Error.valueNotGranted(key: environmentKey, profile: "dev profile")) { + try reconcile([environmentKey: "Production"], granted: granted) + } + } +}