Allow use of a single cert for multiple provisioning profiles (#2193)
* Use correct security call Was using wrong syntax and therefore failed * Do not check for duplicate import of keys as KeyChain takes care of that itself * Adjust tests * Adjust test * Formatting * Parse certificate fingerprint from certs and provisioning profiles * Address issues from PR review * Reformat * Adapt fixture to new requirments * Remove targetName and configurationName from cert and match using fingerprint * Update changelog * Update documentation. * Improve certificate lookup (faster and deterministic) * Revert Gemfile * Remove duplicate declaration * Catch openssl parsing errors * Remove unused error * Apply suggestions from code review Co-authored-by: Marek Fořt <marek.fort@ackee.cz> * Update documentation * Apply PR review comments * Log detailed openssl error when parsing fails * Update changelog * Remove duplicate switch case Co-authored-by: Marek Fořt <marek.fort@ackee.cz>
This commit is contained in:
parent
61a3bc54b0
commit
4d7b9f2c17
|
@ -9,6 +9,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/
|
|||
### Added
|
||||
|
||||
- Add linting for paths of local packages and for URL validity of remote packages [#2255](https://github.com/tuist/tuist/pull/2255) by [@adellibovi](https://github.com/adellibovi).
|
||||
- Allow use of a single cert for multiple provisioning profiles [#2193](https://github.com/tuist/tuist/pull/2193) by [@rist](https://github.com/rist).
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@ import TSCBasic
|
|||
struct Certificate: Equatable {
|
||||
let publicKey: AbsolutePath
|
||||
let privateKey: AbsolutePath
|
||||
/// Content of the fingerprint property of the public key
|
||||
let fingerprint: String
|
||||
let developmentTeam: String
|
||||
let name: String
|
||||
let targetName: String
|
||||
let configurationName: String
|
||||
let isRevoked: Bool
|
||||
}
|
||||
|
|
|
@ -5,23 +5,23 @@ import TuistSupport
|
|||
enum CertificateParserError: FatalError, Equatable {
|
||||
case nameParsingFailed(AbsolutePath, String)
|
||||
case developmentTeamParsingFailed(AbsolutePath, String)
|
||||
case invalidFormat(String)
|
||||
case fileParsingFailed(AbsolutePath)
|
||||
|
||||
var type: ErrorType {
|
||||
switch self {
|
||||
case .nameParsingFailed, .developmentTeamParsingFailed, .invalidFormat:
|
||||
case .nameParsingFailed, .developmentTeamParsingFailed, .fileParsingFailed:
|
||||
return .abort
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case let .invalidFormat(certificate):
|
||||
return "Certificate \(certificate) is in invalid format. Please name your certificates in the following way: Target.Configuration.p12"
|
||||
case let .nameParsingFailed(path, input):
|
||||
return "We couldn't parse the name while parsing the following output from the file \(path.pathString): \(input)"
|
||||
case let .developmentTeamParsingFailed(path, input):
|
||||
return "We couldn't parse the development team while parsing the following output from the file \(path.pathString): \(input)"
|
||||
case let .fileParsingFailed(path):
|
||||
return "We couldn't parse the file \(path.pathString)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -31,6 +31,9 @@ protocol CertificateParsing {
|
|||
/// Parse public-private key pair
|
||||
/// - Returns: Parse `Certificate`
|
||||
func parse(publicKey: AbsolutePath, privateKey: AbsolutePath) throws -> Certificate
|
||||
|
||||
/// Retrieve fingerprint of a public key
|
||||
func parseFingerPrint(developerCertificate: Data) throws -> String
|
||||
}
|
||||
|
||||
/// Subject attributes that are returnen with `openssl x509 -subject`
|
||||
|
@ -48,12 +51,8 @@ private enum SubjectAttribute: String {
|
|||
|
||||
final class CertificateParser: CertificateParsing {
|
||||
func parse(publicKey: AbsolutePath, privateKey: AbsolutePath) throws -> Certificate {
|
||||
let publicKeyComponents = publicKey.basenameWithoutExt.components(separatedBy: ".")
|
||||
guard publicKeyComponents.count == 2 else { throw CertificateParserError.invalidFormat(publicKey.pathString) }
|
||||
let targetName = publicKeyComponents[0]
|
||||
let configurationName = publicKeyComponents[1]
|
||||
|
||||
let subject = try self.subject(at: publicKey)
|
||||
let fingerprint = try self.fingerprint(at: publicKey)
|
||||
let isRevoked = subject.contains("REVOKED")
|
||||
|
||||
let nameRegex = try NSRegularExpression(
|
||||
|
@ -61,9 +60,9 @@ final class CertificateParser: CertificateParsing {
|
|||
options: []
|
||||
)
|
||||
guard
|
||||
let result = nameRegex.firstMatch(in: subject, options: [], range: NSRange(location: 0, length: subject.count))
|
||||
let nameResult = nameRegex.firstMatch(in: subject, options: [], range: NSRange(location: 0, length: subject.count))
|
||||
else { throw CertificateParserError.nameParsingFailed(publicKey, subject) }
|
||||
let name = NSString(string: subject).substring(with: result.range(at: 1)).spm_chomp()
|
||||
let name = NSString(string: subject).substring(with: nameResult.range(at: 1)).spm_chomp()
|
||||
|
||||
let developmentTeamRegex = try NSRegularExpression(
|
||||
pattern: SubjectAttribute.organizationalUnit.rawValue + " *= *([^/,]+)",
|
||||
|
@ -77,18 +76,46 @@ final class CertificateParser: CertificateParsing {
|
|||
return Certificate(
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
fingerprint: fingerprint,
|
||||
developmentTeam: developmentTeam,
|
||||
name: name.sanitizeEncoding(),
|
||||
targetName: targetName,
|
||||
configurationName: configurationName,
|
||||
isRevoked: isRevoked
|
||||
)
|
||||
}
|
||||
|
||||
func parseFingerPrint(developerCertificate: Data) throws -> String {
|
||||
let temporaryFile = try FileHandler.shared.temporaryDirectory().appending(component: "developerCertificate.cer")
|
||||
try developerCertificate.write(to: temporaryFile.asURL)
|
||||
|
||||
return try fingerprint(at: temporaryFile)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func subject(at path: AbsolutePath) throws -> String {
|
||||
try System.shared.capture("openssl", "x509", "-inform", "der", "-in", path.pathString, "-noout", "-subject")
|
||||
do {
|
||||
return try System.shared.capture("openssl", "x509", "-inform", "der", "-in", path.pathString, "-noout", "-subject")
|
||||
} catch let TuistSupport.SystemError.terminated(_, _, standardError) {
|
||||
if let string = String(data: standardError, encoding: .utf8) {
|
||||
logger.warning("Parsing subject of \(path) failed with: \(string)")
|
||||
}
|
||||
throw CertificateParserError.fileParsingFailed(path)
|
||||
} catch {
|
||||
throw CertificateParserError.fileParsingFailed(path)
|
||||
}
|
||||
}
|
||||
|
||||
private func fingerprint(at path: AbsolutePath) throws -> String {
|
||||
do {
|
||||
return try System.shared.capture("openssl", "x509", "-inform", "der", "-in", path.pathString, "-noout", "-fingerprint").spm_chomp()
|
||||
} catch let TuistSupport.SystemError.terminated(_, _, standardError) {
|
||||
if let string = String(data: standardError, encoding: .utf8) {
|
||||
logger.warning("Parsing fingerprint of \(path) failed with: \(string)")
|
||||
}
|
||||
throw CertificateParserError.fileParsingFailed(path)
|
||||
} catch {
|
||||
throw CertificateParserError.fileParsingFailed(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
extension Dictionary where Key == Fingerprint, Value == Certificate {
|
||||
func first(for provisioningProfile: ProvisioningProfile) -> Certificate? {
|
||||
provisioningProfile.developerCertificateFingerprints.compactMap { self[$0] }.first
|
||||
}
|
||||
}
|
|
@ -59,7 +59,7 @@ public class SigningMapper: ProjectMapping {
|
|||
private func map(target: Target,
|
||||
project: Project,
|
||||
keychainPath: AbsolutePath,
|
||||
certificates: [TargetName: [ConfigurationName: Certificate]],
|
||||
certificates: [Fingerprint: Certificate],
|
||||
provisioningProfiles: [TargetName: [ConfigurationName: ProvisioningProfile]]) throws -> Target
|
||||
{
|
||||
var target = target
|
||||
|
@ -70,7 +70,7 @@ public class SigningMapper: ProjectMapping {
|
|||
.reduce(into: [:]) { dict, configurationPair in
|
||||
guard
|
||||
let provisioningProfile = provisioningProfiles[target.name]?[configurationPair.key.name],
|
||||
let certificate = certificates[target.name]?[configurationPair.key.name]
|
||||
let certificate = certificates.first(for: provisioningProfile)
|
||||
else {
|
||||
dict[configurationPair.key] = configurationPair.value
|
||||
return
|
||||
|
|
|
@ -16,6 +16,7 @@ struct ProvisioningProfile: Equatable {
|
|||
let applicationIdPrefix: [String]
|
||||
let platforms: [String]
|
||||
let expirationDate: Date
|
||||
let developerCertificateFingerprints: [String]
|
||||
|
||||
struct Content {
|
||||
let name: String
|
||||
|
@ -26,6 +27,7 @@ struct ProvisioningProfile: Equatable {
|
|||
let applicationIdPrefix: [String]
|
||||
let platforms: [String]
|
||||
let expirationDate: Date
|
||||
let developerCertificates: [Data]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,6 +54,7 @@ extension ProvisioningProfile.Content: Decodable {
|
|||
case platforms = "Platform"
|
||||
case entitlements = "Entitlements"
|
||||
case expirationDate = "ExpirationDate"
|
||||
case developerCertificates = "DeveloperCertificates"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
|
@ -65,5 +68,6 @@ extension ProvisioningProfile.Content: Decodable {
|
|||
let entitlements = try container.decode(Entitlements.self, forKey: .entitlements)
|
||||
appId = entitlements.appId
|
||||
expirationDate = try container.decode(Date.self, forKey: .expirationDate)
|
||||
developerCertificates = try container.decode([Data].self, forKey: .developerCertificates)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,9 +29,11 @@ protocol ProvisioningProfileParsing {
|
|||
|
||||
final class ProvisioningProfileParser: ProvisioningProfileParsing {
|
||||
private let securityController: SecurityControlling
|
||||
private let certificateParser: CertificateParsing
|
||||
|
||||
init(securityController: SecurityControlling = SecurityController()) {
|
||||
init(securityController: SecurityControlling = SecurityController(), certificateParser: CertificateParsing = CertificateParser()) {
|
||||
self.securityController = securityController
|
||||
self.certificateParser = certificateParser
|
||||
}
|
||||
|
||||
func parse(at path: AbsolutePath) throws -> ProvisioningProfile {
|
||||
|
@ -44,6 +46,10 @@ final class ProvisioningProfileParser: ProvisioningProfileParsing {
|
|||
let plistData = Data(unencryptedProvisioningProfile.utf8)
|
||||
let provisioningProfileContent = try PropertyListDecoder().decode(ProvisioningProfile.Content.self, from: plistData)
|
||||
|
||||
let developerCertificateFingerprints = try provisioningProfileContent.developerCertificates.map {
|
||||
try certificateParser.parseFingerPrint(developerCertificate: $0)
|
||||
}
|
||||
|
||||
return ProvisioningProfile(path: path,
|
||||
name: provisioningProfileContent.name,
|
||||
targetName: targetName,
|
||||
|
@ -54,6 +60,7 @@ final class ProvisioningProfileParser: ProvisioningProfileParsing {
|
|||
appIdName: provisioningProfileContent.appIdName,
|
||||
applicationIdPrefix: provisioningProfileContent.applicationIdPrefix,
|
||||
platforms: provisioningProfileContent.platforms,
|
||||
expirationDate: provisioningProfileContent.expirationDate)
|
||||
expirationDate: provisioningProfileContent.expirationDate,
|
||||
developerCertificateFingerprints: developerCertificateFingerprints)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ public final class SigningInteractor: SigningInteracting {
|
|||
private func install(target: Target,
|
||||
project: Project,
|
||||
keychainPath: AbsolutePath,
|
||||
certificates: [TargetName: [ConfigurationName: Certificate]],
|
||||
certificates: [Fingerprint: Certificate],
|
||||
provisioningProfiles: [TargetName: [ConfigurationName: ProvisioningProfile]]) throws
|
||||
{
|
||||
let targetConfigurations = target.settings?.configurations ?? [:]
|
||||
|
@ -97,7 +97,7 @@ public final class SigningInteractor: SigningInteracting {
|
|||
.compactMap { configuration -> (certificate: Certificate, provisioningProfile: ProvisioningProfile)? in
|
||||
guard
|
||||
let provisioningProfile = provisioningProfiles[target.name]?[configuration.name],
|
||||
let certificate = certificates[target.name]?[configuration.name]
|
||||
let certificate = certificates.first(for: provisioningProfile)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import Foundation
|
|||
import TSCBasic
|
||||
import TuistCore
|
||||
|
||||
typealias Fingerprint = String
|
||||
typealias TargetName = String
|
||||
typealias ConfigurationName = String
|
||||
|
||||
|
@ -10,7 +11,7 @@ protocol SigningMatching {
|
|||
/// - Returns: Certificates and provisioning profiles matched with their configuration and target
|
||||
/// - Warning: Expects certificates and provisioning profiles already decrypted
|
||||
func match(from path: AbsolutePath) throws -> (
|
||||
certificates: [TargetName: [ConfigurationName: Certificate]],
|
||||
certificates: [Fingerprint: Certificate],
|
||||
provisioningProfiles: [TargetName: [ConfigurationName: ProvisioningProfile]]
|
||||
)
|
||||
}
|
||||
|
@ -30,19 +31,17 @@ final class SigningMatcher: SigningMatching {
|
|||
}
|
||||
|
||||
func match(from path: AbsolutePath) throws -> (
|
||||
certificates: [TargetName: [ConfigurationName: Certificate]],
|
||||
certificates: [Fingerprint: Certificate],
|
||||
provisioningProfiles: [TargetName: [ConfigurationName: ProvisioningProfile]]
|
||||
) {
|
||||
let certificateFiles = try signingFilesLocator.locateUnencryptedCertificates(from: path)
|
||||
.sorted()
|
||||
let privateKeyFiles = try signingFilesLocator.locateUnencryptedPrivateKeys(from: path)
|
||||
.sorted()
|
||||
let certificates: [TargetName: [ConfigurationName: Certificate]] = try zip(certificateFiles, privateKeyFiles)
|
||||
let certificates: [Fingerprint: Certificate] = try zip(certificateFiles, privateKeyFiles)
|
||||
.map(certificateParser.parse)
|
||||
.reduce(into: [:]) { dict, certificate in
|
||||
var currentTargetDict = dict[certificate.targetName] ?? [:]
|
||||
currentTargetDict[certificate.configurationName] = certificate
|
||||
dict[certificate.targetName] = currentTargetDict
|
||||
dict[certificate.fingerprint] = certificate
|
||||
}
|
||||
|
||||
// swiftlint:disable:next line_length
|
||||
|
|
|
@ -5,18 +5,16 @@ import TSCBasic
|
|||
extension Certificate {
|
||||
static func test(publicKey: AbsolutePath = AbsolutePath("/"),
|
||||
privateKey: AbsolutePath = AbsolutePath("/"),
|
||||
fingerprint: String = "",
|
||||
developmentTeam: String = "",
|
||||
name: String = "",
|
||||
targetName: String = "",
|
||||
configurationName: String = "",
|
||||
isRevoked: Bool = false) -> Certificate
|
||||
{
|
||||
Certificate(publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
fingerprint: fingerprint,
|
||||
developmentTeam: developmentTeam,
|
||||
name: name,
|
||||
targetName: targetName,
|
||||
configurationName: configurationName,
|
||||
isRevoked: isRevoked)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ extension ProvisioningProfile {
|
|||
appIdName: String = "appIdName",
|
||||
applicationIdPrefix: [String] = [],
|
||||
platforms: [String] = ["iOS"],
|
||||
expirationDate: Date = Date().addingTimeInterval(100)
|
||||
expirationDate: Date = Date().addingTimeInterval(100),
|
||||
developerCertificateFingerprints: [String] = ["developerCertificateFingerprint"]
|
||||
) -> ProvisioningProfile {
|
||||
ProvisioningProfile(
|
||||
path: path,
|
||||
|
@ -27,7 +28,8 @@ extension ProvisioningProfile {
|
|||
appIdName: appIdName,
|
||||
applicationIdPrefix: applicationIdPrefix,
|
||||
platforms: platforms,
|
||||
expirationDate: expirationDate
|
||||
expirationDate: expirationDate,
|
||||
developerCertificateFingerprints: developerCertificateFingerprints
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,18 +5,18 @@ import XCTest
|
|||
@testable import TuistSupportTesting
|
||||
|
||||
final class CertificateParserIntegrationTests: TuistTestCase {
|
||||
var subject: CertificateParser!
|
||||
var certificateParser: CertificateParser!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
subject = CertificateParser()
|
||||
certificateParser = CertificateParser()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
subject = nil
|
||||
certificateParser = nil
|
||||
}
|
||||
|
||||
func test_parse_certificate() throws {
|
||||
|
@ -27,15 +27,14 @@ final class CertificateParserIntegrationTests: TuistTestCase {
|
|||
let expectedCertificate = Certificate(
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
fingerprint: "SHA1 Fingerprint=80:B8:6E:55:1D:8E:F2:38:CD:95:3C:7E:72:3B:DB:B1:A5:B0:5D:60",
|
||||
developmentTeam: "QH95ER52SG",
|
||||
name: "Apple Development: Marek Fort (54GSF6G47V)",
|
||||
targetName: "Target",
|
||||
configurationName: "Debug",
|
||||
isRevoked: false
|
||||
)
|
||||
|
||||
// When
|
||||
let certificate = try subject.parse(publicKey: publicKey, privateKey: privateKey)
|
||||
let certificate = try certificateParser.parse(publicKey: publicKey, privateKey: privateKey)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(certificate, expectedCertificate)
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
import TSCBasic
|
||||
import XCTest
|
||||
@testable import TuistSigning
|
||||
@testable import TuistSigningTesting
|
||||
@testable import TuistSupportTesting
|
||||
|
||||
final class ProvisioningProfileParserTests: TuistTestCase {
|
||||
var provisioningProfileParser: ProvisioningProfileParser!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
provisioningProfileParser = ProvisioningProfileParser()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
provisioningProfileParser = nil
|
||||
}
|
||||
|
||||
func test_parse_provisioningProfile() throws {
|
||||
// Given
|
||||
let currentDirectory = AbsolutePath(#file.replacingOccurrences(of: "file://", with: "")).removingLastComponent()
|
||||
let provisioningProfileFile = currentDirectory.appending(component: "SignApp.debug.mobileprovision")
|
||||
let expectedProvisioningProfile = ProvisioningProfile(
|
||||
path: provisioningProfileFile,
|
||||
name: "SignApp.Debug",
|
||||
targetName: "SignApp",
|
||||
configurationName: "debug",
|
||||
uuid: "d34fb066-f494-4d85-a556-d469c2196f46",
|
||||
teamId: "QH95ER52SG",
|
||||
appId: "QH95ER52SG.io.tuist.SignApp",
|
||||
appIdName: "SignApp",
|
||||
applicationIdPrefix: ["QH95ER52SG"],
|
||||
platforms: ["iOS"],
|
||||
expirationDate: Date(timeIntervalSince1970: 1_619_208_757.0),
|
||||
developerCertificateFingerprints: ["SHA1 Fingerprint=80:B8:6E:55:1D:8E:F2:38:CD:95:3C:7E:72:3B:DB:B1:A5:B0:5D:60"]
|
||||
)
|
||||
|
||||
// When
|
||||
let provisioningProfile = try provisioningProfileParser.parse(at: provisioningProfileFile)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(provisioningProfile, expectedProvisioningProfile)
|
||||
}
|
||||
}
|
|
@ -27,6 +27,11 @@ final class CertificateParserTests: TuistUnitTestCase {
|
|||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-subject",
|
||||
output: subjectOutput
|
||||
)
|
||||
let fingerprintOutput = "subject= /UID=VD55TKL3V6/CN=Apple Development: Name (54GSF6G47V)/OU=QH95ER52SG/O=Name/C=US\n"
|
||||
system.succeedCommand(
|
||||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-fingerprint",
|
||||
output: fingerprintOutput
|
||||
)
|
||||
|
||||
// When
|
||||
XCTAssertThrowsSpecific(
|
||||
|
@ -44,6 +49,11 @@ final class CertificateParserTests: TuistUnitTestCase {
|
|||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-subject",
|
||||
output: subjectOutput
|
||||
)
|
||||
let fingerprintOutput = "subject= /UID=VD55TKL3V6/CN=Apple Development: Name (54GSF6G47V)/OU=QH95ER52SG/O=Name/C=US\n"
|
||||
system.succeedCommand(
|
||||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-fingerprint",
|
||||
output: fingerprintOutput
|
||||
)
|
||||
|
||||
// When
|
||||
XCTAssertThrowsSpecific(
|
||||
|
@ -52,18 +62,6 @@ final class CertificateParserTests: TuistUnitTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func test_throws_invalid_name_when_wrong_format() throws {
|
||||
// Given
|
||||
let publicKey = try temporaryPath()
|
||||
let privateKey = try temporaryPath()
|
||||
|
||||
// When
|
||||
XCTAssertThrowsSpecific(
|
||||
try subject.parse(publicKey: publicKey, privateKey: privateKey),
|
||||
CertificateParserError.invalidFormat(publicKey.pathString)
|
||||
)
|
||||
}
|
||||
|
||||
func test_parsing_succeeds() throws {
|
||||
// Given
|
||||
let publicKey = try temporaryPath().appending(component: "Target.Debug.p12")
|
||||
|
@ -73,13 +71,17 @@ final class CertificateParserTests: TuistUnitTestCase {
|
|||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-subject",
|
||||
output: subjectOutput
|
||||
)
|
||||
let fingerprintOutput = "subject= /UID=VD55TKL3V6/CN=Apple Development: Name (54GSF6G47V)/OU=QH95ER52SG/O=Name/C=US\n"
|
||||
system.succeedCommand(
|
||||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-fingerprint",
|
||||
output: fingerprintOutput
|
||||
)
|
||||
let expectedCertificate = Certificate(
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
fingerprint: "subject= /UID=VD55TKL3V6/CN=Apple Development: Name (54GSF6G47V)/OU=QH95ER52SG/O=Name/C=US",
|
||||
developmentTeam: "QH95ER52SG",
|
||||
name: "Apple Development: Name (54GSF6G47V)",
|
||||
targetName: "Target",
|
||||
configurationName: "Debug",
|
||||
isRevoked: false
|
||||
)
|
||||
|
||||
|
@ -99,13 +101,17 @@ final class CertificateParserTests: TuistUnitTestCase {
|
|||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-subject",
|
||||
output: subjectOutput
|
||||
)
|
||||
let fingerprintOutput = "subject= /UID=VD55TKL3V6/CN=Apple Development: Name (54GSF6G47V)/OU=QH95ER52SG/O=Name/C=US\n"
|
||||
system.succeedCommand(
|
||||
"openssl", "x509", "-inform", "der", "-in", publicKey.pathString, "-noout", "-fingerprint",
|
||||
output: fingerprintOutput
|
||||
)
|
||||
let expectedCertificate = Certificate(
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
fingerprint: "subject= /UID=VD55TKL3V6/CN=Apple Development: Name (54GSF6G47V)/OU=QH95ER52SG/O=Name/C=US",
|
||||
developmentTeam: "QH95ER52SG",
|
||||
name: "Apple Development: Name (54GSF6G47V)",
|
||||
targetName: "Target",
|
||||
configurationName: "Debug",
|
||||
isRevoked: false
|
||||
)
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Foundation
|
||||
import TSCBasic
|
||||
@testable import TuistSigning
|
||||
@testable import TuistSigningTesting
|
||||
|
@ -7,4 +8,9 @@ final class MockCertificateParser: CertificateParsing {
|
|||
func parse(publicKey: AbsolutePath, privateKey: AbsolutePath) throws -> Certificate {
|
||||
try parseStub?(publicKey, privateKey) ?? Certificate.test()
|
||||
}
|
||||
|
||||
var parseFingerPrintStub: ((Data) throws -> String)?
|
||||
func parseFingerPrint(developerCertificate: Data) throws -> String {
|
||||
try parseFingerPrintStub?(developerCertificate) ?? ""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ import TuistCore
|
|||
@testable import TuistSigning
|
||||
|
||||
final class MockSigningMatcher: SigningMatching {
|
||||
var matchStub: ((AbsolutePath) throws -> (certificates: [String: [String: Certificate]], provisioningProfiles: [String: [String: ProvisioningProfile]]))?
|
||||
func match(from path: AbsolutePath) throws -> (certificates: [String: [String: Certificate]], provisioningProfiles: [String: [String: ProvisioningProfile]]) {
|
||||
var matchStub: ((AbsolutePath) throws -> (certificates: [String: Certificate], provisioningProfiles: [String: [String: ProvisioningProfile]]))?
|
||||
func match(from path: AbsolutePath) throws -> (certificates: [String: Certificate], provisioningProfiles: [String: [String: ProvisioningProfile]]) {
|
||||
try matchStub?(path) ?? (certificates: [:], provisioningProfiles: [:])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,7 +37,8 @@ final class ProvisioningProfileParserTests: TuistUnitTestCase {
|
|||
appIdName: "AppIDName",
|
||||
applicationIdPrefix: ["Prefix"],
|
||||
platforms: ["iOS"],
|
||||
expirationDate: Date(timeIntervalSinceReferenceDate: 640_729_461)
|
||||
expirationDate: Date(timeIntervalSinceReferenceDate: 640_729_461),
|
||||
developerCertificateFingerprints: []
|
||||
)
|
||||
securityController.decodeFileStub = { _ in
|
||||
.testProvisioningProfile(
|
||||
|
@ -110,6 +111,9 @@ private extension String {
|
|||
<date>\(expirationDate)</date>
|
||||
<key>Name</key>
|
||||
<string>\(name)</string>
|
||||
<key>DeveloperCertificates</key>
|
||||
<array>
|
||||
</array>
|
||||
<key>ProvisionedDevices</key>
|
||||
<array>
|
||||
<string>2b41533fd2df499800f493b261d060fe6d60838b</string>
|
||||
|
|
|
@ -207,14 +207,11 @@ final class SigningInteractorTests: TuistUnitTestCase {
|
|||
let targetName = "target"
|
||||
let configuration = "configuration"
|
||||
let expectedCertificate = Certificate.test(name: "certA")
|
||||
let expectedProvisioningProfile = ProvisioningProfile.test(name: "profileA")
|
||||
let expectedProvisioningProfile = ProvisioningProfile.test(name: "profileA", developerCertificateFingerprints: ["fingerprint"])
|
||||
signingMatcher.matchStub = { _ in
|
||||
(certificates: [
|
||||
targetName: [
|
||||
configuration: expectedCertificate,
|
||||
// Used to ensure only certificates that have configuration are installed
|
||||
"other-config": Certificate.test(name: "certB"),
|
||||
],
|
||||
"fingerprint": expectedCertificate,
|
||||
"otherFingerprint": Certificate.test(name: "certB"),
|
||||
],
|
||||
provisioningProfiles: [
|
||||
targetName: [
|
||||
|
|
|
@ -44,16 +44,16 @@ final class SigningMapperTests: TuistUnitTestCase {
|
|||
let targetName = "target"
|
||||
let configuration = "configuration"
|
||||
let certificate = Certificate.test(name: "certA")
|
||||
let fingerprint = "fingerprint"
|
||||
let provisioningProfile = ProvisioningProfile.test(
|
||||
name: "profileA",
|
||||
teamId: "TeamID",
|
||||
appId: "TeamID.BundleID"
|
||||
appId: "TeamID.BundleID",
|
||||
developerCertificateFingerprints: ["otherFingerPrint", fingerprint]
|
||||
)
|
||||
signingMatcher.matchStub = { _ in
|
||||
(certificates: [
|
||||
targetName: [
|
||||
configuration: certificate,
|
||||
],
|
||||
fingerprint: certificate,
|
||||
],
|
||||
provisioningProfiles: [
|
||||
targetName: [
|
||||
|
|
|
@ -73,35 +73,30 @@ final class SigningMatcherTests: TuistUnitTestCase {
|
|||
]
|
||||
}
|
||||
certificateParser.parseStub = { publicKey, privateKey in
|
||||
let configurationName: String
|
||||
let fingerprint: String
|
||||
if publicKey == publicKeyPath {
|
||||
configurationName = debugConfiguration
|
||||
fingerprint = "fingerprint"
|
||||
} else {
|
||||
configurationName = releaseConfiguration
|
||||
fingerprint = "otherFingerprint"
|
||||
}
|
||||
return Certificate.test(
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
targetName: targetName,
|
||||
configurationName: configurationName
|
||||
fingerprint: fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
let expectedCertificates: [String: [String: Certificate]] = [
|
||||
targetName: [
|
||||
debugConfiguration: Certificate.test(
|
||||
publicKey: publicKeyPath,
|
||||
privateKey: privateKeyPath,
|
||||
targetName: targetName,
|
||||
configurationName: debugConfiguration
|
||||
),
|
||||
releaseConfiguration: Certificate.test(
|
||||
publicKey: releasePublicKeyPath,
|
||||
privateKey: releasePrivateKeyPath,
|
||||
targetName: targetName,
|
||||
configurationName: releaseConfiguration
|
||||
),
|
||||
],
|
||||
let expectedCertificates: [String: Certificate] = [
|
||||
"fingerprint": Certificate.test(
|
||||
publicKey: publicKeyPath,
|
||||
privateKey: privateKeyPath,
|
||||
fingerprint: "fingerprint"
|
||||
),
|
||||
"otherFingerprint": Certificate.test(
|
||||
publicKey: releasePublicKeyPath,
|
||||
privateKey: releasePrivateKeyPath,
|
||||
fingerprint: "otherFingerprint"
|
||||
),
|
||||
]
|
||||
|
||||
let debugProvisioningProfilePath = AbsolutePath("/\(targetName).\(debugConfiguration).mobileprovision")
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
5AHwOTcfzNr1TYeAnL/pAg==-we3696+agUK2mv4hFbJqvLgBADWtUknOKJLZ+UhMFaNfOx/hBSbLtTwE56+3K1vDrY1RBiSg8zkWjm/aXpLtgCwGekvNWwLGBwOvPL5r+fxp96QvYUbvWMTZBvA1yVGFwptgGSYZ4xCqqOZdLNzB8UtuB6c+WlSKSlUdcx6wbjNoEMPVYxP7APkJnlZTZqqgXn27IqWSrD5zCahshxep8F4xefOboHQL4aUe/KdxX0dZTbnE2OrrAFbNmOcc7kwTL0pSdEhBZDjCSYB9gK+90P+t9HZWcYzDnYQbR73wq7QEDONoF0VgwUGYtsTd3c4wPddezEo0Gw07o/CEnccho+RD9t1gwKuWAXKZxnM3jI46wGVJ4v4qHZuZy+t/Q/GxhsaFDhrIrPTsthw5fzZbOm20BDL2BCH4GiWSW2QCHBvctsTXQ9JKXHiQsW72UlVMKz1oj5Xh3+QGyiDZZxrfAQmN0SwI4WGsOateLEOV5lfQ57+9oS3VewC60lXL8iYSzq0/w2ecOZxCu3NLECQLN83xFJYKeloZT8g0eZXgNeLPs0eaOkRZN8NBl/ZvOBZKitk/L/G1OSFHTL4J0zv3ykDxV4kfHxMIgxE9BCYEvIOCSOrhDB5KjYQc9Phv6bL9jXjlyScPnKsl6t7+7r58R9P9fY5FNoMs1YZEmBbny1UjZ/Ny6KL2ADYTlJs0MKikJvFi3SLgzm3tT+l9G1yximpwwETMAT473oAspIKJcaNdxFtv0ctEonyDTy8svvQ+IvZjkdWMtv91Ze5Ht3YG8iR2CAOhlr1JcPz9B/+UuxO8hWMNYI19Ff4cqYy8dZSI+DJ3IgoDzEUnUUDTagUTWVG3pEJG0uoKYKedNCR8quLf+XzABSe8FP0/u1xMCKWVxgVbFdUfgEvxPQKFmkyKhF0PtFHQnQRtHo66AdfqdjPqDb/WULTQeXBnv2vIXt4DtIj7w7Z0xNuMCjxEDYREOwglMMGHqfAkbXC2XL4bVKmVRleC3LdjpISh2308YqBTJwlEInIBNAOS02+0BJdp3/vP4I83FXJWHhQdT9wrzaXsysn1jVlD+GPh3pxjUfOWj45nuPO+Mt/KhO0HM/o4/1qC75vsvsvOjnEU6EWucBUy5GCcV+TYIzKJvyJ+k81R90JXILSkZPkFpNMXjCCD22r7fbyWfZXn9hgUHjZ02DhBGpfcHszIMVP8hxv5Ptt9kFJekceMBPLiRLZh/C2skLDE7rkzYPEnVzGIZ0oUT8rYZJ7kjjc56eXvPQ7oCwMPQQeayzwJVobREQnn6ypxvaWpRscq39uWl8kC7OiS3T1mt+75e+kQ5jqY7nf8fsQlPKn8w/hUL32f80FOR11iZ3/aid1/gt5FpT8QoLpZ6R3CSM+spedi3XWUvPMdhUOeEvzg8qoBVuzq/aDTo1qIQ6hNcEmlT+TA9bK4LraTmkHguZ8IW+Vej+yFucfEh+zqqwte+FTwjCwrHlRvcx+Ks5nykVz6ubZAXZ3szat7y6ltjEMFpRvTjZ3hPcLwsBFv88h/1pnNS2oRDU/RikgQJ+b0nyRQF7m170MCzmmMGnIV+ZFvEaCfCTleay4V5IsC1uHA04UtPANsUig7Pm71o5NpwHckWykvcf6LdtCpInJmVlWDoTcFmykxWuQGogz6t3/xkVNULkIQtmQilI+usPBoNm3YM62orkks4hVFqgHqDvN5VC7Bt/35yzumIAOCedqmDRltrzbHTjt66Gfuh9FQIqFEE1muSuuCXsW9erD1Oi7VKuYfg9/i3yyCQyAT05wM5anRGjVbLlg9r4O1m30WfVB7A6/Je47gKbYzVq40PR37YyGrs/2hHLqiUv6Kan1fGS2DjwUzH8xpGWFy6jNzVzMUYH7Y0V15n+rticuki6L5AHn8OgoNEwivLmRxG7/mKrRV
|
|
@ -1 +0,0 @@
|
|||
dThPH8pFcEHgwpG1mqFDPg==-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
|
|
@ -18,12 +18,13 @@ Tuist aims to solve all of the above pain points in a simple and _deterministic_
|
|||
|
||||
## How to Get Started
|
||||
|
||||
As the first step, download all your provisioning profiles and certficates that you'll need.
|
||||
Once you download them, make sure to rename the files, so they abide by the following convention:
|
||||
`Target.Configuration.extension`
|
||||
Where `Target` should be a name of the target, `Configuration` a name of the configuration and leave the default extension
|
||||
that you downloaded the file with (eg. `p12` for certificate, etc.).
|
||||
Now you can put all those files in `Tuist/Signing` directory.
|
||||
As the first step, download all your provisioning profiles that you'll need.
|
||||
Once you downloaded them, make sure to rename the provisioning profiles, so they abide by the following convention:
|
||||
`Target.Configuration.mobileprovision` for iOS apps or `Target.Configuration.provisionprofile` for macOS apps.
|
||||
Where `Target` should be a name of the target, `Configuration` a name of the configuration.
|
||||
Export the public (as .cer file) and private key (as .p12 file with an empty string as a password) from your keychain. Use the same basename for these two files: `SomeName.cer` and `SomeName.p12` - this name doesn't need to match any target or configuration.
|
||||
If multiple provisioning profiles use the same certificate, it's fine to have `.p12` and `.cer` files just once in the folder - Tuist will find the matching one based on information embedded into the provisioning profile.
|
||||
Now you can put all those files in `Tuist/Signing` directory within your project.
|
||||
To make it all work, create a secure password by running [tuist secret](/docs/commands/secret/) and place its contents into `Tuist/master.key` that will be used
|
||||
for encrypting and decrypting your files.
|
||||
|
||||
|
|
Loading…
Reference in New Issue