diff --git a/build.gradle.kts b/build.gradle.kts index 1fb01d6..cd9a859 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,28 +3,32 @@ import kotlin.io.path.createDirectories import kotlin.io.path.writeText plugins { - kotlin("jvm") version "2.0.0" - kotlin("plugin.serialization") version "2.0.0" + kotlin("jvm") version "2.0.21" + kotlin("plugin.serialization") version "2.0.21" + alias(libs.plugins.versions) + alias(libs.plugins.versions.filter) + alias(libs.plugins.versions.update) application } group = "nkiesel.org" -version = "2.8.1" +version = "3.0.0" repositories { mavenCentral() } dependencies { - implementation("com.github.ajalt.clikt:clikt:4.3.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") - implementation("org.http4k:http4k-core:5.23.0.0") - implementation("org.http4k:http4k-client-okhttp:5.23.0.0") - implementation("com.github.ajalt.mordant:mordant:2.6.0") + implementation(libs.clikt) + implementation(libs.clikt.markdown) + implementation(libs.kotlin.serialization) + implementation(libs.http4k.core) + implementation(libs.http4k.client.okhttp) + implementation(libs.mordant) + implementation(libs.google.cloud.secretmanager) - testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.2") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.2") - testImplementation("io.kotest:kotest-assertions-core:5.9.1") + testImplementation(libs.junit.bom) + testImplementation(libs.junit.jupiter) } kotlin { @@ -52,7 +56,9 @@ tasks.register("uberJar") { dependsOn(configurations.runtimeClasspath) from({ configurations.runtimeClasspath.get().filter { it.name.endsWith(".jar") }.map { zipTree(it) } - }) + }) { + exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA") + } } val versionFile: Path = layout.buildDirectory.file("generated/version").get().asFile.toPath() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..101d485 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[versions] +clikt = "5.0.1" +gcsm = "2.53.0" +http4k = "5.33.1.0" +junit5 = "5.11.3" +mordant = "3.0.1" +plugin-versions = "0.51.0" +plugin-versionsFilter = "0.1.16" +plugin-versionsUpdate = "0.8.5" +serialization = "1.7.3" + +[libraries] +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } +clikt-markdown = { module = "com.github.ajalt.clikt:clikt-markdown", version.ref = "clikt" } +google-cloud-secretmanager = { module = "com.google.cloud:google-cloud-secretmanager", version.ref = "gcsm" } +http4k-client-okhttp = { module = "org.http4k:http4k-client-okhttp", version.ref = "http4k" } +http4k-core = { module = "org.http4k:http4k-core", version.ref = "http4k" } +junit-bom = { module = "org.junit:junit-bom", version.ref = "junit5" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } +kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +mordant = { module = "com.github.ajalt.mordant:mordant", version.ref = "mordant" } + +[plugins] +versions = { id = "com.github.ben-manes.versions", version.ref = "plugin-versions" } +versions-filter = { id = "se.ascp.gradle.gradle-versions-filter", version.ref = "plugin-versionsFilter" } +versions-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "plugin-versionsUpdate" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e644113..2c35211 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..df97d72 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf1..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30d..9d21a21 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/src/main/kotlin/CertificateHelper.kt b/src/main/kotlin/CertificateHelper.kt index 5a72118..6e46536 100644 --- a/src/main/kotlin/CertificateHelper.kt +++ b/src/main/kotlin/CertificateHelper.kt @@ -1,6 +1,9 @@ import com.github.ajalt.clikt.completion.CompletionCandidates import com.github.ajalt.clikt.completion.completionOption import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.installMordantMarkdown +import com.github.ajalt.clikt.core.main import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.default import com.github.ajalt.clikt.parameters.options.* @@ -8,6 +11,8 @@ import com.github.ajalt.clikt.parameters.types.enum import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.mordant.rendering.TextColors.* import com.github.ajalt.mordant.terminal.Terminal +import com.google.cloud.secretmanager.v1.ProjectName +import com.google.cloud.secretmanager.v1.SecretManagerServiceClient import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* @@ -58,11 +63,11 @@ fun String?.hasContent() = !this.isNullOrEmpty() fun BooleanArray?.hasContent() = this != null && this.isNotEmpty() enum class InputFormat { - SERVER, JSON, PEM, BASE64, VAULT, CONFIG + SERVER, JSON, PEM, BASE64, CONFIG, SECRET, } enum class OutputFormat { - SUMMARY, TEXT, PEM, BASE64, CONFIG + SUMMARY, TEXT, PEM, BASE64 } private const val terminalIO = "-" @@ -118,26 +123,13 @@ private val extendedKeyUsages = mapOf( typealias X509List = List fun main(args: Array) { - CertificateHelper().main(args) + CertificateHelper().run { + installMordantMarkdown() + main(args) + } } -class CertificateHelper : CliktCommand( - name = "ch", - help = """ - Reads or updates certificates from server, config, file, or vault. Examples: - ``` - ch -f server api.github.com - ch -f pem my_cert.pem - ``` - """.trimIndent(), - epilog = """ - Vault operations need a current vault token. This can be provided either via - the environment variable VAULT_TOKEN, or via the file `${'$'}HOME/.vault-token`. - The latter is automatically created when using the command "vault login". The - token (normally valid for 24 hours) can be generated after signing into the vault - and then using the "Copy Token" menu entry from the top-right user menu. - """.trimIndent(), -) { +class CertificateHelper : CliktCommand(name = "ch") { init { completionOption() versionOption( @@ -146,6 +138,19 @@ class CertificateHelper : CliktCommand( ) } + override fun help(context: Context): String = """ + Reads or updates certificates from server, config, file, or secret. Examples: + ``` + ch -f server api.github.com + ch -f pem my_cert.pem + ``` + """.trimIndent() + + override fun helpEpilog(context: Context): String = """ + GSM operations need an access token. Run `gcloud auth application-default login` + to allow `ch` access to GSM. + """.trimIndent() + private val inputOption by option( "-i", "--input", completionCandidates = CompletionCandidates.Path, help = "Input file or server name; - for stdin" @@ -155,9 +160,9 @@ class CertificateHelper : CliktCommand( private val hostName by option("-n", "--hostName", help = "CA bundle using partner server name from config").flag() private val jwe by option("-j", "--jwe", help = "partner JWE info from config").flag() private val tls by option("--tls", help = "own TLS info from config").flag() - private val bundle by option("-b", "--bundle", help = "partner CA bundle info from config").flag(default = true) + private val bundle by option("-b", "--bundle", help = "partner CA bundle info from config").flag(default = false) private val key by option("-k", "--key", help = "partner config key") - private val cleanup by option("--cleanup", help = "Clean up certificates (remove duplicates, drop expired)").flag() + private val secretName by option("-s", "--secretName", help = "partner-related secret name").default("") private val port by option("-p", "--port", help = "partner server port").int().default(443) private val output by option( "-o", "--output", completionCandidates = CompletionCandidates.Path, @@ -209,45 +214,27 @@ class CertificateHelper : CliktCommand( input = inputArgument.ifBlank { inputOption } useStdin = input == terminalIO - when (inputFormat) { + when (if (secretName.isNotBlank()) InputFormat.SECRET else inputFormat) { InputFormat.PEM, InputFormat.BASE64 -> handlePEM() InputFormat.SERVER -> handleServer() InputFormat.JSON -> handleJson() InputFormat.CONFIG -> handleConfig() - InputFormat.VAULT -> handleVault() + InputFormat.SECRET -> handleSecret() } writer.flush() val final = content.toString().let { when (outputFormat) { - OutputFormat.BASE64, OutputFormat.CONFIG -> it.base64Encode() + OutputFormat.BASE64 -> it.base64Encode() else -> it } } when { output == terminalIO -> terminal.print(final) - outputFormat == OutputFormat.CONFIG -> updateConfig(final) else -> Path(output).writeText(final) } } - private fun updateConfig(content: String) { - val config = Path(output).readText() - val configKey = getConfigKey() - if (configKey.isNullOrBlank()) { - info(input, "Key is required for config files") - return - } - try { - val json = parser.parseToJsonElement(config).jsonObject - val updated = setJsonValue(json, "$configKey.tls.caBundleBase64", content) - Path(output).writeText(parser.encodeToString(updated)) - } catch (e: Exception) { - info(output, "Cannot parse as JSON") - return - } - } - private fun handlePEM() { if (useStdin) { val text = readText() @@ -297,9 +284,8 @@ class CertificateHelper : CliktCommand( SSLSocketFactory.getDefault().createSocket().use { it.connect(InetSocketAddress(address, port), timeout.inWholeMilliseconds.toInt()) } - } catch (e: Exception) { + } catch (_: Exception) { error(host, "Could not connect to $address within $timeout") - return emptyList() } val socketFactory = tlsContext.apply { @@ -308,9 +294,8 @@ class CertificateHelper : CliktCommand( val sslSocket = try { socketFactory.createSocket(address, port) as SSLSocket - } catch (e: Exception) { + } catch (_: Exception) { error(host, "Could not connect to $address") - return emptyList() } sslSocket.use { try { @@ -319,11 +304,7 @@ class CertificateHelper : CliktCommand( } } - val chain = tm.chain?.toMutableList() - if (chain == null) { - error(host, "Could not obtain server certificate chain for $address") - return emptyList() - } + val chain = tm.chain?.toMutableList() ?: error(host, "Could not obtain server certificate chain for $address") if (considerCertificate(chain.size)) { do { @@ -369,7 +350,6 @@ class CertificateHelper : CliktCommand( val configKey = getConfigKey() if (configKey.isNullOrBlank()) { error(input, "Key is required for config files") - return } val config = if (useStdin) readText() else Path(input).readText() @@ -386,7 +366,6 @@ class CertificateHelper : CliktCommand( } if (json == null) { error(input, "Cannot extract $configKey") - return } if (hostName) { @@ -396,100 +375,98 @@ class CertificateHelper : CliktCommand( } } + private inner class Config(config: String) { + val jsonElement = parser.parseToJsonElement(config) + + inline fun extract(name: String): T { + try { + return parser.decodeFromString(parser.encodeToString(jsonElement.jsonObject[name])) + } catch (_: Exception) { + error(input, "Cannot extract $name") + } + } + } + private fun handleConfig() { - val config = if (useStdin) readText() else Path(input).readText() val configKey = getConfigKey() if (configKey.isNullOrBlank()) { error(input, "Key is required for config files") - return - } - - val json = parser.parseToJsonElement(config).jsonObject[configKey] - if (json == null) { - error(input, "Cannot extract $configKey") - return } - - val partner = parser.decodeFromString(parser.encodeToString(json)) - + val config = Config(if (useStdin) readText() else Path(input).readText()) + val gcpSecretManager = config.extract("gcp-secret-manager") + val projectName = ProjectName.of(gcpSecretManager.project) + val partner = config.extract(configKey) when { hostName -> handleServer(partner.tls.hostName) - jwe -> partner.api?.JWEPublicKeyBase64?.forEach { chain(input, it.base64Decode().inputStream()) } - tls -> partner.tls.clientCertificateBase64?.let { chain(input, it.base64Decode().inputStream()) } + jwe || tls -> secrets( + projectName, + if (jwe) partner.api?.partnerJWECertificates else partner.tls.ckTLSCertificates + )?.forEach { (name, value) -> + chain( + "$name from $projectName", + value.base64Decode().inputStream() + ) + } bundle -> chain(input, partner.tls.caBundleBase64?.base64Decode()?.inputStream()) } } - private fun getVaultKey(): String? { - val configKey = key - return when { - configKey.isNullOrBlank() -> null - "/quick-apply/" in configKey -> configKey - else -> "/v1/secret/member/quick-apply/$configKey" - } - } + fun PartnerRelatedSecret(key: String) = PartnerRelatedSecret( + current = GSMReference("user", key), + next = GSMReference("user", key) + ) - private fun getEnv(name: String) = System.getenv(name).takeUnless { it.isNullOrBlank() } - - private fun getVaultHost(): String { - return when { - !useStdin -> input - else -> getEnv("VAULT_ADDR") ?: "" + private fun handleSecret() { + if (secretName.isBlank()) { + error(input, "Secret name is required") } - } - private fun getVaultToken(): String { - return getEnv("VAULT_TOKEN") ?: Path(System.getenv("HOME"), ".vault-token").readText() - } - - private fun handleVault() { - val host = getVaultHost() - val vaultKey = getVaultKey() - if (vaultKey.isNullOrBlank()) { - info(input, "Key is required for vault") - return - } - val vaultToken = getVaultToken() - if (vaultToken.isBlank()) { - info(input, "Token is required for vault") - return + val config = Config(if (useStdin) readText() else Path(input).readText()) + val gcpSecretManager = config.extract("gcp-secret-manager") + val projectName = ProjectName.of(gcpSecretManager.project) + secrets(projectName, PartnerRelatedSecret(secretName))?.forEach { (name, value) -> + chain( + "$name from $projectName", + value.base64Decode().inputStream() + ) } + } - val request = Request(Method.GET, Uri.of(host).appendToPath(vaultKey)) - .header("X-Vault-Token", vaultToken) - // We currently have to use the insecure client because the vault certificate issuer is not in our - // list of trusted root certificates - val client = OkHttp(PreCannedOkHttpClients.insecureOkHttpClient()) - val response = client(request) - if (response.status.code != 200) { - info(input, "Vault did not return data") - return + private fun secrets(projectName: ProjectName, partnerRelatedSecret: PartnerRelatedSecret?): Map? { + if (partnerRelatedSecret == null) { + return null } - val json: JsonElement = parser.parseToJsonElement(response.bodyString()) - val data = json.jsonObject["data"]?.jsonObject?.get("value")?.jsonPrimitive?.content - if (data == null) { - info(input, "Vault did not return expected JSON") - return - } - - with(writer) { - when (outputFormat) { - OutputFormat.BASE64 -> println(data) - OutputFormat.PEM -> println(data.base64Decode().decodeToString()) - else -> chain(input, data.base64Decode().inputStream()) + try { + SecretManagerServiceClient.create().use { client -> + fun value(secret: GSMReference): Pair = + secret.key to (client.accessSecretVersion(secret.latest(projectName)).payload.data.toString(Charsets.US_ASCII) + ?: "") + + val current = value(partnerRelatedSecret.current) + if (partnerRelatedSecret.current.key == partnerRelatedSecret.next.key) { + return mapOf(current) + } else { + val next = value(partnerRelatedSecret.next) + return if (current.second == next.second) { + mapOf("${current.first} and ${next.first}" to current.second) + } else { + mapOf(current, next) + } + } } + } catch (e: Exception) { + error(input, "Could not read secrets from ${projectName.project}: ${e.message?.lines()?.get(0)}") } } private fun chain(name: String, inputStream: InputStream?) { if (inputStream == null) { error(name, "No data") - return } inputStream.use { stream -> try { process(name, certificateFactory.generateCertificates(stream).map { it as X509Certificate }) - } catch (e: Exception) { + } catch (_: Exception) { error(name, "Could not read as X509 certificate") } } @@ -501,52 +478,13 @@ class CertificateHelper : CliktCommand( terminal.println("\n$name: $info") } - private fun error(name: String, info: String) { + private fun error(name: String, info: String): Nothing { info(name, red(info)) - } - - /** - * This removes duplicates and expired certificates from the list. It also sorts the certificates - * so that issuers of a certificate - if they are part of the list - come after the certificate. - */ - private fun cleanup(list: X509List): X509List { - /** - * Split the [candidates] into 2 lists: those who have an issuer in the [parents] list, and the rest - */ - fun findChildren(parents: X509List, candidates: X509List): Pair { - val parentIds = parents.map { it.subjectX500Principal } - return candidates.partition { it.issuerX500Principal in parentIds } - } - - // Filter and sort and split into root certs and the rest - val now = Instant.now() - var (issuers, candidates) = list - .filter { it.notAfter.toInstant() >= now } - .distinctBy { it.subjectX500Principal } - .sortedBy { cert -> cert.notAfter } - .partition { it.subjectX500Principal == it.issuerX500Principal } - - // Add immediate children of issuers to issuers - while (candidates.isNotEmpty()) { - findChildren(issuers, candidates).let { (children, remaining) -> - if (children.isEmpty()) { - // could not find any cert in candidates issued by issuer - issuers += remaining - candidates = emptyList() - } else { - issuers += children - candidates = remaining - } - } - } - - // We want the leaf certs first, so reversing the list - return issuers.reversed() + exitProcess(1) } private fun process(name: String, certificates: X509List) { - val certs = if (cleanup) cleanup(certificates) else certificates - for (cert in certs.withIndex()) { + for (cert in certificates.withIndex()) { if (considerCertificate(cert.index)) { certificate(cert.value, name) } @@ -557,7 +495,7 @@ class CertificateHelper : CliktCommand( when (outputFormat) { OutputFormat.SUMMARY -> certificateSummary(cert, name) OutputFormat.TEXT -> certificateText(cert, name) - OutputFormat.BASE64, OutputFormat.PEM, OutputFormat.CONFIG -> certificatePem(cert) + OutputFormat.BASE64, OutputFormat.PEM -> certificatePem(cert) } } diff --git a/src/main/kotlin/EAC.kt b/src/main/kotlin/EAC.kt index 833d0c4..8251230 100644 --- a/src/main/kotlin/EAC.kt +++ b/src/main/kotlin/EAC.kt @@ -1,51 +1,58 @@ +import com.google.cloud.secretmanager.v1.ProjectName +import com.google.cloud.secretmanager.v1.SecretVersionName +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class EAC( - val api: Api? = null, -// val continueUrl: String, -// val secondsBetweenHealthchecks: Int, -// val statusPageUrlTemplate: String, -// val tilaUrl: String, - val tls: Tls + val oauth: OAuth? = null, + val tls: Tls, + val api: Api? = null ) @Serializable -data class Api( - val JWEPublicKeyBase64: Array, -// val JWEPrivateKeyBase64: Array, -// val timeoutInMs: Int -) { - override fun equals(other: Any?): Boolean { - return this === other || other is Api && JWEPublicKeyBase64.contentEquals(other.JWEPublicKeyBase64) - } - - override fun hashCode(): Int { - return JWEPublicKeyBase64.contentHashCode() - } +data class GCPSecretManager( + val enabled: Boolean, + val project: String +) + +@Serializable +data class GSMReference( + @SerialName("_source") val source: String, + @SerialName("_key") val key: String, + ) { + fun latest(project: ProjectName): SecretVersionName = SecretVersionName.of(project.project, key, "latest") } @Serializable -data class JWEPrivateKeyBase64( - val _source: String, - val _key: String +data class PartnerRelatedSecret( + val current: GSMReference, + val next: GSMReference, +) + +@Serializable +data class Api( + val partnerJWECertificates: PartnerRelatedSecret, + val ckJWEPrivateKeys: PartnerRelatedSecret, ) @Serializable data class Tls( val hostName: String, -// val hostHeader: String, -// val path: String, -// val healthCheckPath: String, -// val ciphers: String, val overrideBundle: Boolean = false, val caBundleBase64: String? = null, val clientCertificateBase64: String? = null, -// val clientPrivateKeyBase64: ClientPrivateKeyBase64 + val ckTLSCertificates: PartnerRelatedSecret, + val ckTLSPrivateKeys: PartnerRelatedSecret, +) + +@Serializable +data class OAuthClientConfig( + @SerialName("client_id") val clientId: GSMReference, + @SerialName("client_secrets") val clientSecrets: PartnerRelatedSecret, ) -//@Serializable -//data class ClientPrivateKeyBase64( -// val _source: String, -// val _key: String -//) +@Serializable +data class OAuth( + @SerialName("client_config") val clientConfig: OAuthClientConfig +) diff --git a/src/test/kotlin/EACTest.kt b/src/test/kotlin/EACTest.kt index 1f8e0d8..51455f4 100644 --- a/src/test/kotlin/EACTest.kt +++ b/src/test/kotlin/EACTest.kt @@ -1,25 +1,44 @@ import io.kotest.matchers.shouldBe -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import org.intellij.lang.annotations.Language import org.junit.jupiter.api.Test class EACTest { - private val json = """ + private val json = Json { ignoreUnknownKeys = true } + + @Language("JSON") + private val abc = """ { - "api": { - "JWEPublicKeyBase64": ["abcde"], - "timeoutInMs": 60000 - }, "tls": { "hostName": "api.example.com", - "caBundleBase64": "abcde" + "ckTLSCertificates": { + "current": { + "_source": "vault", + "_key": "abc-tls-ck-certificate-current" + }, + "next": { + "_source": "vault", + "_key": "abc-tls-ck-certificate-next" + } + }, + "ckTLSPrivateKeys": { + "current": { + "_source": "vault", + "_key": "abc-tls-ck-privatekey-current" + }, + "next": { + "_source": "vault", + "_key": "abc-tls-ck-privatekey-next" + } + } } } """.trimIndent() @Test fun `parse Json`() { - val eac = Json { ignoreUnknownKeys = true }.decodeFromString(json) + val eac = json.decodeFromString(abc) eac.tls.hostName shouldBe "api.example.com" + eac.tls.ckTLSCertificates.current.key shouldBe "abc-tls-ck-certificate-current" } }