mirror of
https://github.com/xtool-org/xtool.git
synced 2026-11-14 11:35:03 +03:00
316a11e523
## What does this PR do?
Fixes #305 (thanks @0x53 for the detailed diagnosis).
`Certificate.serialNumber()` stripped leading `0` *characters* from the
hex string, so a serial like `0337…` became `337…` and never matched the
`serialNumber` returned by the API. xtool then treated its stored
certificate as missing: free teams are prompted to revoke a valid
certificate on every run, and paid teams take the same path and try to
create another certificate, which @McoreD confirmed fails with `409 …
You already have a current Development certificate or a pending
certificate request.`
The character-level `drop` predates b6a40ca, which switched to
fixed-width `%02hhX` formatting; since then it removes the significant
zero of any first byte below `0x10`. swift-certificates already strips
leading zero bytes (including DER sign padding) when parsing, so the
`drop` isn't needed for padding.
- `serialNumber()` now drops only leading zero *bytes*, matching the API
and `openssl x509 -serial`.
- The stored-certificate lookup uses a new
`Certificate.hasSerialNumber(_:)`, which compares serials numerically
(ignoring case and leading zeros), so it doesn't depend on Apple's exact
formatting.
- Profile creation no longer finds the signing certificate with
`filter[serialNumber]`. The API lists serials with the leading zero
(`0E41…`), but the filter only matches them without it (`E41…`), so once
`serialNumber()` kept the zero the filter matched nothing and profile
creation failed with a 409
([details](https://github.com/xtool-org/xtool/pull/306#issuecomment-6181157860)).
It now fetches all pages of certificates and matches them with
`hasSerialNumber(_:)`. Fix by @McoreD, with pagination added so a
certificate on a later page isn't missed.
## How was it tested?
- Added `Tests/XToolTests/CertificateTests.swift`: builds a cert with
the serial from #305, round-trips it through DER, and checks
`serialNumber()` and `hasSerialNumber(_:)`, plus a high-bit serial so
DER sign padding doesn't leak into the string. The leading-zero test
fails on `main` (returns `33742570D6F9684A23311711956A084`) and passes
with this change.
- `swift test` (17 tests) and `make lint` pass on macOS (Xcode 27, Swift
6.4, `XTL_CI=1`).
- @McoreD tested on Linux (Arch, Swift 6.4.1, Xcode 27 SDK, on top of
1.22.2) with a paid team whose certificate serial starts with `0`: the
stored certificate is reused, and provisioning, signing and install to
an iPhone (iOS 27) succeed end to end (tested before pagination was
added). `CertificateTests` also pass on Linux.
- Not tested with a free team.
To generate test certificates, the test target now depends on `Crypto`
and `X509`, which XKit already uses.
## AI tool usage
How much of this PR was AI-assisted? (check one)
- [ ] **0** - No AI was used to write code
- [x] **1** - I was assisted by AI. I reviewed the finished result.
- [ ] **2** - I set the AI going and left it to it; nobody has read the
result - no review, or AI review only
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Certificate serial numbers now preserve leading zero nibbles while
omitting unnecessary padding.
* Certificate matching now ignores letter case and leading zeros, and
profile lookups find matching certificates even when the API’s
serial-number filter treats leading-zero values differently.
* Certificate lookups continue to use the existing fallback when no
matching certificate is found.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: McoreD <McoreD@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
4.2 KiB
Swift
122 lines
4.2 KiB
Swift
//
|
|
// DeveloperServicesFetchProfileOperation.swift
|
|
// XKit
|
|
//
|
|
// Created by Kabir Oberai on 13/10/19.
|
|
// Copyright © 2019 Kabir Oberai. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import Superutils
|
|
import DeveloperAPI
|
|
|
|
public struct DeveloperServicesFetchProfileOperation: DeveloperServicesOperation {
|
|
|
|
public enum Errors: Error {
|
|
case bundleIDNotFound
|
|
case tooManyMatchingBundleIDs
|
|
case invalidProfileData
|
|
}
|
|
|
|
public let context: SigningContext
|
|
public let bundleID: String
|
|
public let signingInfo: SigningInfo
|
|
public init(context: SigningContext, bundleID: String, signingInfo: SigningInfo) {
|
|
self.context = context
|
|
self.bundleID = bundleID
|
|
self.signingInfo = signingInfo
|
|
}
|
|
|
|
public func perform() async throws -> Mobileprovision {
|
|
let bundleIDs = try await context.developerAPIClient
|
|
.bundleIdsGetCollection(query: .init(
|
|
filter_lbrack_identifier_rbrack_: [bundleID],
|
|
fields_lbrack_profiles_rbrack_: [.bundleId],
|
|
include: [.profiles]
|
|
))
|
|
.ok.body.json
|
|
|
|
// filter[identifier] is a prefix filter so we need to manually upgrade to equality
|
|
let filtered = bundleIDs.data.filter { $0.attributes?.identifier == self.bundleID }
|
|
|
|
let bundleID: Components.Schemas.BundleId
|
|
switch filtered.count {
|
|
case 0:
|
|
throw Errors.bundleIDNotFound
|
|
case 1:
|
|
bundleID = filtered[0]
|
|
default:
|
|
throw Errors.tooManyMatchingBundleIDs
|
|
}
|
|
|
|
let profiles = bundleID.relationships?.profiles?.data ?? []
|
|
switch profiles.count {
|
|
case 0:
|
|
// we're good
|
|
break
|
|
case 1:
|
|
_ = try await context.developerAPIClient.profilesDeleteInstance(path: .init(id: profiles[0].id)).noContent
|
|
default:
|
|
// if the user has >1 profile, it's probably okay to add another one (acceptable for non-free accounts?)
|
|
break
|
|
}
|
|
|
|
// Match locally rather than with filter[serialNumber]: the API lists serials with a
|
|
// leading zero nibble ("0E41…") but its filter only matches them without it ("E41…").
|
|
let certs = try await DeveloperAPIPages {
|
|
try await context.developerAPIClient.certificatesGetCollection().ok.body.json
|
|
} next: {
|
|
$0.links.next
|
|
}
|
|
.map(\.data)
|
|
.reduce(into: [], +=)
|
|
.filter { $0.attributes?.serialNumber.map(signingInfo.certificate.hasSerialNumber) == true }
|
|
|
|
let allDevices = try await context.developerAPIClient.devicesGetCollection()
|
|
.ok.body.json.data
|
|
.filter { $0.attributes?.deviceClass?.value1?.supportsIOSApps == true }
|
|
|
|
let response = try await context.developerAPIClient.profilesCreateInstance(
|
|
body: .json(
|
|
.init(
|
|
data: .init(
|
|
_type: .profiles,
|
|
attributes: .init(
|
|
name: "XTL profile \(bundleID.id)",
|
|
profileType: .init(.iosAppDevelopment)
|
|
),
|
|
relationships: .init(
|
|
bundleId: .init(
|
|
data: .init(_type: .bundleIds, id: bundleID.id)
|
|
),
|
|
devices: .init(data: allDevices.map {
|
|
.init(_type: .devices, id: $0.id)
|
|
}),
|
|
certificates: .init(data: certs.map {
|
|
.init(_type: .certificates, id: $0.id)
|
|
})
|
|
)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
.created.body.json.data
|
|
|
|
guard let contentString = response.attributes?.profileContent,
|
|
let contentData = Data(base64Encoded: contentString)
|
|
else { throw Errors.invalidProfileData }
|
|
|
|
return try Mobileprovision(data: contentData)
|
|
}
|
|
|
|
}
|
|
|
|
extension Components.Schemas.Device.AttributesPayload.DeviceClassPayload.Value1Payload {
|
|
fileprivate var supportsIOSApps: Bool {
|
|
switch self {
|
|
case .ipad, .iphone, .ipod: true
|
|
default: false
|
|
}
|
|
}
|
|
}
|