diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt index 1cd2c4b2347..510bc018cbf 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/MacSigner.kt @@ -5,6 +5,8 @@ package org.jetbrains.compose.desktop.application.internal +import org.jetbrains.compose.desktop.application.internal.validation.MacSigningCertificateKind +import org.jetbrains.compose.desktop.application.internal.validation.ResolvedMacSigningIdentity import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings import org.jetbrains.compose.internal.utils.Arch import org.jetbrains.compose.internal.utils.MacUtils @@ -29,6 +31,9 @@ internal abstract class MacSigner(protected val runTool: ExternalToolRunner) { } abstract val settings: ValidatedMacOSSigningSettings? + + open val resolvedSigningIdentity: ResolvedMacSigningIdentity? + get() = null } internal class NoCertificateSigner(runTool: ExternalToolRunner) : MacSigner(runTool) { @@ -59,29 +64,35 @@ internal class MacSignerImpl( @Transient private var signKeyValue: String? = null - override fun sign( - file: File, - entitlements: File?, - forceEntitlements: Boolean - ) { - // sign key calculation is delayed to avoid - // creating an external process during the configuration - // phase, which became an error in Gradle 8.1 - // https://github.com/JetBrains/compose-multiplatform/issues/3060 - val signKey = signKeyValue ?: run { + override val resolvedSigningIdentity: ResolvedMacSigningIdentity by lazy { + resolveMacSigningIdentity(settings) { candidate -> + var certificates = "" runTool( MacUtils.security, args = listOfNotNull( "find-certificate", "-a", "-c", - settings.fullDeveloperID, + candidate, settings.keychain?.absolutePath ), - processStdout = { signKeyValue = matchCertificates(it) } + processStdout = { certificates = it }, + logToConsole = ExternalToolRunner.LogToConsole.Never ) - signKeyValue!! + certificates } + } + + override fun sign( + file: File, + entitlements: File?, + forceEntitlements: Boolean + ) { + // sign key calculation is delayed to avoid + // creating an external process during the configuration + // phase, which became an error in Gradle 8.1 + // https://github.com/JetBrains/compose-multiplatform/issues/3060 + val signKey = signKeyValue ?: resolvedSigningIdentity.fullIdentity.also { signKeyValue = it } runTool.unsign(file) runTool.sign( file = file, @@ -91,40 +102,107 @@ internal class MacSignerImpl( keychain = settings.keychain ) } +} - private fun matchCertificates(certificates: String): String { - // When the developer id contains non-ascii characters, the output of `security find-certificate` is - // slightly different. The `alis` line first has the hex-encoded developer id, then some spaces, - // and then the developer id with non-ascii characters encoded as octal. - // See https://bugs.openjdk.org/browse/JDK-8308042 - val regex = Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") - val m = regex.matcher(certificates) - if (!m.find()) { - val keychainPath = settings.keychain?.absolutePath - error( - "Could not find certificate for '${settings.identity}'" + - " in keychain [${keychainPath.orEmpty()}]" - ) +internal fun resolveMacSigningIdentity( + settings: ValidatedMacOSSigningSettings, + findCertificates: (String) -> String +): ResolvedMacSigningIdentity { + val parsedKind = settings.parsedIdentity.kind + if (parsedKind != null) { + check(parsedKind.isAppSigningCertificate) { + buildString { + append("Signing settings error: '${settings.identity}' is not an app signing certificate. ") + append("Use one of: ") + append(MacSigningCertificateKind.appSigningKinds.joinToString { it.displayName }) + append(".") + } } + } + + val matches = mutableListOf() + for (candidate in settings.appSigningSearchIdentities) { + matches += extractCertificateAliases(findCertificates(candidate)) + .filter { matchesCandidateIdentity(it, candidate) } + } + + if (matches.isEmpty()) { + error(buildMissingCertificateMessage(settings)) + } + + val distinctMatches = matches.distinct() + if (distinctMatches.size > 1) { + error(buildAmbiguousCertificateMessage(settings, distinctMatches.toSet())) + } + + return MacSigningCertificateKind.resolveIdentity(distinctMatches.single()) +} + +private fun buildMissingCertificateMessage(settings: ValidatedMacOSSigningSettings): String { + val keychainPath = settings.keychain?.absolutePath.orEmpty() + val identity = settings.identity + val checkedIdentities = settings.appSigningSearchIdentities.joinToString("\n") { "* $it" } + return buildString { + appendLine("Could not find a matching app signing certificate for '$identity' in keychain [$keychainPath].") + appendLine("Checked certificate names:") + appendLine(checkedIdentities) + append("For notarized distribution outside the App Store, use 'Developer ID Application'. ") + append("For Mac App Store uploads, use 'Apple Distribution' or '3rd Party Mac Developer Application' for the app ") + append("and '3rd Party Mac Developer Installer' for PKG. Development certificates such as ") + append("'Apple Development' and 'Mac Developer' are only suitable for local app signing.") + } +} + +private fun buildAmbiguousCertificateMessage( + settings: ValidatedMacOSSigningSettings, + matches: Set +): String = buildString { + appendLine("Multiple matching certificates are found for '${settings.identity}' in keychain [${settings.keychain?.absolutePath.orEmpty()}].") + appendLine("Matching certificates:") + appendLine(matches.joinToString("\n") { "* $it" }) + append("Specify the full certificate identity in 'nativeDistributions.macOS.signing.identity'.") +} + +private fun matchesCandidateIdentity(alias: String, candidate: String): Boolean { + val candidateIdentity = MacSigningCertificateKind.parseIdentity(candidate) + val aliasIdentity = MacSigningCertificateKind.parseIdentity(alias) + if (candidateIdentity.kind == null || aliasIdentity.kind != candidateIdentity.kind) { + return false + } + + val candidateName = candidateIdentity.name + val aliasName = aliasIdentity.name + if (aliasName == candidateName) { + return true + } + if (!aliasName.startsWith(candidateName)) { + return false + } + return TEAM_ID_SUFFIX_REGEX.matches(aliasName.removePrefix(candidateName)) +} + +internal fun extractCertificateAliases(certificates: String): List { + // When the developer id contains non-ascii characters, the output of `security find-certificate` is + // slightly different. The `alis` line first has the hex-encoded developer id, then some spaces, + // and then the developer id with non-ascii characters encoded as octal. + // See https://bugs.openjdk.org/browse/JDK-8308042 + val m = CERTIFICATE_ALIAS_REGEX.matcher(certificates) + val result = linkedSetOf() + while (m.find()) { val hexEncoded = m.group(1) - if (hexEncoded.isNullOrBlank()) { - // Regular case; developer id only has ascii characters - val result = m.group(2) - if (m.find()) - error( - "Multiple matching certificates are found for '${settings.fullDeveloperID}'. " + - "Please specify keychain containing unique matching certificate." - ) - return result + val alias = if (hexEncoded.isNullOrBlank()) { + m.group(2) } else { - return hexEncoded + hexEncoded .substring(2) .chunked(2) .map { it.toInt(16).toByte() } .toByteArray() .toString(Charsets.UTF_8) } + result.add(alias) } + return result.toList() } private fun ExternalToolRunner.codesign(vararg args: String) = @@ -155,4 +233,9 @@ private fun optionalArg(arg: String, value: String?): Array = if (value != null) arrayOf(arg, value) else emptyArray() private val File.isExecutable: Boolean - get() = toPath().isExecutable() \ No newline at end of file + get() = toPath().isExecutable() + +private val CERTIFICATE_ALIAS_REGEX: Pattern = + Pattern.compile("\"alis\"=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"") + +private val TEAM_ID_SUFFIX_REGEX = Regex(""" \([A-Z0-9]{10}\)$""") diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt new file mode 100644 index 00000000000..c6c0b0f6df0 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/MacSigningIdentity.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.application.internal.validation + +/** + * Current Apple certificate names plus legacy aliases we still need to support. + * + * Legacy compatibility matters because: + * - jpackage still recognizes the older "3rd Party Mac Developer ..." names + * - existing user keychains may still contain legacy certificates + */ +internal enum class MacSigningCertificateKind( + val prefix: String, + val isAppSigningCertificate: Boolean, + val isJPackageCompatible: Boolean +) { + DeveloperIdApplication(prefix = "Developer ID Application: ", isAppSigningCertificate = true, isJPackageCompatible = true), + DeveloperIdInstaller(prefix = "Developer ID Installer: ", isAppSigningCertificate = false, isJPackageCompatible = false), + AppleDistribution(prefix = "Apple Distribution: ", isAppSigningCertificate = true, isJPackageCompatible = false), + AppleDevelopment(prefix = "Apple Development: ", isAppSigningCertificate = true, isJPackageCompatible = false), + ThirdPartyMacDeveloperApplication(prefix = "3rd Party Mac Developer Application: ", isAppSigningCertificate = true, isJPackageCompatible = true), + ThirdPartyMacDeveloperInstaller(prefix = "3rd Party Mac Developer Installer: ", isAppSigningCertificate = false, isJPackageCompatible = false), + MacDeveloper(prefix = "Mac Developer: ", isAppSigningCertificate = true, isJPackageCompatible = false); + + val displayName: String + get() = prefix.removeSuffix(": ") + + /** Installer certificate kinds that pair with this app signing certificate. */ + val installerKinds: List + get() = when (this) { + DeveloperIdApplication -> listOf(DeveloperIdInstaller) + ThirdPartyMacDeveloperApplication, AppleDistribution -> + listOf(ThirdPartyMacDeveloperInstaller) + AppleDevelopment, MacDeveloper, DeveloperIdInstaller, + ThirdPartyMacDeveloperInstaller -> emptyList() + } + + companion object { + val appSigningKinds: List + get() = entries.filter { it.isAppSigningCertificate } + + private fun fromIdentity(identity: String): MacSigningCertificateKind? = + entries.firstOrNull { identity.startsWith(it.prefix) } + + fun parseIdentity(identity: String): ParsedSigningIdentity { + val kind = fromIdentity(identity) + val name = kind?.let { identity.removePrefix(it.prefix) } ?: identity + return ParsedSigningIdentity(kind, name) + } + + fun resolveIdentity(identity: String): ResolvedMacSigningIdentity { + val kind = fromIdentity(identity) + check(kind != null && kind.isAppSigningCertificate) { + "Unsupported macOS app signing identity: '$identity'" + } + return ResolvedMacSigningIdentity(identity, kind) + } + } +} + +internal data class ParsedSigningIdentity( + val kind: MacSigningCertificateKind?, + val name: String +) + +internal data class ResolvedMacSigningIdentity( + val fullIdentity: String, + val kind: MacSigningCertificateKind +) { + val isJPackageCompatible: Boolean + get() = kind.isJPackageCompatible + + val installerSigningIdentityCandidates: List + get() { + val commonName = fullIdentity.removePrefix(kind.prefix) + return kind.installerKinds.map { it.prefix + commonName } + } +} diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt index 96bcc35b968..2160fd5ab9e 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt @@ -17,25 +17,25 @@ internal data class ValidatedMacOSSigningSettings( val bundleID: String, val identity: String, val keychain: File?, - val prefix: String, - private val appStore: Boolean + val prefix: String ) { - val fullDeveloperID: String + val parsedIdentity: ParsedSigningIdentity + get() = MacSigningCertificateKind.parseIdentity(identity) + + val appSigningSearchIdentities: List get() { - val developerIdPrefix = "Developer ID Application: " - val thirdPartyMacDeveloperPrefix = "3rd Party Mac Developer Application: " - return when { - identity.startsWith(developerIdPrefix) -> identity - identity.startsWith(thirdPartyMacDeveloperPrefix) -> identity - else -> (if (!appStore) developerIdPrefix else thirdPartyMacDeveloperPrefix) + identity + val (kind, name) = parsedIdentity + return if (kind != null) { + listOfNotNull(identity.takeIf { kind.isAppSigningCertificate }) + } else { + MacSigningCertificateKind.appSigningKinds.map { it.prefix + name } } } } internal fun MacOSSigningSettings.validate( bundleIDProvider: Provider, - project: Project, - appStoreProvider: Provider + project: Project ): ValidatedMacOSSigningSettings { check(currentOS == OS.MacOS) { ERR_WRONG_OS } @@ -54,14 +54,11 @@ internal fun MacOSSigningSettings.validate( } keychainFile } else null - val appStore = appStoreProvider.orNull == true - return ValidatedMacOSSigningSettings( bundleID = bundleID, identity = signIdentity, keychain = keychainFile, - prefix = signPrefix, - appStore = appStore + prefix = signPrefix ) } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt index d1935673dbd..cfa54eae6e7 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/tasks/AbstractJPackageTask.kt @@ -32,6 +32,8 @@ import org.jetbrains.compose.desktop.application.dsl.FileAssociation import org.jetbrains.compose.desktop.application.dsl.MacOSSigningSettings import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.internal.APP_RESOURCES_DIR +import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner +import org.jetbrains.compose.desktop.application.internal.extractCertificateAliases import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistListValue import org.jetbrains.compose.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue @@ -56,6 +58,7 @@ import org.jetbrains.compose.desktop.application.internal.files.normalizedPath import org.jetbrains.compose.desktop.application.internal.files.transformJar import org.jetbrains.compose.desktop.application.internal.javaOption import org.jetbrains.compose.desktop.application.internal.validation.validate +import org.jetbrains.compose.internal.utils.MacUtils import org.jetbrains.compose.internal.utils.OS import org.jetbrains.compose.internal.utils.clearDirs import org.jetbrains.compose.internal.utils.currentArch @@ -338,7 +341,7 @@ abstract class AbstractJPackageTask @Inject constructor( if (currentOS == OS.MacOS) { if (shouldSign) { val validatedSettings = - nonValidatedSettings!!.validate(nonValidatedMacBundleID, project, macAppStore) + nonValidatedSettings!!.validate(nonValidatedMacBundleID, project) MacSignerImpl(validatedSettings, runExternalTool) } else NoCertificateSigner(runExternalTool) } else null @@ -502,10 +505,15 @@ abstract class AbstractJPackageTask @Inject constructor( cliArg("--mac-entitlements", macEntitlementsFile) macSigner?.settings?.let { signingSettings -> - cliArg("--mac-sign", true) - cliArg("--mac-signing-key-user-name", signingSettings.identity) - cliArg("--mac-signing-keychain", signingSettings.keychain) - cliArg("--mac-package-signing-prefix", signingSettings.prefix) + val resolvedSigningIdentity = macSigner?.resolvedSigningIdentity + // Never pass --mac-sign for PKG: jpackage's PKG bundler fails with pre-signed + // app images. PKG signing is handled by productsign in signPkgIfNeeded() instead. + if (resolvedSigningIdentity?.isJPackageCompatible == true && targetFormat != TargetFormat.Pkg) { + cliArg("--mac-sign", true) + cliArg("--mac-signing-key-user-name", resolvedSigningIdentity.fullIdentity) + cliArg("--mac-signing-keychain", signingSettings.keychain) + cliArg("--mac-package-signing-prefix", signingSettings.prefix) + } } } } @@ -632,10 +640,76 @@ abstract class AbstractJPackageTask @Inject constructor( override fun checkResult(result: ExecResult) { super.checkResult(result) modifyRuntimeOnMacOsIfNeeded() + signPkgIfNeeded() val outputFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}") } + private fun signPkgIfNeeded() { + if (currentOS != OS.MacOS || targetFormat != TargetFormat.Pkg) return + val macSigner = macSigner ?: return + val signingSettings = macSigner.settings ?: return + val resolvedSigningIdentity = macSigner.resolvedSigningIdentity ?: return + + val candidates = resolvedSigningIdentity.installerSigningIdentityCandidates + check(candidates.isNotEmpty()) { + buildString { + append("PKG signing is not supported with '${resolvedSigningIdentity.fullIdentity}'. ") + append("Development certificates can sign local app bundles, but installer packages require ") + append("'Developer ID Application' plus 'Developer ID Installer' for outside-App-Store distribution, ") + append("or 'Apple Distribution'/'3rd Party Mac Developer Application' plus ") + append("'3rd Party Mac Developer Installer' for Mac App Store uploads.") + } + } + val installerIdentity = candidates.firstOrNull { installerCertificateExists(it, signingSettings.keychain) } + check(installerIdentity != null) { + buildString { + appendLine("Could not find a matching installer signing certificate for '${resolvedSigningIdentity.fullIdentity}'.") + appendLine("Checked installer certificate names:") + appendLine(candidates.joinToString("\n") { "* $it" }) + } + } + + val pkgFile = findOutputFileOrDir(destinationDir.ioFile, targetFormat) + val tmpPkg = pkgFile.resolveSibling("${pkgFile.nameWithoutExtension}-unsigned.pkg") + check(pkgFile.renameTo(tmpPkg)) { + "Failed to rename ${pkgFile.absolutePath} to ${tmpPkg.absolutePath}" + } + + try { + val args = mutableListOf("--sign", installerIdentity) + signingSettings.keychain?.let { + args.addAll(listOf("--keychain", it.absolutePath)) + } + args.addAll(listOf(tmpPkg.absolutePath, pkgFile.absolutePath)) + + runExternalTool( + tool = MacUtils.productsign, + args = args + ) + } finally { + if (!pkgFile.exists() && tmpPkg.exists()) { + check(tmpPkg.renameTo(pkgFile)) { + "Failed to restore unsigned PKG from ${tmpPkg.absolutePath} to ${pkgFile.absolutePath}" + } + } else { + tmpPkg.delete() + } + } + } + + private fun installerCertificateExists(identity: String, keychain: File?): Boolean { + var certificates = "" + runExternalTool( + tool = MacUtils.security, + args = listOfNotNull("find-certificate", "-a", "-c", identity, keychain?.absolutePath), + checkExitCodeIsNormal = false, + processStdout = { certificates = it }, + logToConsole = ExternalToolRunner.LogToConsole.Never + ) + return extractCertificateAliases(certificates).contains(identity) + } + private fun modifyRuntimeOnMacOsIfNeeded() { if (currentOS != OS.MacOS || targetFormat != TargetFormat.AppImage) return diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt index 4537489e8f4..88edec9f061 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/internal/utils/osUtils.kt @@ -76,6 +76,10 @@ internal object MacUtils { File("/usr/bin/make").checkExistingFile() } + val productsign: File by lazy { + File("/usr/bin/productsign").checkExistingFile() + } + val open: File by lazy { File("/usr/bin/open").checkExistingFile() } diff --git a/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt new file mode 100644 index 00000000000..2a353e87040 --- /dev/null +++ b/gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/unit/MacSignerTest.kt @@ -0,0 +1,173 @@ +package org.jetbrains.compose.test.tests.unit + +import org.jetbrains.compose.desktop.application.internal.resolveMacSigningIdentity +import org.jetbrains.compose.desktop.application.internal.validation.ValidatedMacOSSigningSettings +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class MacSignerTest { + @Test + fun resolvesBareIdentityToDeveloperIdApplicationCertificate() { + val resolved = resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Developer ID Application: Andy Himberger" -> + certificateOutput("Developer ID Application: Andy Himberger (GK8V53S8Z3)") + + else -> "" + } + } + + assertEquals("Developer ID Application: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + assertEquals( + listOf("Developer ID Installer: Andy Himberger (GK8V53S8Z3)"), + resolved.installerSigningIdentityCandidates + ) + } + + @Test + fun resolvesBareIdentityToMacDeveloperCertificate() { + val resolved = resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Mac Developer: Andy Himberger" -> + certificateOutput("Mac Developer: Andy Himberger (GK8V53S8Z3)") + + else -> "" + } + } + + assertEquals("Mac Developer: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + + assertTrue(resolved.installerSigningIdentityCandidates.isEmpty()) + } + + @Test + fun keepsExplicitAppleDevelopmentIdentity() { + val resolved = resolveMacSigningIdentity( + settings(identity = "Apple Development: Andy Himberger (GK8V53S8Z3)") + ) { candidate -> + if (candidate == "Apple Development: Andy Himberger (GK8V53S8Z3)") { + certificateOutput(candidate) + } else { + "" + } + } + + assertEquals("Apple Development: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + + assertTrue(resolved.installerSigningIdentityCandidates.isEmpty()) + } + + @Test + fun keepsExplicitAppleDistributionIdentity() { + val resolved = resolveMacSigningIdentity( + settings(identity = "Apple Distribution: Andy Himberger (GK8V53S8Z3)") + ) { candidate -> + if (candidate == "Apple Distribution: Andy Himberger (GK8V53S8Z3)") { + certificateOutput(candidate) + } else { + "" + } + } + + assertEquals("Apple Distribution: Andy Himberger (GK8V53S8Z3)", resolved.fullIdentity) + + assertEquals( + listOf("3rd Party Mac Developer Installer: Andy Himberger (GK8V53S8Z3)"), + resolved.installerSigningIdentityCandidates + ) + } + + @Test + fun resolvesPkgInstallerCandidatesForLegacyDistributionCertificates() { + val resolved = resolveMacSigningIdentity( + settings(identity = "3rd Party Mac Developer Application: Andy Himberger (GK8V53S8Z3)") + ) { candidate -> + if (candidate == "3rd Party Mac Developer Application: Andy Himberger (GK8V53S8Z3)") { + certificateOutput(candidate) + } else { + "" + } + } + + assertEquals( + listOf("3rd Party Mac Developer Installer: Andy Himberger (GK8V53S8Z3)"), + resolved.installerSigningIdentityCandidates + ) + } + + @Test + fun failsWhenBareIdentityMatchesMultipleCertificates() { + val error = assertFailsWith { + resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Apple Development: Andy Himberger" -> + certificateOutput("Apple Development: Andy Himberger (GK8V53S8Z3)") + + "Mac Developer: Andy Himberger" -> + certificateOutput("Mac Developer: Andy Himberger (GK8V53S8Z3)") + + else -> "" + } + } + } + + assertContains(error.message.orEmpty(), "Multiple matching certificates are found") + assertContains(error.message.orEmpty(), "Specify the full certificate identity") + } + + @Test + fun failsWhenIdentityCannotBeResolved() { + val error = assertFailsWith { + resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { "" } + } + + assertContains(error.message.orEmpty(), "Could not find a matching app signing certificate") + assertContains(error.message.orEmpty(), "Developer ID Application: Andy Himberger") + assertContains(error.message.orEmpty(), "Mac Developer: Andy Himberger") + } + + @Test + fun ignoresSubstringMatchesThatDoNotMatchCandidateIdentity() { + val error = assertFailsWith { + resolveMacSigningIdentity(settings(identity = "Andy Himberger")) { candidate -> + when (candidate) { + "Apple Development: Andy Himberger" -> + certificateOutput("Apple Development: Andy Himberger II (GK8V53S8Z3)") + + else -> "" + } + } + } + + assertContains(error.message.orEmpty(), "Could not find a matching app signing certificate") + } + + @Test + fun rejectsInstallerCertificatesAsAppSigningIdentities() { + val error = assertFailsWith { + resolveMacSigningIdentity( + settings(identity = "Developer ID Installer: Andy Himberger (GK8V53S8Z3)") + ) { "" } + } + + assertContains(error.message.orEmpty(), "is not an app signing certificate") + } + + private fun settings(identity: String) = ValidatedMacOSSigningSettings( + bundleID = "com.example.app", + identity = identity, + keychain = null, + prefix = "com.example." + ) + + private fun certificateOutput(alias: String): String = """ + keychain: "/Users/test/Library/Keychains/login.keychain-db" + version: 512 + class: 0x80001000 + attributes: + "alis"="$alias" + """.trimIndent() +} diff --git a/tutorials/Signing_and_notarization_on_macOS/README.md b/tutorials/Signing_and_notarization_on_macOS/README.md index d79be326e58..461f6b132d8 100644 --- a/tutorials/Signing_and_notarization_on_macOS/README.md +++ b/tutorials/Signing_and_notarization_on_macOS/README.md @@ -207,8 +207,11 @@ macOS { * Set the `sign` DSL property or to `true`. * Alternatively, the `compose.desktop.mac.sign` Gradle property can be used. -* Set the `identity` DSL property to the certificate's name, e.g. `"John Doe"`. +* Set the `identity` DSL property to the certificate's full name, e.g. + `"Developer ID Application: John Doe (TEAMID)"`. * Alternatively, the `compose.desktop.mac.signing.identity` Gradle property can be used. + * Bare names such as `"John Doe"` are also supported, but if multiple matching certificates are found, + you will need to use the full identity. * Optionally, set the `keychain` DSL property to the path to the specific keychain, containing your certificate. * Alternatively, the `compose.desktop.mac.signing.keychain` Gradle property can be used. * This step is only necessary, if multiple developer certificates of the same type are installed. @@ -219,6 +222,18 @@ The following Gradle properties can be used instead of DSL properties: * `compose.desktop.mac.signing.identity` overrides the `identity` DSL property. * `compose.desktop.mac.signing.keychain` overrides the `keychain` DSL property. +### Choosing the correct certificate type + +Use different certificate types depending on the task: + +* Local app signing and development builds: `Apple Development` or `Mac Developer`. +* Notarized distribution outside the App Store: `Developer ID Application`. +* Mac App Store app bundle signing: `Apple Distribution` or `3rd Party Mac Developer Application`. +* Mac App Store PKG signing: `3rd Party Mac Developer Installer`. + +Development certificates can sign app bundles, but they cannot sign installer packages and are not suitable for +notarized outside-App-Store distribution. + Those properties could be stored in `$HOME/.gradle/gradle.properties` to use across multiple applications. ### Notarization