From 20b22690fd575945f481fb7ecd813486a2184a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Fr=C3=B8land?= Date: Mon, 27 Jan 2025 09:06:11 +0100 Subject: [PATCH 1/6] Adding yaml serilazation/deseralization --- build.gradle.kts | 8 ++++++- .../kotlin/cryptoservice/service/Utils.kt | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 56c5ffb..af91e9c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("org.springframework.boot") version "3.3.2" id("io.spring.dependency-management") version "1.1.6" kotlin("jvm") version "2.0.10" + kotlin("plugin.serialization") version "2.0.10" kotlin("plugin.spring") version "2.0.10" id("org.jlleitschuh.gradle.ktlint") version "12.1.1" } @@ -19,13 +20,18 @@ repositories { mavenCentral() } +val fasterXmlJacksonVersion = "2.17.0" +val kotlinxSerializationVersion = "1.7.3" + dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("io.micrometer:micrometer-registry-prometheus") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$fasterXmlJacksonVersion") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$fasterXmlJacksonVersion") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/kotlin/cryptoservice/service/Utils.kt b/src/main/kotlin/cryptoservice/service/Utils.kt index 5f76239..3898503 100644 --- a/src/main/kotlin/cryptoservice/service/Utils.kt +++ b/src/main/kotlin/cryptoservice/service/Utils.kt @@ -1,4 +1,9 @@ package cryptoservice.service +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import com.fasterxml.jackson.module.kotlin.registerKotlinModule val sopsCmd: List = listOf("sops") val encrypt: List = listOf("encrypt") @@ -13,3 +18,19 @@ val inputFile: List = listOf("/dev/stdin") const val EXECUTION_STATUS_OK = 0 fun gcpAccessToken(accessToken: String): List = listOf("--gcp-access-token", accessToken) + +object YamlUtils { + val yamlFactory = YAMLFactory() + val objectMapper = + ObjectMapper(yamlFactory) + .registerKotlinModule() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + + inline fun deSerialize(yamlString: String) = objectMapper.readValue(yamlString, T::class.java) + + fun serialize(t: T) = + ObjectMapper(yamlFactory.enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE)) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .registerKotlinModule() + .writeValueAsString(t) +} From 23fcea35453a7ba7db07fdd8a2768435efbb7c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Fr=C3=B8land?= Date: Mon, 27 Jan 2025 09:07:58 +0100 Subject: [PATCH 2/6] Adding data model of sops config --- .../kotlin/cryptoservice/model/SopsConfig.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/kotlin/cryptoservice/model/SopsConfig.kt diff --git a/src/main/kotlin/cryptoservice/model/SopsConfig.kt b/src/main/kotlin/cryptoservice/model/SopsConfig.kt new file mode 100644 index 0000000..17ba07e --- /dev/null +++ b/src/main/kotlin/cryptoservice/model/SopsConfig.kt @@ -0,0 +1,35 @@ +package cryptoservice.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class SopsConfig( + @JsonProperty("shamir_threshold") val shamirThreshold: Int, + @JsonProperty("key_groups") val keyGroups: List? = null, + @JsonProperty("kms") val kms: List? = null, + @JsonProperty("gcp_kms") val gcpKms: List? = null, + @JsonProperty("azure_kv") val azureKv: List? = null, + @JsonProperty("hc_vault") val hcVault: List? = null, + @JsonProperty("age") val age: List? = null, + @JsonProperty("lastmodified") val lastModified: String? = null, + @JsonProperty("mac") val mac: String? = null, + @JsonProperty("pgp") val pgp: List? = null, + @JsonProperty("unencrypted_suffix") val unencryptedSuffix: String? = null, + @JsonProperty("version") val version: String? = null, +) + +data class KeyGroup( + @JsonProperty("gcp_kms") val gcpKms: List? = null, + @JsonProperty("hc_vault") val hcVault: List? = null, + @JsonProperty("age") val age: List? = null, +) + +data class GcpKmsEntry( + @JsonProperty("resource_id") val resourceId: String, + @JsonProperty("created_at") val createdAt: String? = null, + @JsonProperty("enc") val enc: String? = null, +) + +data class AgeEntry( + val recipient: String, + val enc: String? = null, +) From 6e23aec9b86fb29c823555f03a5354565b693c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Fr=C3=B8land?= Date: Mon, 27 Jan 2025 09:12:28 +0100 Subject: [PATCH 3/6] Decrypt and extract sops config from cipher text --- .../controller/CryptoController.kt | 8 ++--- .../service/DecryptionService.kt | 36 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/cryptoservice/controller/CryptoController.kt b/src/main/kotlin/cryptoservice/controller/CryptoController.kt index 7452f68..3478dba 100644 --- a/src/main/kotlin/cryptoservice/controller/CryptoController.kt +++ b/src/main/kotlin/cryptoservice/controller/CryptoController.kt @@ -21,15 +21,15 @@ class CryptoController( fun decryptPost( @RequestHeader gcpAccessToken: String, @RequestBody cipherText: String, - ): ResponseEntity { - val decryptedString = - decryptionService.decrypt( + ): ResponseEntity> { + val plainTextAndConfig = + decryptionService.decryptWithSopsConfig( ciphertext = cipherText, gcpAccessToken = GCPAccessToken(gcpAccessToken), sopsAgeKey = sopsAgePrivateKey, ) - return ResponseEntity.ok().body(decryptedString) + return ResponseEntity.ok().body(plainTextAndConfig) } @PostMapping("/encrypt") diff --git a/src/main/kotlin/cryptoservice/service/DecryptionService.kt b/src/main/kotlin/cryptoservice/service/DecryptionService.kt index 61b96b9..1a82838 100644 --- a/src/main/kotlin/cryptoservice/service/DecryptionService.kt +++ b/src/main/kotlin/cryptoservice/service/DecryptionService.kt @@ -2,6 +2,7 @@ package cryptoservice.service import cryptoservice.exception.exceptions.SOPSDecryptionException import cryptoservice.model.GCPAccessToken +import cryptoservice.model.SopsConfig import org.springframework.stereotype.Service import java.io.BufferedReader import java.io.InputStreamReader @@ -10,6 +11,31 @@ import java.io.InputStreamReader class DecryptionService { private val processBuilder = ProcessBuilder().redirectErrorStream(true) + fun extractSopsConfig(ciphertext: String): String { + val rootNode = YamlUtils.objectMapper.readTree(ciphertext) + val sopsNode = rootNode.get("sops") ?: throw IllegalArgumentException("No sops configuration found in ciphertext") + val sopsConfig = YamlUtils.objectMapper.treeToValue(sopsNode, SopsConfig::class.java) + val cleanConfig = sopsConfig.copy( + mac = null, + gcpKms = sopsConfig.gcpKms?.map { it.copy(enc = null) }, + age = sopsConfig.age?.map { it.copy(enc = null) } + ) + return YamlUtils.serialize(cleanConfig) + } + + fun decryptWithSopsConfig( + ciphertext: String, + gcpAccessToken: GCPAccessToken, + sopsAgeKey: String, + ): Pair = + try { + val sopsConfig = extractSopsConfig(ciphertext) + val plaintext = decrypt(ciphertext, gcpAccessToken, sopsAgeKey) + Pair(plaintext, sopsConfig) + } catch (e: Exception) { + throw e + } + fun decrypt( ciphertext: String, gcpAccessToken: GCPAccessToken, @@ -18,10 +44,12 @@ class DecryptionService { try { processBuilder .command( - "sh", - "-c", - "SOPS_AGE_KEY=$sopsAgeKey GOOGLE_OAUTH_ACCESS_TOKEN=${gcpAccessToken.value} " + - "sops decrypt --input-type yaml --output-type json /dev/stdin", + listOf( + "sh", + "-c", + "SOPS_AGE_KEY=$sopsAgeKey GOOGLE_OAUTH_ACCESS_TOKEN=${gcpAccessToken.value} " + + "sops decrypt --input-type yaml --output-type json /dev/stdin" + ) ).start() .run { outputStream.buffered().also { it.write(ciphertext.toByteArray()) }.close() From 4ca2a9c49285606a6c6580b724a2b18ddbdca01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Fr=C3=B8land?= Date: Mon, 27 Jan 2025 10:49:36 +0100 Subject: [PATCH 4/6] Encrypt with values from custom yaml and not sops.yaml --- .../service/EncryptionService.kt | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/cryptoservice/service/EncryptionService.kt b/src/main/kotlin/cryptoservice/service/EncryptionService.kt index c6dab5c..1cb89f1 100644 --- a/src/main/kotlin/cryptoservice/service/EncryptionService.kt +++ b/src/main/kotlin/cryptoservice/service/EncryptionService.kt @@ -2,18 +2,31 @@ package cryptoservice.service import cryptoservice.exception.exceptions.SopsEncryptionException import cryptoservice.model.GCPAccessToken +import cryptoservice.model.SopsConfig import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.io.BufferedReader -import java.io.File import java.io.InputStreamReader +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.registerKotlinModule @Service class EncryptionService { private val processBuilder = ProcessBuilder().redirectErrorStream(true) + private val yamlMapper = ObjectMapper(YAMLFactory()).registerKotlinModule() private val logger = LoggerFactory.getLogger(DecryptionService::class.java) + private fun extractGcpKmsResourceId(config: String): String { + val sopsConfig = yamlMapper.readValue(config, SopsConfig::class.java) + val gcpKmsConfig = sopsConfig.keyGroups?.firstOrNull { it.gcpKms?.isNotEmpty() == true } + ?: throw SopsEncryptionException("No GCP KMS configuration found", "") + + return gcpKmsConfig.gcpKms?.firstOrNull()?.resourceId + ?: throw SopsEncryptionException("No resource_id found in GCP KMS configuration", "") + } + fun encrypt( text: String, config: String, @@ -21,16 +34,18 @@ class EncryptionService { riScId: String, ): String = try { - val tempFile = File.createTempFile("sopsConfig-$riScId-${System.currentTimeMillis()}", ".yaml") - tempFile.writeText(config) - tempFile.deleteOnExit() - + val gcpKmsResourceId = extractGcpKmsResourceId(config) + processBuilder .command( - "sh", - "-c", - "GOOGLE_OAUTH_ACCESS_TOKEN=${gcpAccessToken.value} " + - "sops --encrypt --input-type json --output-type yaml --config ${tempFile.absolutePath} /dev/stdin", + listOf( + "sh", + "-c", + "GOOGLE_OAUTH_ACCESS_TOKEN=${gcpAccessToken.value} " + + "sops --encrypt " + + "--gcp-kms '$gcpKmsResourceId' " + + "--input-type json --output-type yaml /dev/stdin", + ) ).start() .run { outputStream.buffered().also { it.write(text.toByteArray()) }.close() @@ -40,15 +55,18 @@ class EncryptionService { else -> { logger.error("IOException from encrypting yaml with error code ${exitValue()}: $result") throw SopsEncryptionException( - message = @Suppress("ktlint:standard:max-line-length") - "Failed when encrypting RiSc with ID: $riScId by running sops command: sops --encrypt --input-type json --output-type yaml --config ${tempFile.absolutePath} /dev/stdin with error message: $result", + message = "Failed when encrypting RiSc with ID: $riScId by running sops command: sops --encrypt --gcp-kms '$gcpKmsResourceId' --input-type json --output-type yaml /dev/stdin with error message: $result", riScId = riScId, ) } } } } catch (e: Exception) { - logger.error("Decrypting failed.", e) + logger.error("Encryption failed.", e) throw e } + + companion object { + private const val EXECUTION_STATUS_OK = 0 + } } From d831f9539996365fcd58432b29928b5d3d0e24fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Fr=C3=B8land?= Date: Wed, 29 Jan 2025 10:13:42 +0100 Subject: [PATCH 5/6] Code and imports formatting --- .../cryptoservice/model/GCPAccessToken.kt | 4 +++- .../service/DecryptionService.kt | 15 +++++++------ .../service/EncryptionService.kt | 22 +++++++++++-------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/cryptoservice/model/GCPAccessToken.kt b/src/main/kotlin/cryptoservice/model/GCPAccessToken.kt index 7f03334..4ec35ce 100644 --- a/src/main/kotlin/cryptoservice/model/GCPAccessToken.kt +++ b/src/main/kotlin/cryptoservice/model/GCPAccessToken.kt @@ -1,6 +1,8 @@ package cryptoservice.model -data class GCPAccessToken(val value: String) +data class GCPAccessToken( + val value: String, +) fun GCPAccessToken.sensor() = GCPAccessToken( diff --git a/src/main/kotlin/cryptoservice/service/DecryptionService.kt b/src/main/kotlin/cryptoservice/service/DecryptionService.kt index 1a82838..98b231b 100644 --- a/src/main/kotlin/cryptoservice/service/DecryptionService.kt +++ b/src/main/kotlin/cryptoservice/service/DecryptionService.kt @@ -15,11 +15,12 @@ class DecryptionService { val rootNode = YamlUtils.objectMapper.readTree(ciphertext) val sopsNode = rootNode.get("sops") ?: throw IllegalArgumentException("No sops configuration found in ciphertext") val sopsConfig = YamlUtils.objectMapper.treeToValue(sopsNode, SopsConfig::class.java) - val cleanConfig = sopsConfig.copy( - mac = null, - gcpKms = sopsConfig.gcpKms?.map { it.copy(enc = null) }, - age = sopsConfig.age?.map { it.copy(enc = null) } - ) + val cleanConfig = + sopsConfig.copy( + mac = null, + gcpKms = sopsConfig.gcpKms?.map { it.copy(enc = null) }, + age = sopsConfig.age?.map { it.copy(enc = null) }, + ) return YamlUtils.serialize(cleanConfig) } @@ -48,8 +49,8 @@ class DecryptionService { "sh", "-c", "SOPS_AGE_KEY=$sopsAgeKey GOOGLE_OAUTH_ACCESS_TOKEN=${gcpAccessToken.value} " + - "sops decrypt --input-type yaml --output-type json /dev/stdin" - ) + "sops decrypt --input-type yaml --output-type json /dev/stdin", + ), ).start() .run { outputStream.buffered().also { it.write(ciphertext.toByteArray()) }.close() diff --git a/src/main/kotlin/cryptoservice/service/EncryptionService.kt b/src/main/kotlin/cryptoservice/service/EncryptionService.kt index 1cb89f1..4e66278 100644 --- a/src/main/kotlin/cryptoservice/service/EncryptionService.kt +++ b/src/main/kotlin/cryptoservice/service/EncryptionService.kt @@ -1,5 +1,8 @@ package cryptoservice.service +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.registerKotlinModule import cryptoservice.exception.exceptions.SopsEncryptionException import cryptoservice.model.GCPAccessToken import cryptoservice.model.SopsConfig @@ -7,9 +10,6 @@ import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.io.BufferedReader import java.io.InputStreamReader -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory -import com.fasterxml.jackson.module.kotlin.registerKotlinModule @Service class EncryptionService { @@ -20,9 +20,10 @@ class EncryptionService { private fun extractGcpKmsResourceId(config: String): String { val sopsConfig = yamlMapper.readValue(config, SopsConfig::class.java) - val gcpKmsConfig = sopsConfig.keyGroups?.firstOrNull { it.gcpKms?.isNotEmpty() == true } - ?: throw SopsEncryptionException("No GCP KMS configuration found", "") - + val gcpKmsConfig = + sopsConfig.keyGroups?.firstOrNull { it.gcpKms?.isNotEmpty() == true } + ?: throw SopsEncryptionException("No GCP KMS configuration found", "") + return gcpKmsConfig.gcpKms?.firstOrNull()?.resourceId ?: throw SopsEncryptionException("No resource_id found in GCP KMS configuration", "") } @@ -35,7 +36,7 @@ class EncryptionService { ): String = try { val gcpKmsResourceId = extractGcpKmsResourceId(config) - + processBuilder .command( listOf( @@ -45,7 +46,7 @@ class EncryptionService { "sops --encrypt " + "--gcp-kms '$gcpKmsResourceId' " + "--input-type json --output-type yaml /dev/stdin", - ) + ), ).start() .run { outputStream.buffered().also { it.write(text.toByteArray()) }.close() @@ -55,7 +56,10 @@ class EncryptionService { else -> { logger.error("IOException from encrypting yaml with error code ${exitValue()}: $result") throw SopsEncryptionException( - message = "Failed when encrypting RiSc with ID: $riScId by running sops command: sops --encrypt --gcp-kms '$gcpKmsResourceId' --input-type json --output-type yaml /dev/stdin with error message: $result", + message = + "Failed when encrypting RiSc with ID: $riScId" + + "by running sops command: sops --encrypt --gcp-kms '$gcpKmsResourceId' " + + "--input-type json --output-type yaml /dev/stdin with error message: $result", riScId = riScId, ) } From d05f0a055ec7ad0541accd0934b4a95fe8936553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5kon=20Fr=C3=B8land?= Date: Wed, 29 Jan 2025 10:17:24 +0100 Subject: [PATCH 6/6] Add tests for extractSopsConfig method in DecryptionServiceTest --- .../service/DecryptionServiceTest.kt | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/cryptoservice/service/DecryptionServiceTest.kt b/src/test/kotlin/cryptoservice/service/DecryptionServiceTest.kt index 05c4f4d..3c04e87 100644 --- a/src/test/kotlin/cryptoservice/service/DecryptionServiceTest.kt +++ b/src/test/kotlin/cryptoservice/service/DecryptionServiceTest.kt @@ -12,7 +12,6 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream -@Disabled class DecryptionServiceTest { companion object { // OBS! Remember to remove before committing @@ -203,31 +202,37 @@ class DecryptionServiceTest { val decryptionService = DecryptionService() } + @Disabled @Test fun `when age key is present and shamir is 1 the ciphertext is successfully decrypted`() { decryptionService.decrypt(sopsFileWithShamir1, invalidGCPAccessToken, ageKey1) } + @Disabled @Test fun `when gcp access token is valid and shamir is 1 the ciphertext is successfully decrypted`() { decryptionService.decrypt(sopsFileWithShamir1, validGCPAccessToken, invalidAgeKey) } + @Disabled @Test fun `when age key and gcp access token is present and shamir is 2 the ciphertext is successfully decrypted`() { decryptionService.decrypt(sopsFileWithShamir2, validGCPAccessToken, ageKey1) } + @Disabled @Test fun `when age key is not present and gcp access token is valid and shamir is 2 the decryption fails`() { assertThrows { decryptionService.decrypt(sopsFileWithShamir2, validGCPAccessToken, invalidAgeKey) } } + @Disabled @Test fun `when age and key is present but gcp access token is invalid and shamir is 2 the decryption fails`() { assertThrows { decryptionService.decrypt(sopsFileWithShamir2, invalidGCPAccessToken, ageKey1) } } + @Disabled @Execution(ExecutionMode.CONCURRENT) @ParameterizedTest @MethodSource("listOfDecryptionParameters") @@ -241,4 +246,44 @@ class DecryptionServiceTest { assertThat(result).contains(clearTextPartOfContent) } + + @Test + fun `extractSopsConfig should extract sops configuration from ciphertext with shamir threshold 1`() { + val sopsConfig = decryptionService.extractSopsConfig(sopsFileWithShamir1) + + // Verify the extracted config contains expected fields + assertThat(sopsConfig).contains("shamir_threshold: 1") + assertThat(sopsConfig).contains("gcp_kms:") + assertThat( + sopsConfig, + ).contains("resource_id: \"projects/spire-ros-5lmr/locations/europe-west4/keyRings/rosene-team/cryptoKeys/ros-as-code-2\"") + assertThat(sopsConfig).contains("age:") + assertThat(sopsConfig).contains("recipient: \"age1g9m644t5s95zk6px9mh2kctajqw3guuq2alntgfqu2au6fdz85lq4uupug\"") + // Verify it doesn't contain encrypted data fields + assertThat(sopsConfig).doesNotContain("ENC[AES256_GCM") + } + + @Test + fun `extractSopsConfig should extract sops configuration from ciphertext with shamir threshold 2`() { + val sopsConfig = decryptionService.extractSopsConfig(sopsFileWithShamir2) + + // Verify the extracted config contains expected fields + assertThat(sopsConfig).contains("shamir_threshold: 2") + assertThat(sopsConfig).contains("key_groups:") + assertThat(sopsConfig).contains("gcp_kms:") + assertThat(sopsConfig).contains("resource_id: \"projects/spire-ros-5lmr/locations/eur4/keyRings/ROS/cryptoKeys/ros-as-code\"") + assertThat(sopsConfig).contains("age:") + assertThat(sopsConfig).contains("recipient: \"age1g9m644t5s95zk6px9mh2kctajqw3guuq2alntgfqu2au6fdz85lq4uupug\"") + // Verify key_groups don't contain encrypted data fields + assertThat(sopsConfig).doesNotContain("enc: \"ENC[AES256_GCM") + } + + @Test + fun `extractSopsConfig should throw IllegalArgumentException when sops section is missing`() { + val ciphertextWithoutSops = "schemaVersion: v1\ntitle: test" + + assertThrows { + decryptionService.extractSopsConfig(ciphertextWithoutSops) + } + } }