Files
xtool/Sources/XKit/DeveloperServices/Profiles/DeveloperServicesFetchProfileOperation.swift
Chintan Patel 316a11e523 certs: match certificates whose serial starts with 0 (#306)
## 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>
2026-10-15 16:44:32 -04:00

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
}
}
}