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,9 @@

package org.jetbrains.compose.desktop.application.internal

import org.jetbrains.compose.desktop.application.internal.validation.MacSigningCertificateKind
import org.jetbrains.compose.desktop.application.internal.validation.MacSigningIdentityInput
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 +32,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 +65,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 +103,105 @@ 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 requestedIdentity = settings.parsedIdentity
check(requestedIdentity.isAppSigningIdentity) {
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 { it.matchesCandidateIdentity(candidate) }
}

if (matches.isEmpty()) {
error(buildMissingCertificateMessage(settings))
}

val distinctMatches = linkedSetOf<String>().apply { addAll(matches) }
if (distinctMatches.size > 1) {
error(buildAmbiguousCertificateMessage(settings, distinctMatches))
}

return ResolvedMacSigningIdentity.fromIdentity(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 'Mac App Distribution' or 'Apple Distribution' for the app ")
append("and a matching installer certificate for PKG. Development certificates such as ")
append("'Apple Development', 'Mac 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 String.matchesCandidateIdentity(candidate: String): Boolean {
val candidateIdentity = MacSigningIdentityInput.parse(candidate)
val aliasIdentity = MacSigningIdentityInput.parse(this)
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))
}

private 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 +232,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,182 @@
/*
* 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 "Mac Developer" certificates
*/
internal enum class MacSigningCertificateKind(
val prefix: String,
val isAppSigningCertificate: Boolean,
val isJPackageCompatible: Boolean,
val isDevelopmentCertificate: Boolean
) {
// Current outside-App-Store distribution certificates
DeveloperIdApplication(
prefix = "Developer ID Application: ",
isAppSigningCertificate = true,
isJPackageCompatible = true,
isDevelopmentCertificate = false
),
DeveloperIdInstaller(
prefix = "Developer ID Installer: ",
isAppSigningCertificate = false,
isJPackageCompatible = false,
isDevelopmentCertificate = false
),

// Current App Store / distribution certificates
AppleDistribution(
prefix = "Apple Distribution: ",
isAppSigningCertificate = true,
isJPackageCompatible = false,
isDevelopmentCertificate = false
),
MacAppDistribution(
prefix = "Mac App Distribution: ",
isAppSigningCertificate = true,
isJPackageCompatible = false,
isDevelopmentCertificate = false
),
MacInstallerDistribution(
prefix = "Mac Installer Distribution: ",
isAppSigningCertificate = false,
isJPackageCompatible = false,
isDevelopmentCertificate = false
),

// Current development certificates
AppleDevelopment(
prefix = "Apple Development: ",
isAppSigningCertificate = true,
isJPackageCompatible = false,
isDevelopmentCertificate = true
),
MacDevelopment(
prefix = "Mac Development: ",
isAppSigningCertificate = true,
isJPackageCompatible = false,
isDevelopmentCertificate = true
),
MacDeveloper(
prefix = "Mac Developer: ",
isAppSigningCertificate = true,
isJPackageCompatible = false,
isDevelopmentCertificate = true
),

// Legacy compatibility certificates
ThirdPartyMacDeveloperApplication(
prefix = "3rd Party Mac Developer Application: ",
isAppSigningCertificate = true,
isJPackageCompatible = true,
isDevelopmentCertificate = false
),
ThirdPartyMacDeveloperInstaller(
prefix = "3rd Party Mac Developer Installer: ",
isAppSigningCertificate = false,
isJPackageCompatible = false,
isDevelopmentCertificate = false
);

val displayName: String
get() = prefix.removeSuffix(": ")

companion object {
val appSigningKinds = listOf(
DeveloperIdApplication,
ThirdPartyMacDeveloperApplication,
AppleDistribution,
MacAppDistribution,
AppleDevelopment,
MacDevelopment,
MacDeveloper,
)

fun fromIdentity(identity: String): MacSigningCertificateKind? =
entries.firstOrNull { identity.startsWith(it.prefix) }
}
}

internal data class MacSigningIdentityInput(
val rawIdentity: String,
val kind: MacSigningCertificateKind?,
val name: String
) {
val isExplicitlyPrefixed: Boolean
get() = kind != null

val fullIdentity: String
get() = kind?.prefix?.plus(name) ?: rawIdentity

val isAppSigningIdentity: Boolean
get() = kind?.isAppSigningCertificate != false

fun appSigningSearchIdentities(): List<String> {
if (isExplicitlyPrefixed) {
return listOfNotNull(fullIdentity.takeIf { isAppSigningIdentity })
}

return MacSigningCertificateKind.appSigningKinds.map { it.prefix + name }
}

companion object {
fun parse(identity: String): MacSigningIdentityInput {
val kind = MacSigningCertificateKind.fromIdentity(identity)
val name = kind?.let { identity.removePrefix(it.prefix) } ?: identity
return MacSigningIdentityInput(
rawIdentity = identity,
kind = kind,
name = name
)
}
}
}

internal data class ResolvedMacSigningIdentity(
val fullIdentity: String,
val kind: MacSigningCertificateKind
) {
val isJPackageCompatible: Boolean
get() = kind.isJPackageCompatible

val installerSigningIdentityCandidates: List<String>
get() = when (kind) {
MacSigningCertificateKind.DeveloperIdApplication ->
listOf(fullIdentity.replaceFirst(kind.prefix, MacSigningCertificateKind.DeveloperIdInstaller.prefix))

MacSigningCertificateKind.ThirdPartyMacDeveloperApplication,
MacSigningCertificateKind.AppleDistribution,
MacSigningCertificateKind.MacAppDistribution -> listOf(
MacSigningCertificateKind.ThirdPartyMacDeveloperInstaller.prefix + commonName,
MacSigningCertificateKind.MacInstallerDistribution.prefix + commonName,
)

MacSigningCertificateKind.AppleDevelopment,
MacSigningCertificateKind.MacDevelopment,
MacSigningCertificateKind.MacDeveloper,
MacSigningCertificateKind.DeveloperIdInstaller,
MacSigningCertificateKind.ThirdPartyMacDeveloperInstaller,
MacSigningCertificateKind.MacInstallerDistribution -> emptyList()
}

private val commonName: String
get() = fullIdentity.removePrefix(kind.prefix)

companion object {
fun fromIdentity(identity: String): ResolvedMacSigningIdentity {
val kind = MacSigningCertificateKind.fromIdentity(identity)
check(kind != null && kind.isAppSigningCertificate) {
"Unsupported macOS app signing identity: '$identity'"
}
return ResolvedMacSigningIdentity(identity, kind)
}
}
}
Loading