Skip to content

Commit

Permalink
Support sourcing CC encryption key via env var
Browse files Browse the repository at this point in the history
Restore support for multiple key sources from initial implementation.

Avoid leaking I/O streams when encrypting them.

Document how to provide a custom encryption key.

Move info on secrets out of 'Not yet implemented' section.

Issue: gradle#27055

Co-authored-by: Laura Kassovic <lkassovic@gradle.com>
  • Loading branch information
abstratt and lkasso committed Dec 6, 2023
1 parent 838dd3f commit 611c0ae
Show file tree
Hide file tree
Showing 4 changed files with 322 additions and 98 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,54 @@ import org.gradle.test.precondition.Requires
import org.gradle.test.preconditions.UnitTestPreconditions
import org.gradle.testfixtures.internal.NativeServicesTestFixture

import java.nio.charset.StandardCharsets

import static org.gradle.configurationcache.EnvironmentVarKeySource.GRADLE_ENCRYPTION_KEY_ENV_KEY

import java.nio.file.FileVisitOption
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import java.util.stream.Stream

import static org.gradle.initialization.IGradlePropertiesLoader.ENV_PROJECT_PROPERTIES_PREFIX
import static org.gradle.util.Matchers.containsLine
import static org.gradle.util.Matchers.matchesRegexp

class ConfigurationCacheEncryptionIntegrationTest extends AbstractConfigurationCacheIntegrationTest {
TestFile keyStoreDir
String encryptionKeyText
String encryptionKeyAsBase64

def setup() {
keyStoreDir = new TestFile(testDirectory, 'keystores')
encryptionKeyText = "01234567890123456789012345678901"
encryptionKeyAsBase64 = Base64.encoder.encodeToString(encryptionKeyText.getBytes(StandardCharsets.UTF_8))
}

def "configuration cache can be loaded without errors using #encryptionTransformation"() {
def "configuration cache can be loaded without errors from #source using #encryptionTransformation"() {
given:
def additionalOpts = [
"-Dorg.gradle.configuration-cache.internal.encryption-alg=${encryptionTransformation}"
]
def configurationCache = newConfigurationCacheFixture()
runWithEncryption(true, ["help"], additionalOpts)
runWithEncryption(source, ["help"], additionalOpts)

when:
runWithEncryption(true, ["help"], additionalOpts)
runWithEncryption(source, ["help"], additionalOpts)

then:
configurationCache.assertStateLoaded()

where:
_ | encryptionTransformation
_ | "AES/ECB/PKCS5PADDING"
_ | "AES/CBC/PKCS5PADDING"
encryptionTransformation | source
"AES/ECB/PKCS5PADDING" | EncryptionKind.KEYSTORE
"AES/CBC/PKCS5PADDING" | EncryptionKind.KEYSTORE
"AES/ECB/PKCS5PADDING" | EncryptionKind.ENV_VAR
"AES/CBC/PKCS5PADDING" | EncryptionKind.ENV_VAR
}

def "configuration cache is #encrypted if enabled=#enabled"() {
def "configuration cache encryption enablement is #enabled if kind=#kind"() {
given:
def configurationCache = newConfigurationCacheFixture()
buildFile """
Expand Down Expand Up @@ -102,14 +114,19 @@ class ConfigurationCacheEncryptionIntegrationTest extends AbstractConfigurationC
}
}
"""

expect:
findRequiredKeystoreFile(false) == null

when:
runWithEncryption(enabled, ["useSensitive"], ["-Psensitive_property_name=sensitive_property_value"], [
runWithEncryption(kind, ["useSensitive"], ["-Psensitive_property_name=sensitive_property_value"], [
(ENV_PROJECT_PROPERTIES_PREFIX + 'sensitive_property_name2'): 'sensitive_property_value2',
"SENSITIVE_ENV_VAR_NAME": 'sensitive_env_var_value'
])

then:
configurationCache.assertStateStored()
enabled == kind.encrypted
def cacheDir = new File(this.testDirectory, ".gradle/configuration-cache")
isFoundInDirectory(cacheDir, "sensitive_property_name".getBytes()) == !enabled
isFoundInDirectory(cacheDir, "sensitive_property_value".getBytes()) == !enabled
Expand All @@ -122,10 +139,14 @@ class ConfigurationCacheEncryptionIntegrationTest extends AbstractConfigurationC
isFoundInDirectory(cacheDir, "sensitive_convention".getBytes()) == !enabled
isFoundInDirectory(cacheDir, "sensitive".getBytes()) == !enabled
isFoundInDirectory(cacheDir, "SENSITIVE".getBytes()) == !enabled

(findRequiredKeystoreFile(false) != null) == keystoreExpected

where:
encrypted | enabled
"encrypted" | true
"unencrypted" | false
kind | enabled | keystoreExpected
EncryptionKind.NONE | false | false
EncryptionKind.KEYSTORE | true | true
EncryptionKind.ENV_VAR | true | false
}

private boolean isFoundInDirectory(File startDir, byte[] toFind) {
Expand All @@ -141,7 +162,7 @@ class ConfigurationCacheEncryptionIntegrationTest extends AbstractConfigurationC
given:
def configurationCache = newConfigurationCacheFixture()
runWithEncryption()
findKeystoreFile().delete()
findRequiredKeystoreFile().delete()

when:
runWithEncryption()
Expand All @@ -157,7 +178,7 @@ class ConfigurationCacheEncryptionIntegrationTest extends AbstractConfigurationC
runWithEncryption()

and:
def keyStoreFile = findKeystoreFile()
def keyStoreFile = findRequiredKeystoreFile()

KeyStore ks = KeyStore.getInstance(KeyStoreKeySource.KEYSTORE_TYPE)
keyStoreFile.withInputStream { ks.load(it, new char[]{'c', 'c'}) }
Expand Down Expand Up @@ -190,36 +211,109 @@ class ConfigurationCacheEncryptionIntegrationTest extends AbstractConfigurationC
}

void runWithEncryption(
boolean enabled = true,
EncryptionKind kind = EncryptionKind.KEYSTORE,
List<String> tasks = ["help"],
List<String> additionalArgs = [],
Map<String, String> envVars = [:]
Map<String, String> envVars = [:],
Closure<Void> runner = this::configurationCacheRun
) {
def allArgs = tasks + getEncryptionOptions(enabled) + additionalArgs
executer.withEnvironmentVars(envVars)
configurationCacheRun(*allArgs)
def allArgs = tasks + getEncryptionOptions(kind) + additionalArgs + ["-s"]
// envVars overrides encryption env vars
def allVars = getEncryptionEnvVars(kind) + envVars
executer.withEnvironmentVars(allVars)
runner(*allArgs)
}

private List<String> getEncryptionOptions(boolean enabled = true) {
if (!enabled) {
return [
"-Dorg.gradle.configuration-cache.internal.encryption=false"
]
private List<String> getEncryptionOptions(EncryptionKind kind = EncryptionKind.KEYSTORE) {
switch (kind) {
case EncryptionKind.KEYSTORE:
return [
"-Dorg.gradle.configuration-cache.internal.key-store-dir=${keyStoreDir}",
]
case EncryptionKind.ENV_VAR:
// the env var is all that is required
return []
default:
// NONE
return [
"-Dorg.gradle.configuration-cache.internal.encryption=false"
]
}
return [
'-s',
"-Dorg.gradle.configuration-cache.internal.key-store-dir=${keyStoreDir}",
]
}

def "build fails if key is provided via env var but invalid"() {
given:
def invalidEncryptionKey = Base64.encoder.encodeToString((encryptionKeyText + "foo").getBytes(StandardCharsets.UTF_8))

when:
runWithEncryption(EncryptionKind.ENV_VAR, ["help"], [], [(GRADLE_ENCRYPTION_KEY_ENV_KEY): invalidEncryptionKey], this::configurationCacheFails)

then:
// since the key is not fully validated until needed, we only get an error when encrypting
failure.assertHasDescription("Error while encrypting")
// exception error message varies across JCE implementations, but the exception class is predictable
containsLine(result.error, matchesRegexp(".*java.security.InvalidKeyException.*"))
}

def "build fails if key is provided via env var but not Base64-encoded"() {
given:
char invalidBase64Char = "!"
def invalidEncryptionKey = "${invalidBase64Char}${encryptionKeyAsBase64}"

when:
runWithEncryption(EncryptionKind.ENV_VAR, ["help"], [], [(GRADLE_ENCRYPTION_KEY_ENV_KEY): invalidEncryptionKey], this::configurationCacheFails)

then:
// since the key is not fully validated until needed, we only get an error when encrypting
failure.assertHasDescription("Error loading encryption key from GRADLE_ENCRYPTION_KEY environment variable")
failure.assertHasCause("Illegal base64 character ${Integer.toHexString((int) invalidBase64Char)}")
}

def "build fails if key is provided via env var but not long enough"() {
given:
def insufficientlyLongEncryptionKey = Base64.encoder.encodeToString("01234567".getBytes(StandardCharsets.UTF_8))

when:
runWithEncryption(EncryptionKind.ENV_VAR, ["help"], [], [(GRADLE_ENCRYPTION_KEY_ENV_KEY): insufficientlyLongEncryptionKey], this::configurationCacheFails)

then:
failure.assertHasDescription("Error loading encryption key from GRADLE_ENCRYPTION_KEY environment variable")
failure.assertHasCause("Encryption key length is 8 bytes, but must be at least 16 bytes long")
}

def "new configuration cache entry if env var key changes"() {
given:
def configurationCache = newConfigurationCacheFixture()
def differentKey = "O6lTi7qNmAAIookBZGqHqyDph882NPQOXW5P5K2yupM="

when:
runWithEncryption(EncryptionKind.ENV_VAR, ["help"], [], [(GRADLE_ENCRYPTION_KEY_ENV_KEY): this.encryptionKeyAsBase64])

then:
configurationCache.assertStateStored()

when:
runWithEncryption(EncryptionKind.ENV_VAR, ["help"], [], [(GRADLE_ENCRYPTION_KEY_ENV_KEY): differentKey])

then:
configurationCache.assertStateStored()
}

private Map<String, String> getEncryptionEnvVars(EncryptionKind kind = EncryptionKind.KEYSTORE) {
if (kind == EncryptionKind.ENV_VAR) {
return [(GRADLE_ENCRYPTION_KEY_ENV_KEY): encryptionKeyAsBase64]
}
return [:]
}

private boolean isSubArray(byte[] contents, byte[] toFind) {
Bytes.indexOf(contents, toFind) >= 0
}

private TestFile findKeystoreFile() {
private TestFile findRequiredKeystoreFile(boolean required = true) {
def keyStoreDirFiles = keyStoreDir.allDescendants()
def keyStorePath = keyStoreDirFiles.find { it.endsWith('gradle.keystore') }
assert keyStorePath != null
keyStoreDir.file(keyStorePath)
assert !required || keyStorePath != null
return keyStorePath?.with { keyStoreDir.file(keyStorePath) }
}
}
Loading

0 comments on commit 611c0ae

Please sign in to comment.