diff --git a/Documentation/xtool.docc/First-app.tutorial b/Documentation/xtool.docc/First-app.tutorial index 396c221b..7cab744d 100644 --- a/Documentation/xtool.docc/First-app.tutorial +++ b/Documentation/xtool.docc/First-app.tutorial @@ -70,8 +70,8 @@ } @Step { - xtool will now connect to Apple Developer Services, register your device with your Apple ID, generate a Certificate + App ID + Provisioning Profile, sign the app, and then install it. - + xtool will now connect to Apple Developer Services, register your device with your Apple ID, create an App ID + Provisioning Profile, sign the app, and then install it. If you configured `xtool auth login --signing-p12`, xtool reuses that saved certificate; otherwise it may create a new development certificate. + @Code(name: "Terminal", file: "build-3.sh", reset: true) {} } diff --git a/Documentation/xtool.docc/Installation-Linux.md b/Documentation/xtool.docc/Installation-Linux.md index 88cc8baa..a5e23d0a 100644 --- a/Documentation/xtool.docc/Installation-Linux.md +++ b/Documentation/xtool.docc/Installation-Linux.md @@ -115,6 +115,23 @@ Choice (0-1): Once you select a login mode, you'll be asked to provide the corresponding credentials (API key or email+password+2FA). Needless to say, *your credentials are only sent to Apple* and nobody else (feel free to build xtool from source and check!) +If you already have an Apple Development certificate and want xtool to reuse it for provisioning, log in with a `.p12` export: + +```bash +xtool auth login \ + --mode password \ + --signing-p12 /path/to/cert.p12 \ + --signing-p12-password '' +``` + +xtool copies the certificate into its own config directory and stores the password for later use during `xtool dev` and `xtool install`. + +You can verify this state with: + +```bash +xtool auth status +``` + ### 3. Configure xtool: SDK After you're logged in, you'll be asked to provide the path to the `Xcode.xip` file you downloaded earlier. diff --git a/Documentation/xtool.docc/Installation-macOS.md b/Documentation/xtool.docc/Installation-macOS.md index 98f3fa67..b36e806d 100644 --- a/Documentation/xtool.docc/Installation-macOS.md +++ b/Documentation/xtool.docc/Installation-macOS.md @@ -76,6 +76,23 @@ Choice (0-1): Once you select a login mode, you'll be asked to provide the corresponding credentials (API key or email+password+2FA). Needless to say, *your credentials are only sent to Apple* and nobody else (feel free to build xtool from source and check!) +If you already have an Apple Development certificate and want xtool to reuse it for provisioning, log in with a `.p12` export: + +```bash +xtool auth login \ + --mode password \ + --signing-p12 /path/to/cert.p12 \ + --signing-p12-password '' +``` + +xtool copies the certificate into its own config directory and stores the password for later use during `xtool dev` and `xtool install`. + +You can verify this state with: + +```bash +xtool auth status +``` + ## Next steps You're now ready to use xtool! See . (The tutorial is tailored to Linux, but it works the same on macOS.) diff --git a/Sources/CXKit/include/pkcs12.h b/Sources/CXKit/include/pkcs12.h new file mode 100644 index 00000000..11862ff4 --- /dev/null +++ b/Sources/CXKit/include/pkcs12.h @@ -0,0 +1,17 @@ +#ifndef PKCS12_HELPERS_H +#define PKCS12_HELPERS_H + +#include + +#pragma clang assume_nonnull begin + +void * _Nullable xtl_pkcs12_copy_private_key_pem( + const void *p12_data, + size_t p12_len, + const char *password, + size_t *pem_len +); + +#pragma clang assume_nonnull end + +#endif diff --git a/Sources/CXKit/pkcs12.c b/Sources/CXKit/pkcs12.c new file mode 100644 index 00000000..2ccc3467 --- /dev/null +++ b/Sources/CXKit/pkcs12.c @@ -0,0 +1,148 @@ +#include +#include +#include + +#include +#include +#if OPENSSL_VERSION_MAJOR >= 3 +#include +#endif + +#include "pkcs12.h" + +#if OPENSSL_VERSION_MAJOR >= 3 +static void configure_provider_search_path(void) { + static const char *candidate_paths[] = { + "/opt/homebrew/lib/ossl-modules", + "/usr/local/lib/ossl-modules", + }; + + for (size_t i = 0; i < (sizeof(candidate_paths) / sizeof(candidate_paths[0])); i++) { + const char *path = candidate_paths[i]; + if (access(path, R_OK) == 0) { + OSSL_PROVIDER_set_default_search_path(NULL, path); + return; + } + } +} +#endif + +void *xtl_pkcs12_copy_private_key_pem( + const void *p12_data, + size_t p12_len, + const char *password, + size_t *pem_len +) { + if (!p12_data || !pem_len) { + return NULL; + } + + *pem_len = 0; + + BIO *input = BIO_new_mem_buf(p12_data, (int)p12_len); + if (!input) { + return NULL; + } + + PKCS12 *p12 = d2i_PKCS12_bio(input, NULL); + BIO_free(input); + if (!p12) { + return NULL; + } + + EVP_PKEY *private_key = NULL; + X509 *certificate = NULL; + STACK_OF(X509) *ca = NULL; + + int parsed = PKCS12_parse(p12, password, &private_key, &certificate, &ca); + +#if OPENSSL_VERSION_MAJOR >= 3 + OSSL_PROVIDER *default_provider = NULL; + OSSL_PROVIDER *legacy_provider = NULL; + if (!parsed) { + configure_provider_search_path(); + default_provider = OSSL_PROVIDER_load(NULL, "default"); + legacy_provider = OSSL_PROVIDER_load(NULL, "legacy"); + parsed = PKCS12_parse(p12, password, &private_key, &certificate, &ca); + } +#endif + + PKCS12_free(p12); + + if (!parsed || !private_key) { + if (certificate) { + X509_free(certificate); + } + if (private_key) { + EVP_PKEY_free(private_key); + } + if (ca) { + sk_X509_pop_free(ca, X509_free); + } +#if OPENSSL_VERSION_MAJOR >= 3 + if (legacy_provider) { + OSSL_PROVIDER_unload(legacy_provider); + } + if (default_provider) { + OSSL_PROVIDER_unload(default_provider); + } +#endif + return NULL; + } + + BIO *output = BIO_new(BIO_s_mem()); + if (!output) { + X509_free(certificate); + EVP_PKEY_free(private_key); + if (ca) { + sk_X509_pop_free(ca, X509_free); + } +#if OPENSSL_VERSION_MAJOR >= 3 + if (legacy_provider) { + OSSL_PROVIDER_unload(legacy_provider); + } + if (default_provider) { + OSSL_PROVIDER_unload(default_provider); + } +#endif + return NULL; + } + + int wrote = PEM_write_bio_PrivateKey(output, private_key, NULL, NULL, 0, NULL, NULL); + EVP_PKEY_free(private_key); + X509_free(certificate); + if (ca) { + sk_X509_pop_free(ca, X509_free); + } +#if OPENSSL_VERSION_MAJOR >= 3 + if (legacy_provider) { + OSSL_PROVIDER_unload(legacy_provider); + } + if (default_provider) { + OSSL_PROVIDER_unload(default_provider); + } +#endif + + if (!wrote) { + BIO_free(output); + return NULL; + } + + char *pem_data = NULL; + long bio_len = BIO_get_mem_data(output, &pem_data); + if (!pem_data || bio_len <= 0) { + BIO_free(output); + return NULL; + } + + void *copied = malloc((size_t)bio_len); + if (!copied) { + BIO_free(output); + return NULL; + } + memcpy(copied, pem_data, (size_t)bio_len); + BIO_free(output); + + *pem_len = (size_t)bio_len; + return copied; +} diff --git a/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift index 980cb9c9..31b421fd 100644 --- a/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift +++ b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift @@ -9,6 +9,18 @@ import Foundation import DeveloperAPI import Dependencies +import CXKit +#if canImport(Security) +import Security +#endif + +@_silgen_name("xtl_pkcs12_copy_private_key_pem") +private func xtl_pkcs12_copy_private_key_pem_native( + _ p12Data: UnsafeRawPointer, + _ p12Length: Int, + _ password: UnsafePointer?, + _ pemLength: UnsafeMutablePointer +) -> UnsafeMutableRawPointer? public typealias DeveloperServicesCertificate = Components.Schemas.Certificate @@ -33,6 +45,7 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera } @Dependency(\.signingInfoManager) var signingInfoManager + @Dependency(\.keyValueStorage) var keyValueStorage public let context: SigningContext public let confirmRevocation: @Sendable ([DeveloperServicesCertificate]) async -> Bool @@ -93,15 +106,144 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera return signingInfo } + private func loadLocalSigningInfo( + matching certificates: [DeveloperServicesCertificate] + ) -> SigningInfo? { +#if canImport(Security) + let storedPath = try? keyValueStorage.string(forKey: "XTLSavedSigningP12Path") + let candidates = [storedPath] + .compactMap { $0 } + .map { URL(fileURLWithPath: $0) } + .filter { FileManager.default.fileExists(atPath: $0.path) } + + guard let p12URL = candidates.first, + let p12Data = try? Data(contentsOf: p12URL) + else { + return nil + } + + let password = (try? keyValueStorage.string(forKey: "XTLSavedSigningP12Password")) + ?? "" + + let options: [String: Any] = [kSecImportExportPassphrase as String: password] + var importedItems: CFArray? + let importStatus = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &importedItems) + guard importStatus == errSecSuccess, + let importedItems, + let firstItem = (importedItems as NSArray).firstObject as? NSDictionary, + let identityAny = firstItem[kSecImportItemIdentity as String] + else { + return nil + } + let identity = identityAny as! SecIdentity + + var certificateRef: SecCertificate? + guard SecIdentityCopyCertificate(identity, &certificateRef) == errSecSuccess, + let certificateRef + else { + return nil + } + + var privateKeyRef: SecKey? + let privateKeyPEM: String + if SecIdentityCopyPrivateKey(identity, &privateKeyRef) == errSecSuccess, + let privateKeyRef, + let privateKeyBytes = SecKeyCopyExternalRepresentation(privateKeyRef, nil) as Data? + { + privateKeyPEM = Self.pem(body: privateKeyBytes, header: "RSA PRIVATE KEY") + } else if let pem = Self.extractPrivateKeyPEMWithNativePKCS12(p12Data: p12Data, password: password) { + privateKeyPEM = pem + } else { + return nil + } + + let certData = SecCertificateCopyData(certificateRef) as Data + guard let certificate = try? Certificate(data: certData) else { + return nil + } + + let serial = certificate.serialNumber() + let normalizedSerial = Self.normalizeSerialNumber(serial) + guard let matchingCertificate = certificates.first(where: { + Self.normalizeSerialNumber($0.attributes?.serialNumber) == normalizedSerial + }), + let expirationDate = matchingCertificate.attributes?.expirationDate, + expirationDate > Date() + else { + return nil + } + + let signingInfo = SigningInfo( + privateKey: .init(data: Data(privateKeyPEM.utf8)), + certificate: certificate + ) + return signingInfo +#else + _ = certificates + return nil +#endif + } + + private static func pem(body: Data, header: String) -> String { + let base64 = body.base64EncodedString() + var lines: [String] = [] + lines.reserveCapacity((base64.count / 64) + 2) + var index = base64.startIndex + while index < base64.endIndex { + let nextIndex = base64.index(index, offsetBy: 64, limitedBy: base64.endIndex) ?? base64.endIndex + lines.append(String(base64[index.. String? { + let passwordCString = password.cString(using: .utf8) ?? [0] + return p12Data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> String? in + guard let base = bytes.baseAddress else { + return nil + } + + return passwordCString.withUnsafeBufferPointer { passwordBuffer in + var pemLength = 0 + guard let pemPointer = xtl_pkcs12_copy_private_key_pem_native( + base, + bytes.count, + passwordBuffer.baseAddress, + &pemLength + ), pemLength > 0 else { + return nil + } + + let pemData = Data(bytesNoCopy: pemPointer, count: pemLength, deallocator: .free) + return String(data: pemData, encoding: .utf8) + } + } + } + + private static func normalizeSerialNumber(_ serial: String?) -> String { + let upper = (serial ?? "").uppercased() + let trimmed = upper.drop { $0 == "0" } + return String(trimmed) + } + public func perform() async throws -> SigningInfo { let certificates = try await context.developerAPIClient.certificatesGetCollection().ok.body.json.data guard let signingInfo = signingInfoManager[self.context.auth.identityID] else { + if let signingInfo = loadLocalSigningInfo(matching: certificates) { + signingInfoManager[self.context.auth.identityID] = signingInfo + return signingInfo + } return try await self.replaceCertificates(certificates, requireConfirmation: true) } let knownSerialNumber = signingInfo.certificate.serialNumber() guard let certificate = certificates.first(where: { $0.attributes?.serialNumber == knownSerialNumber }) else { + if let signingInfo = loadLocalSigningInfo(matching: certificates) { + signingInfoManager[self.context.auth.identityID] = signingInfo + return signingInfo + } // we need to revoke existing certs, otherwise it doesn't always let us make a new one return try await self.replaceCertificates(certificates, requireConfirmation: true) } @@ -109,6 +251,10 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera if let date = certificate.attributes?.expirationDate, date > Date() { return signingInfo } else { + if let signingInfo = loadLocalSigningInfo(matching: certificates) { + signingInfoManager[self.context.auth.identityID] = signingInfo + return signingInfo + } // we have a certificate for this machine but it's not usable return try await self.replaceCertificates( [certificate], diff --git a/Sources/XToolSupport/AuthCommand.swift b/Sources/XToolSupport/AuthCommand.swift index 40f86576..ba2a9580 100644 --- a/Sources/XToolSupport/AuthCommand.swift +++ b/Sources/XToolSupport/AuthCommand.swift @@ -3,6 +3,9 @@ import XKit import ArgumentParser import Crypto import Dependencies +#if canImport(Security) +import Security +#endif enum AuthMode: String, CaseIterable, CustomStringConvertible, ExpressibleByArgument { case key @@ -22,6 +25,8 @@ struct AuthOperation { var logoutFromExisting: Bool var mode: AuthMode? + var signingP12: String? = nil + var signingP12Password: String? = nil var quiet = false func run() async throws { @@ -51,10 +56,46 @@ struct AuthOperation { try await logInWithKey() } try token.save() + try await saveSigningCertificateIfProvided() print("Logged in.\n\(token)") } + private func saveSigningCertificateIfProvided() async throws { + let rawPath = signingP12 + guard let rawPath else { + return + } + + let expandedPath = (rawPath as NSString).expandingTildeInPath + let sourceURL = URL(fileURLWithPath: expandedPath) + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + throw Console.Error("Signing p12 not found at path: \(sourceURL.path)") + } + + let password: String + if let explicit = signingP12Password { + password = explicit + } else { + password = try await Console.getPassword("Signing certificate password: ") + } + + @Dependency(\.persistentDirectory) var persistentDirectory + let destinationDirectory = persistentDirectory.appendingPathComponent("signing", isDirectory: true) + let destinationURL = destinationDirectory.appendingPathComponent("cert.p12") + + if !FileManager.default.fileExists(atPath: destinationDirectory.path) { + try FileManager.default.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + } + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + + try AuthToken.saveSigningCertificate(path: destinationURL.path, password: password) + print("Saved signing certificate for device provisioning.") + } + private func logInWithKey() async throws -> AuthToken { let id = try await Console.promptRequired("Key ID: ", existing: nil) .trimmingCharacters(in: .whitespacesAndNewlines) @@ -130,13 +171,17 @@ struct AuthLoginCommand: AsyncParsableCommand { @Option(name: [.short, .long], help: "Apple ID") var username: String? @Option(name: [.short, .long]) var password: String? @Option(name: [.short, .long]) var mode: AuthMode? + @Option(help: "Path to signing certificate (.p12) to copy and save") var signingP12: String? + @Option(help: "Password for signing certificate (.p12)") var signingP12Password: String? func run() async throws { try await AuthOperation( username: username, password: password, logoutFromExisting: true, - mode: mode + mode: mode, + signingP12: signingP12, + signingP12Password: signingP12Password ).run() } } @@ -186,10 +231,75 @@ struct AuthStatusCommand: AsyncParsableCommand { func run() async throws { if let token = try? AuthToken.saved() { print("Logged in.\n\(token)") + print(try signingCertificateStatus()) } else { print("Logged out") } } + + private func signingCertificateStatus() throws -> String { + guard let path = try AuthToken.savedSigningCertificatePath() else { + return "- Signing certificate: not configured" + } + + let fileExists = FileManager.default.fileExists(atPath: path) + let hasPassword = !(try AuthToken.savedSigningCertificatePassword() ?? "").isEmpty + + var lines = [ + "- Signing certificate: configured", + "- Signing certificate path: \(path)", + "- Signing certificate file: \(fileExists ? "present" : "missing")", + "- Signing certificate password: \(hasPassword ? "saved" : "missing")", + ] + + #if canImport(Security) + if fileExists, + hasPassword, + let certSummary = try loadSavedCertificateSummary(path: path) { + lines.append("- Signing certificate subject: \(certSummary.subject)") + lines.append("- Signing certificate serial: \(certSummary.serial)") + } + #endif + + return lines.joined(separator: "\n") + } + + #if canImport(Security) + private func loadSavedCertificateSummary(path: String) throws -> (subject: String, serial: String)? { + guard let password = try AuthToken.savedSigningCertificatePassword(), + let p12Data = try? Data(contentsOf: URL(fileURLWithPath: path)) + else { + return nil + } + + let options: [String: Any] = [kSecImportExportPassphrase as String: password] + var importedItems: CFArray? + guard SecPKCS12Import(p12Data as CFData, options as CFDictionary, &importedItems) == errSecSuccess, + let importedItems, + let firstItem = (importedItems as NSArray).firstObject as? NSDictionary, + let identityAny = firstItem[kSecImportItemIdentity as String] + else { + return nil + } + + let identity = identityAny as! SecIdentity + var certificateRef: SecCertificate? + guard SecIdentityCopyCertificate(identity, &certificateRef) == errSecSuccess, + let certificateRef + else { + return nil + } + + let certData = SecCertificateCopyData(certificateRef) as Data + guard let certificate = try? Certificate(data: certData) else { + return nil + } + + let subject = (try? certificate.developerIdentity()) ?? "unknown" + let serial = certificate.serialNumber() + return (subject, serial) + } + #endif } struct AuthCommand: AsyncParsableCommand { diff --git a/Sources/XToolSupport/AuthToken.swift b/Sources/XToolSupport/AuthToken.swift index 82e7b0ff..2d92a4ac 100644 --- a/Sources/XToolSupport/AuthToken.swift +++ b/Sources/XToolSupport/AuthToken.swift @@ -46,6 +46,8 @@ extension AuthToken { private static let encoder = JSONEncoder() private static let decoder = JSONDecoder() + static let signingP12PathKey = "XTLSavedSigningP12Path" + static let signingP12PasswordKey = "XTLSavedSigningP12Password" static func saved() throws -> Self { guard let data = try storage.data(forKey: "XTLAuthToken") else { @@ -56,6 +58,7 @@ extension AuthToken { static func clear() throws { try Self.storage.setData(nil, forKey: "XTLAuthToken") + try clearSigningCertificate() } func save() throws { @@ -79,4 +82,25 @@ extension AuthToken { } } + static func saveSigningCertificate(path: String, password: String) throws { + try storage.setString(path, forKey: Self.signingP12PathKey) + try storage.setString(password, forKey: Self.signingP12PasswordKey) + } + + static func savedSigningCertificatePath() throws -> String? { + try storage.string(forKey: Self.signingP12PathKey) + } + + static func savedSigningCertificatePassword() throws -> String? { + try storage.string(forKey: Self.signingP12PasswordKey) + } + + static func clearSigningCertificate() throws { + if let path = try savedSigningCertificatePath() { + try? FileManager.default.removeItem(atPath: path) + } + try storage.setData(nil, forKey: Self.signingP12PathKey) + try storage.setData(nil, forKey: Self.signingP12PasswordKey) + } + }