Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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\"<blob>=(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<String>()
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>
): 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<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 m = CERTIFICATE_ALIAS_REGEX.matcher(certificates)
val result = linkedSetOf<String>()
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) =
Expand Down Expand Up @@ -155,4 +233,9 @@ private fun optionalArg(arg: String, value: String?): Array<String> =
if (value != null) arrayOf(arg, value) else emptyArray()

private val File.isExecutable: Boolean
get() = toPath().isExecutable()
get() = toPath().isExecutable()

private val CERTIFICATE_ALIAS_REGEX: Pattern =
Pattern.compile("\"alis\"<blob>=(0x[0-9A-F]+)?\\s*\"([^\"]+)\"")

private val TEAM_ID_SUFFIX_REGEX = Regex(""" \([A-Z0-9]{10}\)$""")
Original file line number Diff line number Diff line change
@@ -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<MacSigningCertificateKind>
get() = when (this) {
DeveloperIdApplication -> listOf(DeveloperIdInstaller)
ThirdPartyMacDeveloperApplication, AppleDistribution ->
listOf(ThirdPartyMacDeveloperInstaller)
AppleDevelopment, MacDeveloper, DeveloperIdInstaller,
ThirdPartyMacDeveloperInstaller -> emptyList()
}

companion object {
val appSigningKinds: List<MacSigningCertificateKind>
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<String>
get() {
val commonName = fullIdentity.removePrefix(kind.prefix)
return kind.installerKinds.map { it.prefix + commonName }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
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<String?>,
project: Project,
appStoreProvider: Provider<Boolean?>
project: Project
): ValidatedMacOSSigningSettings {
check(currentOS == OS.MacOS) { ERR_WRONG_OS }

Expand All @@ -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
)
}

Expand Down
Loading