Repository navigation
signer: reconcile entitlements with the provisioning profile #324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
TartanLeGrand
wants to merge
5
commits into
xtool-org:main
Choose a base branch
from
TartanLeGrand:fix/autosigner-reconcile-entitlements
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+325
−0
Open
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b86a8df
signer: reconcile entitlements with the provisioning profile
TartanLeGrand ac68928
signer: print reconcile errors with their message
TartanLeGrand d68f506
signer: drop distribution-only entitlements in development signing
TartanLeGrand ccab333
signer: error when the profile lacks the iCloud environment
TartanLeGrand 74f69f6
signer: document the checked entitlement keys
TartanLeGrand File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
147 changes: 147 additions & 0 deletions
147
Sources/XKit/DeveloperServices/App IDs/Entitlements/EntitlementsReconciler.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] | ||
|
|
||
| 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] ?? [:] | ||
| } | ||
|
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.