diff --git a/devtools/cli/pom.xml b/devtools/cli/pom.xml index 9ba49274bea8b4..a9f1575a938679 100644 --- a/devtools/cli/pom.xml +++ b/devtools/cli/pom.xml @@ -73,6 +73,11 @@ assertj-core test + + io.smallrye.config + smallrye-config-crypto + test + io.quarkus diff --git a/devtools/cli/src/main/java/io/quarkus/cli/Config.java b/devtools/cli/src/main/java/io/quarkus/cli/Config.java new file mode 100644 index 00000000000000..bb1891a961e6ba --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/Config.java @@ -0,0 +1,29 @@ +package io.quarkus.cli; + +import java.util.List; +import java.util.concurrent.Callable; + +import io.quarkus.cli.common.OutputOptionMixin; +import io.quarkus.cli.config.Encryptor; +import io.quarkus.cli.config.SetConfig; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "config", header = "Manage Quarkus configuration", subcommands = { SetConfig.class, Encryptor.class }) +public class Config implements Callable { + @CommandLine.Mixin(name = "output") + protected OutputOptionMixin output; + + @CommandLine.Spec + protected CommandLine.Model.CommandSpec spec; + + @CommandLine.Unmatched // avoids throwing errors for unmatched arguments + List unmatchedArgs; + + @Override + public Integer call() throws Exception { + CommandLine.ParseResult result = spec.commandLine().getParseResult(); + CommandLine appCommand = spec.subcommands().get("set"); + return appCommand.execute(result.originalArgs().stream().filter(x -> !"config".equals(x)).toArray(String[]::new)); + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java b/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java index 3db8a4d8c3af2c..c8e94ce0f656b6 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java @@ -45,7 +45,15 @@ import picocli.CommandLine.UnmatchedArgumentException; @CommandLine.Command(name = "quarkus", subcommands = { - Create.class, Build.class, Dev.class, Run.class, Test.class, ProjectExtensions.class, Image.class, Deploy.class, + Create.class, + Build.class, + Dev.class, + Run.class, + Test.class, + Config.class, + ProjectExtensions.class, + Image.class, + Deploy.class, Registry.class, Info.class, Update.class, diff --git a/devtools/cli/src/main/java/io/quarkus/cli/config/BaseConfigCommand.java b/devtools/cli/src/main/java/io/quarkus/cli/config/BaseConfigCommand.java new file mode 100644 index 00000000000000..2cd4b16318d49b --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/config/BaseConfigCommand.java @@ -0,0 +1,32 @@ +package io.quarkus.cli.config; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; + +import io.quarkus.cli.common.OutputOptionMixin; +import picocli.CommandLine; + +public class BaseConfigCommand { + @CommandLine.Mixin(name = "output") + protected OutputOptionMixin output; + + @CommandLine.Spec + protected CommandLine.Model.CommandSpec spec; + + Path projectRoot; + + protected Path projectRoot() { + if (projectRoot == null) { + projectRoot = output.getTestDirectory(); + if (projectRoot == null) { + projectRoot = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + } + } + return projectRoot; + } + + protected String encodeToString(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/config/Encryptor.java b/devtools/cli/src/main/java/io/quarkus/cli/config/Encryptor.java new file mode 100644 index 00000000000000..cc9f88077a950f --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/config/Encryptor.java @@ -0,0 +1,94 @@ +package io.quarkus.cli.config; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.Callable; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "encryptor", aliases = "enc", header = "Encrypt Secrets using AES/GCM/NoPadding algorithm by default") +public class Encryptor extends BaseConfigCommand implements Callable { + @Option(required = true, names = { "-s", "--secret" }, description = "Secret") + String secret; + + @Option(names = { "-k", "--key" }, description = "Encryption Key") + String encryptionKey; + + @Option(names = { "-b" }, description = "Encryption Key in Base64 format", defaultValue = "false") + private boolean base64EncryptionKey; + + @Option(hidden = true, names = { "-a", "--algorithm" }, description = "Algorithm", defaultValue = "AES") + String algorithm; + + @Option(hidden = true, names = { "-m", "--mode" }, description = "Mode", defaultValue = "GCM") + String mode; + + @Option(hidden = true, names = { "-p", "--padding" }, description = "Algorithm", defaultValue = "NoPadding") + String padding; + + @Option(hidden = true, names = { "-q", "--quiet" }, defaultValue = "false") + boolean quiet; + + private String encryptedSecret; + + @Override + public Integer call() throws Exception { + if (encryptionKey == null) { + encryptionKey = encodeToString(generateEncryptionKey().getEncoded()); + } else { + if (!base64EncryptionKey) { + encryptionKey = encodeToString(encryptionKey.getBytes(StandardCharsets.UTF_8)); + } + } + + Cipher cipher = Cipher.getInstance(algorithm + "/" + mode + "/" + padding); + byte[] iv = new byte[12]; + new SecureRandom().nextBytes(iv); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(encryptionKey.getBytes(StandardCharsets.UTF_8)); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(sha256.digest(), "AES"), new GCMParameterSpec(128, iv)); + + byte[] encrypted = cipher.doFinal(secret.getBytes(StandardCharsets.UTF_8)); + + ByteBuffer message = ByteBuffer.allocate(1 + iv.length + encrypted.length); + message.put((byte) iv.length); + message.put(iv); + message.put(encrypted); + + this.encryptedSecret = Base64.getUrlEncoder().withoutPadding().encodeToString((message.array())); + if (!quiet) { + System.out.println("Encrypted Secret: " + encryptedSecret); + System.out.println("Encryption Key: " + encryptionKey); + } + + return 0; + } + + private SecretKey generateEncryptionKey() { + try { + return KeyGenerator.getInstance(algorithm).generateKey(); + } catch (Exception e) { + System.err.println("Error while generating the encryption key: " + e); + System.exit(-1); + } + return null; + } + + public String getEncryptedSecret() { + return encryptedSecret; + } + + public String getEncryptionKey() { + return encryptionKey; + } +} diff --git a/devtools/cli/src/main/java/io/quarkus/cli/config/SetConfig.java b/devtools/cli/src/main/java/io/quarkus/cli/config/SetConfig.java new file mode 100644 index 00000000000000..f3164bfe4b6613 --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/config/SetConfig.java @@ -0,0 +1,103 @@ +package io.quarkus.cli.config; + +import java.io.BufferedWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +import io.smallrye.config.ConfigValue; +import picocli.CommandLine; + +@CommandLine.Command(name = "set") +public class SetConfig extends BaseConfigCommand implements Callable { + @CommandLine.Option(required = true, names = { "-n", "--name" }, description = "Configuration name") + String name; + @CommandLine.Option(names = { "-a", "--value" }, description = "Configuration value") + String value; + @CommandLine.Option(names = { "-k", "--encrypt" }, description = "Encrypt value") + boolean encrypt; + + @Override + public Integer call() throws Exception { + Path properties = projectRoot().resolve("src/main/resources/application.properties"); + if (!properties.toFile().exists()) { + System.out.println("Could not find an application.properties file"); + return 0; + } + + List lines = Files.readAllLines(properties); + + if (encrypt) { + Encryptor encryptor = new Encryptor(); + List args = new ArrayList<>(); + args.add("-q"); + if (value == null) { + value = findKey(lines, name).getValue(); + } + args.add("--secret=" + value); + if (value == null || value.length() == 0) { + System.out.println("Cannot encrypt an empty value"); + return -1; + } + + ConfigValue encryptionKey = findKey(lines, "smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key"); + if (encryptionKey.getValue() != null) { + args.add("-b"); + args.add("--key=" + encryptionKey.getValue()); + } + + int execute = new CommandLine(encryptor).execute(args.toArray(new String[] {})); + if (execute < 0) { + System.exit(execute); + } + value = "${aes-gcm-nopadding::" + encryptor.getEncryptedSecret() + "}"; + if (encryptionKey.getValue() == null) { + lines.add(encryptionKey.getName() + "=" + encryptor.getEncryptionKey()); + } + } + + int nameLineNumber = -1; + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.startsWith(name + "=")) { + nameLineNumber = i; + break; + } + } + + if (nameLineNumber != -1) { + if (value != null) { + System.out.println("Setting " + name + " to " + value); + lines.set(nameLineNumber, name + "=" + value); + } else { + System.out.println("Removing " + name); + lines.remove(nameLineNumber); + } + } else { + System.out.println("Adding " + name + " with " + value); + lines.add(name + "=" + (value != null ? value : "")); + } + + try (BufferedWriter writer = Files.newBufferedWriter(properties)) { + for (String i : lines) { + writer.write(i); + writer.newLine(); + } + } + + return 0; + } + + public static ConfigValue findKey(List lines, String name) { + ConfigValue configValue = ConfigValue.builder().withName(name).build(); + for (int i = 0; i < lines.size(); i++) { + final String line = lines.get(i); + if (line.startsWith(configValue.getName() + "=")) { + return configValue.withValue(line.substring(name.length() + 1)).withLineNumber(i); + } + } + return configValue; + } +} diff --git a/devtools/cli/src/test/java/io/quarkus/cli/config/EncryptorTest.java b/devtools/cli/src/test/java/io/quarkus/cli/config/EncryptorTest.java new file mode 100644 index 00000000000000..f9382226a228e1 --- /dev/null +++ b/devtools/cli/src/test/java/io/quarkus/cli/config/EncryptorTest.java @@ -0,0 +1,25 @@ +package io.quarkus.cli.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Paths; +import java.util.Scanner; + +import org.junit.jupiter.api.Test; + +import io.quarkus.cli.CliDriver; +import io.smallrye.config.crypto.AESGCMNoPaddingSecretKeysHandler; + +class EncryptorTest { + @Test + void encrypt() throws Exception { + CliDriver.Result result = CliDriver.execute(Paths.get(System.getProperty("user.dir")), "config", "encryptor", + "--secret=12345678"); + Scanner scanner = new Scanner(result.getStdout()); + String secret = scanner.nextLine().split(": ")[1]; + String encryptionKey = scanner.nextLine().split(": ")[1]; + + AESGCMNoPaddingSecretKeysHandler handler = new AESGCMNoPaddingSecretKeysHandler(encryptionKey); + assertEquals("12345678", handler.decode(secret)); + } +} diff --git a/devtools/cli/src/test/java/io/quarkus/cli/config/SetConfigTest.java b/devtools/cli/src/test/java/io/quarkus/cli/config/SetConfigTest.java new file mode 100644 index 00000000000000..151db16bd352cd --- /dev/null +++ b/devtools/cli/src/test/java/io/quarkus/cli/config/SetConfigTest.java @@ -0,0 +1,124 @@ +package io.quarkus.cli.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import io.quarkus.cli.CliDriver; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfig; +import io.smallrye.config.SmallRyeConfigBuilder; + +class SetConfigTest { + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws Exception { + Path resources = tempDir.resolve("src/main/resources"); + Files.createDirectories(resources); + Files.createFile(resources.resolve("application.properties")); + } + + @Test + void createConfiguration() throws Exception { + CliDriver.Result result = CliDriver.execute(tempDir, "config", "set", "--name=foo.bar", "--value=1234"); + assertEquals(0, result.getExitCode()); + assertTrue(result.getStdout().contains("Adding foo.bar with 1234")); + assertEquals("1234", config().getRawValue("foo.bar")); + } + + @Test + void updateConfiguration() throws Exception { + Path propertiesFile = tempDir.resolve("src/main/resources/application.properties"); + Properties properties = new Properties(); + properties.load(propertiesFile.toUri().toURL().openStream()); + properties.put("foo.bar", "1234"); + properties.store(new FileOutputStream(propertiesFile.toFile()), ""); + + CliDriver.Result result = CliDriver.execute(tempDir, "config", "set", "--name=foo.bar", "--value=5678"); + assertEquals(0, result.getExitCode()); + assertTrue(result.getStdout().contains("Setting foo.bar to 5678")); + assertEquals("5678", config().getRawValue("foo.bar")); + } + + @Test + void deleteConfiguration() throws Exception { + Path propertiesFile = tempDir.resolve("src/main/resources/application.properties"); + Properties properties = new Properties(); + properties.load(propertiesFile.toUri().toURL().openStream()); + properties.put("foo.bar", "1234"); + properties.store(new FileOutputStream(propertiesFile.toFile()), ""); + + CliDriver.Result result = CliDriver.execute(tempDir, "config", "set", "--name=foo.bar"); + assertEquals(0, result.getExitCode()); + assertTrue(result.getStdout().contains("Removing foo.bar")); + assertNull(config().getConfigValue("foo.bar").getValue()); + } + + @Test + void createEncryptedConfiguration() throws Exception { + CliDriver.Result result = CliDriver.execute(tempDir, "config", "set", "--name=foo.bar", "--value=1234", "-k"); + assertEquals(0, result.getExitCode()); + assertTrue(result.getStdout().contains("Adding foo.bar")); + + SmallRyeConfig config = config(); + assertEquals("aes-gcm-nopadding", config.getConfigValue("foo.bar").getExtendedExpressionHandler()); + assertEquals("1234", config.getConfigValue("foo.bar").getValue()); + + String encryption = config.getRawValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key"); + assertNotNull(encryption); + + result = CliDriver.execute(tempDir, "config", "set", "--name=foo.baz", "--value=5678", "-k"); + assertEquals(0, result.getExitCode()); + assertTrue(result.getStdout().contains("Adding foo.baz")); + + config = config(); + + assertEquals("aes-gcm-nopadding", config.getConfigValue("foo.bar").getExtendedExpressionHandler()); + assertEquals("1234", config.getConfigValue("foo.bar").getValue()); + assertTrue(config.isPropertyPresent("foo.baz")); + + // TODO - radcortez - Requires update in SmallRye Config + //assertEquals("aes-gcm-nopadding", config.getConfigValue("foo.baz").getExtendedExpressionHandler()); + //assertEquals("5678", config.getConfigValue("foo.baz").getValue()); + } + + @Test + void updateEncryptedConfiguration() throws Exception { + Path propertiesFile = tempDir.resolve("src/main/resources/application.properties"); + Properties properties = new Properties(); + properties.load(propertiesFile.toUri().toURL().openStream()); + properties.put("foo.bar", "1234"); + properties.store(new FileOutputStream(propertiesFile.toFile()), ""); + + CliDriver.Result result = CliDriver.execute(tempDir, "config", "set", "--name=foo.bar", "-k"); + assertEquals(0, result.getExitCode()); + + SmallRyeConfig config = config(); + assertEquals("aes-gcm-nopadding", config.getConfigValue("foo.bar").getExtendedExpressionHandler()); + assertEquals("1234", config.getConfigValue("foo.bar").getValue()); + } + + private SmallRyeConfig config() throws Exception { + final PropertiesConfigSource propertiesConfigSource = new PropertiesConfigSource( + tempDir.resolve("src/main/resources/application.properties").toUri().toURL()); + System.out.println(propertiesConfigSource.getProperties()); + return new SmallRyeConfigBuilder() + .addDefaultInterceptors() + .addDiscoveredSecretKeysHandlers() + .withSources(propertiesConfigSource) + .withDefaultValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key", "default") + .build(); + } +} diff --git a/docs/src/main/asciidoc/config-secrets.adoc b/docs/src/main/asciidoc/config-secrets.adoc new file mode 100644 index 00000000000000..3f35d49bc2ddae --- /dev/null +++ b/docs/src/main/asciidoc/config-secrets.adoc @@ -0,0 +1,161 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc +//// += Secrets in Configuration +include::_attributes.adoc[] +:diataxis-type: core +:categories: security + +Use encrypted configuration values to protect sensitive passwords, secrets, tokens and keys. + +A secret configuration may be expressed as `${handler::value}`, where the `handler` is the name of a +`io.smallrye.config.SecretKeysHandler` to decode or decrypt the `value`. + +== Encrypt Configuration values + +To encrypt and later decrypt configuration values, add the following managed dependency: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.smallrye.config + smallrye-config-crypto + +---- + +Use the Quarkus CLI command to add a new encrypted value or encrypt an existent value in `application.properties`: + +[role="primary asciidoc-tabs-sync-cli"] +.CLI +**** +[source, bash] +---- +quarkus config set --encrypt --name=my.secret --value=1234 +---- + +_For more information about how to install the Quarkus CLI and use it, please refer to xref:cli-tooling.adoc[the Quarkus CLI guide]._ +**** + +The configuration property `my.secret` will be added to `application.properties` with the value `1234` encrypted and +encoded in *Base64* and an expression `${aes-gcm-nopadding::}`, with the required secret handler to decrypt the value. +If it doesn't exist, an encryption key is also generated and set into +`smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key`. + +[source,properties] +---- +my.secret=${aes-gcm-nopadding::DNNH8kR9i5EWBfuEwG1nhpkpov5MeFF9sjv-eqOgdHKf} + +smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key=ATa0tt4awR_GeQPpuLlFiA +---- + +NOTE: The default secret handler uses the `AES/GCM/NoPadding` algorithm and requires the expression +`${aes-gcm-nopadding::value}` to decrypt the `value`. + +== Read Encrypted Configuration + +Quarkus configuration system, will automatically decrypt the configuration value when looking up `my.secret`. + +The encryption key used to encrypt the value must be the same used to decrypt the value and set into +`smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key`. + +[source,java] +---- +class BusinessBean { + @Inject + SmallRyeConfig config; + + public void businessMethod() { + ConfigValue mySecret = config.getConfigValue("my.secret"); + mySecret.getValue(); <1> + } +} +---- +<1> Returns the value `1234`. + +== Store secrets in a Keystore + +While having encrypted values, is better than plain values, we would still like to avoid having these set up in +`application.properties`. + +Java KeyStore is used as a file-based `Vault`. Sensitive data can be imported to and securely stored in this `Vault` +as Java `SecretKey` values. To use the `KeyStore` `ConfigSource` add the following managed dependency: + +```xml + + io.smallrye.config + smallrye-config-source-keystore + +``` + +=== Create a KeyStore + +The following command creates a simple KeyStore: + +[source, bash] +---- +echo DNNH8kR9i5EWBfuEwG1nhpkpov5MeFF9sjv | keytool -importpass -alias my.secret -keystore properties -storepass arealpassword -storetype PKCS12 -v +---- + +The `-alias my.secret` option stores the configuration property name `my.secret` in the KeyStore with the value +`DNNH8kR9i5EWBfuEwG1nhpkpov5MeFF9sjv`. + +The `-storepass secret` is the password required to access the keystore. It can also be encrypted with the Quarkus CLI: + +[role="primary asciidoc-tabs-sync-cli"] +.CLI +**** +[source, bash] +---- +quarkus config encryptor -b --secret arealpassword --key ATa0tt4awR_GeQPpuLlFiA +---- +**** + +Generate the `KeyStore` with the encrypted `storepass` instead: + +[source, bash] +---- +echo DNNH8kR9i5EWBfuEwG1nhpkpov5MeFF9sjv | keytool -importpass -alias my.secret -keystore properties -storepass DO6SYBiXqMRjl5tcQWRKI-OLdNy2fEnoiFGRnGmPA8YOluQrKA -storetype PKCS12 -v +---- + +We also need to safely store the encryption key. You shouldn't store the key with the rest of the secrets, so we can +create another `KeyStore` for the key: + +[source, bash] +---- +echo ATa0tt4awR_GeQPpuLlFiA | keytool -importpass -alias smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key -keystore key -storepass anotherpassword -storetype PKCS12 -v +---- + +=== Use the KeyStore + +To use the newly created `KeyStore`s, add the following configuration to `application.properties`: + +[source,properties] +---- +smallrye.config.source.keystore."properties".path=properties <1> +smallrye.config.source.keystore."properties".password=${aes-gcm-nopadding::DO6SYBiXqMRjl5tcQWRKI-OLdNy2fEnoiFGRnGmPA8YOluQrKA} <2> +smallrye.config.source.keystore."properties".handler=aes-gcm-nopadding <3> + +smallrye.config.source.keystore."key".path=key <4> +smallrye.config.source.keystore."key".password=anotherpassword <5> +---- +<1> The path to the ´KeyStore` with properties secrets +<2> The `KeyStore` password to be able to extract the `KeyStore` secrets +<3> The `SecretKeyHandler` to decrypt the `KeyStore` secrets +<4> The path to the ´KeyStore` with encryption key. +<5> The `KeyStore` password to be able to extract the encryption key + +== Protect the KeyStore password + +You need to specify a `KeyStore` password in `application.properties` for Quarkus be able to extract secrets from the +keystore. This keystore password is a sensitive value, and therefore you should consider how to minimize a risk of +leaking it and how to protect it. + +One important thing you should be aware of is that leaking this password does not necessarily mean the actual secrets +stored in the keystore will also be leaked since an unauthorized person will also need to access the actual keystore +file. Restricting access to the keystore file to a limited number of roles and having Quarkus processes running in one +of these roles will make it harder for anyone outside the group access the keystore. The keystore password can be set +as an environment variable and this password should be periodically changed to limit a window during which an attacker +can try to get to the keystore.