Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Trekker ut SOPS config rett fra kryptert tekst #66

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
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
8 changes: 7 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions src/main/kotlin/cryptoservice/controller/CryptoController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ class CryptoController(
fun decryptPost(
@RequestHeader gcpAccessToken: String,
@RequestBody cipherText: String,
): ResponseEntity<String> {
val decryptedString =
decryptionService.decrypt(
): ResponseEntity<Pair<String, String>> {
val plainTextAndConfig =
decryptionService.decryptWithSopsConfig(
ciphertext = cipherText,
gcpAccessToken = GCPAccessToken(gcpAccessToken),
sopsAgeKey = sopsAgePrivateKey,
)

return ResponseEntity.ok().body(decryptedString)
return ResponseEntity.ok().body(plainTextAndConfig)
}

@PostMapping("/encrypt")
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/cryptoservice/model/GCPAccessToken.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cryptoservice.model

data class GCPAccessToken(val value: String)
data class GCPAccessToken(
val value: String,
)

fun GCPAccessToken.sensor() =
GCPAccessToken(
Expand Down
35 changes: 35 additions & 0 deletions src/main/kotlin/cryptoservice/model/SopsConfig.kt
Original file line number Diff line number Diff line change
@@ -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<KeyGroup>? = null,
@JsonProperty("kms") val kms: List<Any>? = null,
@JsonProperty("gcp_kms") val gcpKms: List<GcpKmsEntry>? = null,
@JsonProperty("azure_kv") val azureKv: List<Any>? = null,
@JsonProperty("hc_vault") val hcVault: List<Any>? = null,
@JsonProperty("age") val age: List<AgeEntry>? = null,
@JsonProperty("lastmodified") val lastModified: String? = null,
@JsonProperty("mac") val mac: String? = null,
@JsonProperty("pgp") val pgp: List<Any>? = null,
@JsonProperty("unencrypted_suffix") val unencryptedSuffix: String? = null,
@JsonProperty("version") val version: String? = null,
)

data class KeyGroup(
@JsonProperty("gcp_kms") val gcpKms: List<GcpKmsEntry>? = null,
@JsonProperty("hc_vault") val hcVault: List<Any>? = null,
@JsonProperty("age") val age: List<AgeEntry>? = 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,
)
37 changes: 33 additions & 4 deletions src/main/kotlin/cryptoservice/service/DecryptionService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -10,6 +11,32 @@ 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<String, String> =
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,
Expand All @@ -18,10 +45,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()
Expand Down
44 changes: 33 additions & 11 deletions src/main/kotlin/cryptoservice/service/EncryptionService.kt
Original file line number Diff line number Diff line change
@@ -1,36 +1,52 @@
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
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader

@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,
gcpAccessToken: GCPAccessToken,
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()
Expand All @@ -40,15 +56,21 @@ 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
}
}
21 changes: 21 additions & 0 deletions src/main/kotlin/cryptoservice/service/Utils.kt
Original file line number Diff line number Diff line change
@@ -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<String> = listOf("sops")
val encrypt: List<String> = listOf("encrypt")
Expand All @@ -13,3 +18,19 @@ val inputFile: List<String> = listOf("/dev/stdin")
const val EXECUTION_STATUS_OK = 0

fun gcpAccessToken(accessToken: String): List<String> = listOf("--gcp-access-token", accessToken)

object YamlUtils {
val yamlFactory = YAMLFactory()
val objectMapper =
ObjectMapper(yamlFactory)
.registerKotlinModule()
.setSerializationInclusion(JsonInclude.Include.NON_NULL)

inline fun <reified T> deSerialize(yamlString: String) = objectMapper.readValue(yamlString, T::class.java)

fun <T> serialize(t: T) =
ObjectMapper(yamlFactory.enable(YAMLGenerator.Feature.LITERAL_BLOCK_STYLE))
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
.registerKotlinModule()
.writeValueAsString(t)
}
47 changes: 46 additions & 1 deletion src/test/kotlin/cryptoservice/service/DecryptionServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Exception> { 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<Exception> { decryptionService.decrypt(sopsFileWithShamir2, invalidGCPAccessToken, ageKey1) }
}

@Disabled
@Execution(ExecutionMode.CONCURRENT)
@ParameterizedTest
@MethodSource("listOfDecryptionParameters")
Expand All @@ -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<IllegalArgumentException> {
decryptionService.extractSopsConfig(ciphertextWithoutSops)
}
}
}
Loading