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

Add CLI command for Config #34493

Merged
merged 1 commit into from
Jan 25, 2024
Merged
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
5 changes: 5 additions & 0 deletions devtools/cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.smallrye.config</groupId>
<artifactId>smallrye-config-crypto</artifactId>
<scope>test</scope>
</dependency>
<!-- This dependency is here to make sure the build order is correct-->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down
29 changes: 29 additions & 0 deletions devtools/cli/src/main/java/io/quarkus/cli/Config.java
Original file line number Diff line number Diff line change
@@ -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.Encrypt;
import io.quarkus.cli.config.SetConfig;
import picocli.CommandLine;
import picocli.CommandLine.Command;

@Command(name = "config", header = "Manage Quarkus configuration", subcommands = { SetConfig.class, Encrypt.class })
public class Config implements Callable<Integer> {
@CommandLine.Mixin(name = "output")
protected OutputOptionMixin output;

@CommandLine.Spec
protected CommandLine.Model.CommandSpec spec;

@CommandLine.Unmatched // avoids throwing errors for unmatched arguments
List<String> 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));
}
}
10 changes: 9 additions & 1 deletion devtools/cli/src/main/java/io/quarkus/cli/QuarkusCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
99 changes: 99 additions & 0 deletions devtools/cli/src/main/java/io/quarkus/cli/config/Encrypt.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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 = "encrypt", aliases = "enc", header = "Encrypt Secrets using AES/GCM/NoPadding algorithm by default")
public class Encrypt extends BaseConfigCommand implements Callable<Integer> {
@Option(required = true, names = { "-s", "--secret" }, description = "Secret")
String secret;

@Option(names = { "-k", "--key" }, description = "Encryption Key")
String encryptionKey;

@Option(names = { "-f", "--format" }, description = "Encryption Key Format (base64 / plain)", defaultValue = "base64")
KeyFormat encryptionKeyFormat;

@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 (encryptionKeyFormat.equals(KeyFormat.base64)) {
encryptionKey = encodeToString(encryptionKey.getBytes());
}
}

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;
}

public enum KeyFormat {
base64,
plain
}
}
102 changes: 102 additions & 0 deletions devtools/cli/src/main/java/io/quarkus/cli/config/SetConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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<Integer> {
@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<String> lines = Files.readAllLines(properties);

if (encrypt) {
Encrypt encrypt = new Encrypt();
List<String> 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("--key=" + encryptionKey.getValue());
}

int execute = new CommandLine(encrypt).execute(args.toArray(new String[] {}));
if (execute < 0) {
System.exit(execute);
}
value = "${aes-gcm-nopadding::" + encrypt.getEncryptedSecret() + "}";
if (encryptionKey.getValue() == null) {
lines.add(encryptionKey.getName() + "=" + encrypt.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<String> 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;
}
}
78 changes: 78 additions & 0 deletions devtools/cli/src/test/java/io/quarkus/cli/config/EncryptTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package io.quarkus.cli.config;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.nio.file.Path;
import java.util.Scanner;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import io.quarkus.cli.CliDriver;
import io.smallrye.config.SmallRyeConfig;
import io.smallrye.config.SmallRyeConfigBuilder;

class EncryptTest {
@TempDir
Path tempDir;

@Test
void encrypt() throws Exception {
CliDriver.Result result = CliDriver.execute(tempDir, "config", "encrypt", "--secret=12345678");
Scanner scanner = new Scanner(result.getStdout());
String secret = scanner.nextLine().split(": ")[1];
String encryptionKey = scanner.nextLine().split(": ")[1];

SmallRyeConfig config = new SmallRyeConfigBuilder()
.addDefaultInterceptors()
.addDiscoveredSecretKeysHandlers()
.withDefaultValue("my.secret", "${aes-gcm-nopadding::" + secret + "}")
.withDefaultValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key", encryptionKey)
.build();

assertEquals("12345678", config.getConfigValue("my.secret").getValue());
}

@Test
void keyPlain() throws Exception {
CliDriver.Result result = CliDriver.execute(tempDir, "config", "encrypt", "--secret=12345678", "-f=plain",
"--key=12345678");
Scanner scanner = new Scanner(result.getStdout());
String secret = scanner.nextLine().split(": ")[1];

SmallRyeConfig config = new SmallRyeConfigBuilder()
.addDefaultInterceptors()
.addDiscoveredSecretKeysHandlers()
.withDefaultValue("my.secret", "${aes-gcm-nopadding::" + secret + "}")
.withDefaultValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key", "12345678")
.build();

assertEquals("12345678", config.getConfigValue("my.secret").getValue());

config = new SmallRyeConfigBuilder()
.addDefaultInterceptors()
.addDiscoveredSecretKeysHandlers()
.withDefaultValue("my.secret", "${aes-gcm-nopadding::" + secret + "}")
.withDefaultValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key", "MTIzNDU2Nzg")
.withDefaultValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key-decode", "true")
.build();

assertEquals("12345678", config.getConfigValue("my.secret").getValue());
}

@Test
void keyBase64() throws Exception {
CliDriver.Result result = CliDriver.execute(tempDir, "config", "encrypt", "--secret=12345678", "--key=12345678");
Scanner scanner = new Scanner(result.getStdout());
String secret = scanner.nextLine().split(": ")[1];

SmallRyeConfig config = new SmallRyeConfigBuilder()
.addDefaultInterceptors()
.addDiscoveredSecretKeysHandlers()
.withDefaultValue("my.secret", "${aes-gcm-nopadding::" + secret + "}")
.withDefaultValue("smallrye.config.secret-handler.aes-gcm-nopadding.encryption-key", "MTIzNDU2Nzg")
.build();

assertEquals("12345678", config.getConfigValue("my.secret").getValue());
}
}
Loading
Loading