From 8b6dfc6c8586a12fa6469d0b5cb975af14f052b5 Mon Sep 17 00:00:00 2001 From: Roberto Cortez Date: Mon, 3 Jul 2023 17:18:19 +0100 Subject: [PATCH] Add CLI command for Config --- .../src/main/java/io/quarkus/cli/Config.java | 29 ++++++ .../main/java/io/quarkus/cli/QuarkusCli.java | 10 +- .../quarkus/cli/config/BaseConfigCommand.java | 32 +++++++ .../java/io/quarkus/cli/config/Encryptor.java | 94 ++++++++++++++++++ .../java/io/quarkus/cli/config/SetConfig.java | 95 +++++++++++++++++++ .../io/quarkus/cli/config/EncryptorTest.java | 21 ++++ 6 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 devtools/cli/src/main/java/io/quarkus/cli/Config.java create mode 100644 devtools/cli/src/main/java/io/quarkus/cli/config/BaseConfigCommand.java create mode 100644 devtools/cli/src/main/java/io/quarkus/cli/config/Encryptor.java create mode 100644 devtools/cli/src/main/java/io/quarkus/cli/config/SetConfig.java create mode 100644 devtools/cli/src/test/java/io/quarkus/cli/config/EncryptorTest.java 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..ca98aa4a227e01 --- /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..5474fb0c961b1b --- /dev/null +++ b/devtools/cli/src/main/java/io/quarkus/cli/config/SetConfig.java @@ -0,0 +1,95 @@ +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"); + args.add("--secret=" + value); + ConfigValue encryptionKey = findEncryptionKey(lines); + if (encryptionKey.getValue() != null) { + 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 ConfigValue findEncryptionKey(List lines) { + ConfigValue encryptionKey = ConfigValue.builder() + .withName("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key").build(); + for (int i = 0; i < lines.size(); i++) { + final String line = lines.get(i); + if (line.startsWith(encryptionKey.getName() + "=")) { + return encryptionKey.withValue(line.substring(66)).withLineNumber(i); + } + } + return encryptionKey; + } +} 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..fb68d362886557 --- /dev/null +++ b/devtools/cli/src/test/java/io/quarkus/cli/config/EncryptorTest.java @@ -0,0 +1,21 @@ +package io.quarkus.cli.config; + +import io.quarkus.cli.CliDriver; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class EncryptorTest { + static final Path testProjectRoot = Paths.get(System.getProperty("user.dir")).toAbsolutePath() + .resolve("target/test-project/"); + static final Path workspaceRoot = testProjectRoot.resolve("CliCreateExtensionTest"); + + @Test + void encrypt() throws Exception { + CliDriver.Result result = CliDriver.execute(workspaceRoot, "config", "encryptor", "--secret=12345678"); + assertEquals(0, result.getExitCode()); + } +}