From f931313e02a8ede25ad312a13f4853e2264e29df Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:09:13 +0200 Subject: [PATCH 1/3] Add saved p12 certificate reuse for provisioning --- Documentation/xtool.docc/First-app.tutorial | 4 +- .../xtool.docc/Installation-Linux.md | 17 ++ .../xtool.docc/Installation-macOS.md | 17 ++ ...perServicesFetchCertificateOperation.swift | 184 ++++++++++++++++++ Sources/XToolSupport/AuthCommand.swift | 118 ++++++++++- Sources/XToolSupport/AuthToken.swift | 24 +++ 6 files changed, 361 insertions(+), 3 deletions(-) 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/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift index 980cb9c9..d1585a5b 100644 --- a/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift +++ b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift @@ -9,6 +9,9 @@ import Foundation import DeveloperAPI import Dependencies +#if canImport(Security) +import Security +#endif public typealias DeveloperServicesCertificate = Components.Schemas.Certificate @@ -33,6 +36,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 +97,191 @@ 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.extractPrivateKeyPEMWithOpenSSL(p12URL: p12URL, 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 fileManager = FileManager.default + let executableCandidates = [ + "/opt/homebrew/bin/openssl", + "/usr/local/bin/openssl", + "/usr/bin/openssl", + ] + guard let opensslPath = executableCandidates.first(where: { fileManager.fileExists(atPath: $0) }) else { + return nil + } + let base = [ + "pkcs12", + "-in", p12URL.path, + "-nocerts", + "-nodes", + "-passin", "env:XTOOL_P12_PASS", + ] + let attempts = [ + ["pkcs12", "-legacy"] + base.dropFirst(), + base, + ] + + for args in attempts { + let process = Process() + process.executableURL = URL(fileURLWithPath: opensslPath) + process.arguments = args + + var env = ProcessInfo.processInfo.environment + env["XTOOL_P12_PASS"] = password + process.environment = env + + let output = Pipe() + process.standardOutput = output + process.standardError = output + + do { + try process.run() + } catch { + continue + } + process.waitUntilExit() + let data = output.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8) ?? "" + + guard process.terminationStatus == 0 else { + continue + } + + if text.isEmpty { + continue + } + + if let pem = Self.extractPEMBlock(from: text, begin: "-----BEGIN PRIVATE KEY-----", end: "-----END PRIVATE KEY-----") { + return pem + } + if let pem = Self.extractPEMBlock(from: text, begin: "-----BEGIN RSA PRIVATE KEY-----", end: "-----END RSA PRIVATE KEY-----") { + return pem + } + } + return nil + } + + private static func extractPEMBlock(from text: String, begin: String, end: String) -> String? { + guard let startRange = text.range(of: begin), + let endRange = text.range(of: end, range: startRange.lowerBound.. 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 +289,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..a48fbc9a 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,52 @@ struct AuthOperation { try await logInWithKey() } try token.save() + try await saveSigningCertificateIfProvided() print("Logged in.\n\(token)") } + private func saveSigningCertificateIfProvided() async throws { + let env = ProcessInfo.processInfo.environment + let rawPath = signingP12 + ?? env["XTOOL_SIGNING_P12"] + ?? env["XTOOL_CERT_P12"] + 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 + ?? env["XTOOL_SIGNING_P12_PASSWORD"] + ?? env["XTOOL_CERT_P12_PASSWORD"] + { + 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 +177,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 +237,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) + } + } From 9b4cdd366754ad4205d12ab515ed643211b86712 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:27:23 +0200 Subject: [PATCH 2/3] Remove env-based p12 auth fallbacks --- .../DeveloperServicesFetchCertificateOperation.swift | 6 +----- Sources/XToolSupport/AuthCommand.swift | 8 +------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift index d1585a5b..f5de1ba2 100644 --- a/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift +++ b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift @@ -203,7 +203,7 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera "-in", p12URL.path, "-nocerts", "-nodes", - "-passin", "env:XTOOL_P12_PASS", + "-passin", "pass:\(password)", ] let attempts = [ ["pkcs12", "-legacy"] + base.dropFirst(), @@ -215,10 +215,6 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera process.executableURL = URL(fileURLWithPath: opensslPath) process.arguments = args - var env = ProcessInfo.processInfo.environment - env["XTOOL_P12_PASS"] = password - process.environment = env - let output = Pipe() process.standardOutput = output process.standardError = output diff --git a/Sources/XToolSupport/AuthCommand.swift b/Sources/XToolSupport/AuthCommand.swift index a48fbc9a..ba2a9580 100644 --- a/Sources/XToolSupport/AuthCommand.swift +++ b/Sources/XToolSupport/AuthCommand.swift @@ -62,10 +62,7 @@ struct AuthOperation { } private func saveSigningCertificateIfProvided() async throws { - let env = ProcessInfo.processInfo.environment let rawPath = signingP12 - ?? env["XTOOL_SIGNING_P12"] - ?? env["XTOOL_CERT_P12"] guard let rawPath else { return } @@ -77,10 +74,7 @@ struct AuthOperation { } let password: String - if let explicit = signingP12Password - ?? env["XTOOL_SIGNING_P12_PASSWORD"] - ?? env["XTOOL_CERT_P12_PASSWORD"] - { + if let explicit = signingP12Password { password = explicit } else { password = try await Console.getPassword("Signing certificate password: ") From 7d49961e9e62022c7e33260ddf0207835acc1ddc Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:40:05 +0200 Subject: [PATCH 3/3] Use native PKCS12 key extraction for saved certs --- Sources/CXKit/include/pkcs12.h | 17 ++ Sources/CXKit/pkcs12.c | 148 ++++++++++++++++++ ...perServicesFetchCertificateOperation.swift | 88 ++++------- 3 files changed, 192 insertions(+), 61 deletions(-) create mode 100644 Sources/CXKit/include/pkcs12.h create mode 100644 Sources/CXKit/pkcs12.c 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 f5de1ba2..31b421fd 100644 --- a/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift +++ b/Sources/XKit/DeveloperServices/Certificates/DeveloperServicesFetchCertificateOperation.swift @@ -9,10 +9,19 @@ 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 public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOperation { @@ -142,7 +151,7 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera let privateKeyBytes = SecKeyCopyExternalRepresentation(privateKeyRef, nil) as Data? { privateKeyPEM = Self.pem(body: privateKeyBytes, header: "RSA PRIVATE KEY") - } else if let pem = Self.extractPrivateKeyPEMWithOpenSSL(p12URL: p12URL, password: password) { + } else if let pem = Self.extractPrivateKeyPEMWithNativePKCS12(p12Data: p12Data, password: password) { privateKeyPEM = pem } else { return nil @@ -188,71 +197,28 @@ public struct DeveloperServicesFetchCertificateOperation: DeveloperServicesOpera return "-----BEGIN \(header)-----\n\(lines.joined(separator: "\\n"))\n-----END \(header)-----\n" } - private static func extractPrivateKeyPEMWithOpenSSL(p12URL: URL, password: String) -> String? { - let fileManager = FileManager.default - let executableCandidates = [ - "/opt/homebrew/bin/openssl", - "/usr/local/bin/openssl", - "/usr/bin/openssl", - ] - guard let opensslPath = executableCandidates.first(where: { fileManager.fileExists(atPath: $0) }) else { - return nil - } - let base = [ - "pkcs12", - "-in", p12URL.path, - "-nocerts", - "-nodes", - "-passin", "pass:\(password)", - ] - let attempts = [ - ["pkcs12", "-legacy"] + base.dropFirst(), - base, - ] - - for args in attempts { - let process = Process() - process.executableURL = URL(fileURLWithPath: opensslPath) - process.arguments = args - - let output = Pipe() - process.standardOutput = output - process.standardError = output - - do { - try process.run() - } catch { - continue + private static func extractPrivateKeyPEMWithNativePKCS12(p12Data: Data, password: String) -> String? { + let passwordCString = password.cString(using: .utf8) ?? [0] + return p12Data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> String? in + guard let base = bytes.baseAddress else { + return nil } - process.waitUntilExit() - let data = output.fileHandleForReading.readDataToEndOfFile() - let text = String(data: data, encoding: .utf8) ?? "" - guard process.terminationStatus == 0 else { - continue - } - - if text.isEmpty { - continue - } + 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 + } - if let pem = Self.extractPEMBlock(from: text, begin: "-----BEGIN PRIVATE KEY-----", end: "-----END PRIVATE KEY-----") { - return pem + let pemData = Data(bytesNoCopy: pemPointer, count: pemLength, deallocator: .free) + return String(data: pemData, encoding: .utf8) } - if let pem = Self.extractPEMBlock(from: text, begin: "-----BEGIN RSA PRIVATE KEY-----", end: "-----END RSA PRIVATE KEY-----") { - return pem - } - } - return nil - } - - private static func extractPEMBlock(from text: String, begin: String, end: String) -> String? { - guard let startRange = text.range(of: begin), - let endRange = text.range(of: end, range: startRange.lowerBound.. String {