summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTor Brede Vekterli <vekterli@yahooinc.com>2022-10-27 15:36:40 +0200
committerGitHub <noreply@github.com>2022-10-27 15:36:40 +0200
commit660f1bbc4f7a387fe9473f85051b11d9fdf2ae12 (patch)
tree8178aae4c27b4ff2c4b7480527fd361bfb5f0bba
parent30a93b4910c1de1a10ebccfa545d03e1deac8056 (diff)
parenta1187ccd114037ffa033cb95165c94a5352849cc (diff)
Merge pull request #24618 from vespa-engine/vekterli/add-basic-crypto-tooling
Add basic tooling for (token-based) public key encryption and decryption
-rw-r--r--security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java1
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java65
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java19
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java104
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java30
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java19
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java18
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java37
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java111
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java93
-rw-r--r--vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java105
-rw-r--r--vespaclient-java/src/test/java/com/yahoo/vespa/security/tool/CryptoToolsTest.java296
-rw-r--r--vespaclient-java/src/test/resources/expected-decrypt-help-output.txt16
-rw-r--r--vespaclient-java/src/test/resources/expected-encrypt-help-output.txt13
-rw-r--r--vespaclient-java/src/test/resources/expected-help-output.txt4
-rw-r--r--vespaclient-java/src/test/resources/expected-keygen-help-output.txt13
16 files changed, 944 insertions, 0 deletions
diff --git a/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java
index 57c89c89665..3a1952da49d 100644
--- a/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java
+++ b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java
@@ -52,6 +52,7 @@ public class SharedKeyTest {
var theirSealed = SealedSharedKey.fromTokenString(publicToken);
var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverPrivate);
+ assertEquals(1, theirSealed.keyId());
assertEquals(expectedSharedSecret, hex(theirShared.secretKey().getEncoded()));
}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java
new file mode 100644
index 00000000000..7560c5f3b4c
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliOptions.java
@@ -0,0 +1,65 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+
+import java.io.PrintStream;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author vekterli
+ * @author bjorncs
+ */
+class CliOptions {
+
+ private static final Option HELP_OPTION = Option.builder("h")
+ .longOpt("help")
+ .hasArg(false)
+ .required(false)
+ .desc("Show help")
+ .build();
+
+ static Options withHelpOption(List<Option> options) {
+ var optionsWithHelp = new Options();
+ options.forEach(optionsWithHelp::addOption);
+ optionsWithHelp.addOption(HELP_OPTION);
+ return optionsWithHelp;
+ }
+
+ static void printTopLevelHelp(PrintStream out, List<Tool> tools) {
+ var formatter = new HelpFormatter();
+ var writer = new PrintWriter(out);
+ formatter.printHelp(
+ writer,
+ formatter.getWidth(),
+ "vespa-security <tool> [TOOL OPTIONS]",
+ "Where <tool> is one of: %s".formatted(tools.stream().map(Tool::name).collect(Collectors.joining(", "))),
+ withHelpOption(List.of()),
+ formatter.getLeftPadding(),
+ formatter.getDescPadding(),
+ "Invoke vespa-security <tool> --help for tool-specific help");
+ writer.flush();
+ }
+
+ static void printToolSpecificHelp(PrintStream out, String toolName,
+ ToolDescription toolDesc,
+ Options optionsWithHelp) {
+ var formatter = new HelpFormatter();
+ var writer = new PrintWriter(out);
+ formatter.printHelp(
+ writer,
+ formatter.getWidth(),
+ "vespa-security %s %s".formatted(toolName, toolDesc.helpArgSuffix()),
+ toolDesc.helpHeader(),
+ optionsWithHelp,
+ formatter.getLeftPadding(),
+ formatter.getDescPadding(),
+ toolDesc.helpFooter());
+ writer.flush();
+ }
+}
+
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java
new file mode 100644
index 00000000000..e9b348ab2a2
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/CliUtils.java
@@ -0,0 +1,19 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+import org.apache.commons.cli.CommandLine;
+
+/**
+ * @author vekterli
+ */
+public class CliUtils {
+
+ public static String optionOrThrow(CommandLine arguments, String option) {
+ var value = arguments.getOptionValue(option);
+ if (value == null) {
+ throw new IllegalArgumentException("Required argument '--%s' must be provided".formatted(option));
+ }
+ return value;
+ }
+
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java
new file mode 100644
index 00000000000..4216ffb6ed4
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Main.java
@@ -0,0 +1,104 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+import com.yahoo.vespa.security.tool.crypto.DecryptTool;
+import com.yahoo.vespa.security.tool.crypto.EncryptTool;
+import com.yahoo.vespa.security.tool.crypto.KeygenTool;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.CommandLineParser;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+
+import java.io.PrintStream;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Primary application entry point for security utility tools. Handles tool selection,
+ * CLI argument parsing and exception printing.
+ *
+ * Based on previous vespa-security-env tool.
+ *
+ * @author vekterli
+ * @author bjorncs
+ */
+public class Main {
+
+ private final PrintStream stdOut;
+ private final PrintStream stdError;
+
+ Main(PrintStream stdOut, PrintStream stdError) {
+ this.stdOut = stdOut;
+ this.stdError = stdError;
+ }
+
+ public static void main(String[] args) {
+ var program = new Main(System.out, System.err);
+ int returnCode = program.execute(args, System.getenv());
+ System.exit(returnCode);
+ }
+
+ private static final List<Tool> TOOLS = List.of(
+ new KeygenTool(), new EncryptTool(), new DecryptTool());
+
+ private static Optional<Tool> toolFromCliArgs(String[] args) {
+ if (args.length == 0) {
+ return Optional.empty();
+ }
+ String toolName = args[0];
+ return TOOLS.stream().filter(t -> t.name().equals(toolName)).findFirst();
+ }
+
+ private static String[] withToolNameArgRemoved(String[] args) {
+ if (args.length == 0) {
+ throw new IllegalArgumentException("Argument array did not contain a tool name");
+ }
+ String[] truncatedArgs = new String[args.length - 1];
+ System.arraycopy(args, 1, truncatedArgs, 0, truncatedArgs.length);
+ return truncatedArgs;
+ }
+
+ private static CommandLine parseCliArguments(String[] cliArgs, Options options) throws ParseException {
+ CommandLineParser parser = new DefaultParser();
+ return parser.parse(options, cliArgs);
+ }
+
+ public int execute(String[] args, Map<String, String> envVars) {
+ boolean debugMode = envVars.containsKey("VESPA_DEBUG");
+ try {
+ var maybeTool = toolFromCliArgs(args);
+ if (maybeTool.isEmpty()) { // This also implicitly covers the top-level --help case.
+ CliOptions.printTopLevelHelp(stdOut, TOOLS);
+ return 0;
+ }
+ var tool = maybeTool.get();
+ var toolDesc = tool.description();
+ var cliOpts = CliOptions.withHelpOption(toolDesc.cliOptions());
+ String[] truncatedArgs = withToolNameArgRemoved(args);
+ var cmdLine = parseCliArguments(truncatedArgs, cliOpts);
+ if (cmdLine.hasOption("help")) {
+ CliOptions.printToolSpecificHelp(stdOut, tool.name(), toolDesc, cliOpts);
+ return 0;
+ }
+ var invocation = new ToolInvocation(cmdLine, envVars, stdOut, stdError, debugMode);
+ return tool.invoke(invocation);
+ } catch (ParseException e) {
+ return handleException("Failed to parse command line arguments: " + e.getMessage(), e, debugMode);
+ } catch (IllegalArgumentException e) {
+ return handleException("Invalid command line arguments: " + e.getMessage(), e, debugMode);
+ } catch (Exception e) {
+ return handleException("Got unhandled exception: " + e.getMessage(), e, debugMode);
+ }
+ }
+
+ private int handleException(String message, Exception exception, boolean debugMode) {
+ stdError.println(message);
+ if (debugMode) {
+ exception.printStackTrace(stdError);
+ }
+ return 1;
+ }
+
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java
new file mode 100644
index 00000000000..251b2d40e3c
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/Tool.java
@@ -0,0 +1,30 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+/**
+ * A named tool that can be invoked via the parent Main program.
+ *
+ * @author vekterli
+ */
+public interface Tool {
+
+ /**
+ * Name of the tool used verbatim on the command line.
+ */
+ String name();
+
+ /**
+ * Description used when "--help" is invoked for a particular tool
+ */
+ ToolDescription description();
+
+ /**
+ * Invokes the tool logic with a ToolInvocation that encapsulates the command line
+ * and input/ouput environment the tool was called in.
+ *
+ * @param invocation parameters and environment to be used by the tool
+ * @return exit code that will be returned by the main process
+ */
+ int invoke(ToolInvocation invocation);
+
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java
new file mode 100644
index 00000000000..168d8fba3ba
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolDescription.java
@@ -0,0 +1,19 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+import org.apache.commons.cli.Option;
+
+import java.util.List;
+
+/**
+ * Used by Tool subclasses to describe their options and calling semantics via
+ * the "--help" output from the Main program.
+ *
+ * @author vekterli
+ */
+public record ToolDescription(String helpArgSuffix,
+ String helpHeader,
+ String helpFooter,
+ List<Option> cliOptions) {
+
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java
new file mode 100644
index 00000000000..b7340ebb749
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/ToolInvocation.java
@@ -0,0 +1,18 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+import org.apache.commons.cli.CommandLine;
+
+import java.io.PrintStream;
+import java.util.Map;
+
+/**
+ * @author vekterli
+ */
+public record ToolInvocation(CommandLine arguments,
+ Map<String, String> envVars,
+ PrintStream stdOut,
+ PrintStream stdError,
+ boolean debugMode) {
+
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java
new file mode 100644
index 00000000000..e3954558026
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/CipherUtils.java
@@ -0,0 +1,37 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool.crypto;
+
+import javax.crypto.Cipher;
+import javax.crypto.CipherOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * @author vekterli
+ */
+public class CipherUtils {
+
+ /**
+ * Streams the contents of fromPath into toPath after being wrapped by the input cipher.
+ * Depending on the Cipher mode, this either encrypts a plaintext file into ciphertext,
+ * or decrypts a ciphertext file into plaintext.
+ *
+ * @param fromPath source file path to read from
+ * @param toPath destination file path to write to
+ * @param cipher a Cipher in either ENCRYPT or DECRYPT mode
+ * @throws IOException if any file operation fails
+ */
+ public static void streamEncipherFileContents(Path fromPath, Path toPath, Cipher cipher) throws IOException {
+ if (fromPath.equals(toPath)) {
+ throw new IllegalArgumentException("Can't use same file as both input and output for enciphering");
+ }
+ try (var inStream = Files.newInputStream(fromPath);
+ var outStream = Files.newOutputStream(toPath);
+ var cipherStream = new CipherOutputStream(outStream, cipher)) {
+ inStream.transferTo(cipherStream);
+ cipherStream.flush();
+ }
+ }
+
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java
new file mode 100644
index 00000000000..b307ab76da8
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/DecryptTool.java
@@ -0,0 +1,111 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool.crypto;
+
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.SealedSharedKey;
+import com.yahoo.security.SharedKeyGenerator;
+import com.yahoo.vespa.security.tool.CliUtils;
+import com.yahoo.vespa.security.tool.Tool;
+import com.yahoo.vespa.security.tool.ToolDescription;
+import com.yahoo.vespa.security.tool.ToolInvocation;
+import org.apache.commons.cli.Option;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Tooling for decrypting a file using a private key that corresponds to the public key used
+ * to originally encrypt the file.
+ *
+ * Uses the opaque token abstraction from {@link SharedKeyGenerator}.
+ *
+ * @author vekterli
+ */
+public class DecryptTool implements Tool {
+
+ static final String OUTPUT_FILE_OPTION = "output-file";
+ static final String RECIPIENT_PRIVATE_KEY_FILE_OPTION = "recipient-private-key-file";
+ static final String KEY_ID_OPTION = "key-id";
+ static final String TOKEN_OPTION = "token";
+
+ private static final List<Option> OPTIONS = List.of(
+ Option.builder("o")
+ .longOpt(OUTPUT_FILE_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Output file for decrypted plaintext")
+ .build(),
+ Option.builder("k")
+ .longOpt(RECIPIENT_PRIVATE_KEY_FILE_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Recipient private key file")
+ .build(),
+ Option.builder("i")
+ .longOpt(KEY_ID_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Numeric ID of recipient key. If this is not provided, " +
+ "the key ID stored as part of the token is not verified.")
+ .build(),
+ Option.builder("t")
+ .longOpt(TOKEN_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Token generated when the input file was encrypted")
+ .build());
+
+ @Override
+ public String name() {
+ return "decrypt";
+ }
+
+ @Override
+ public ToolDescription description() {
+ return new ToolDescription(
+ "<encrypted file> <options>",
+ "Decrypts a file using a provided token and a secret private key. The file must " +
+ "previously have been encrypted using the public key component of the given private key.",
+ "Note: this is a BETA tool version; its interface may be changed at any time",
+ OPTIONS);
+ }
+
+ @Override
+ public int invoke(ToolInvocation invocation) {
+ try {
+ var arguments = invocation.arguments();
+ var leftoverArgs = arguments.getArgs();
+ if (leftoverArgs.length != 1) {
+ throw new IllegalArgumentException("Expected exactly 1 file argument to decrypt");
+ }
+ var inputPath = Paths.get(leftoverArgs[0]);
+ if (!inputPath.toFile().exists()) {
+ throw new IllegalArgumentException("Cannot decrypt file '%s' as it does not exist".formatted(inputPath.toString()));
+ }
+ var maybeKeyId = Optional.ofNullable(arguments.hasOption(KEY_ID_OPTION)
+ ? Integer.parseInt(arguments.getOptionValue(KEY_ID_OPTION))
+ : null);
+ var outputPath = Paths.get(CliUtils.optionOrThrow(arguments, OUTPUT_FILE_OPTION));
+ var privKeyPath = Paths.get(CliUtils.optionOrThrow(arguments, RECIPIENT_PRIVATE_KEY_FILE_OPTION));
+ var tokenString = CliUtils.optionOrThrow(arguments, TOKEN_OPTION);
+ var sealedSharedKey = SealedSharedKey.fromTokenString(tokenString.strip());
+ if (maybeKeyId.isPresent() && (maybeKeyId.get() != sealedSharedKey.keyId())) {
+ throw new IllegalArgumentException(("Key ID specified with --key-id (%d) does not match key ID " +
+ "used when generating the supplied token (%d)")
+ .formatted(maybeKeyId.get(), sealedSharedKey.keyId()));
+ }
+ var privateKey = KeyUtils.fromBase64EncodedX25519PrivateKey(Files.readString(privKeyPath).strip());
+ var secretShared = SharedKeyGenerator.fromSealedKey(sealedSharedKey, privateKey);
+ var cipher = SharedKeyGenerator.makeAesGcmDecryptionCipher(secretShared);
+
+ CipherUtils.streamEncipherFileContents(inputPath, outputPath, cipher);
+
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return 0;
+ }
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java
new file mode 100644
index 00000000000..5d6cee2fabc
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/EncryptTool.java
@@ -0,0 +1,93 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool.crypto;
+
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.SharedKeyGenerator;
+import com.yahoo.vespa.security.tool.CliUtils;
+import com.yahoo.vespa.security.tool.Tool;
+import com.yahoo.vespa.security.tool.ToolDescription;
+import com.yahoo.vespa.security.tool.ToolInvocation;
+import org.apache.commons.cli.Option;
+
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.util.List;
+
+/**
+ * Tooling to encrypt a file using a public key, emitting a non-secret token that can be
+ * passed on to a recipient holding the corresponding private key.
+ *
+ * Uses the opaque token abstraction from {@link SharedKeyGenerator}.
+ *
+ * @author vekterli
+ */
+public class EncryptTool implements Tool {
+
+ static final String OUTPUT_FILE_OPTION = "output-file";
+ static final String KEY_ID_OPTION = "key-id";
+ static final String RECIPIENT_PUBLIC_KEY_OPTION = "recipient-public-key";
+
+ private static final List<Option> OPTIONS = List.of(
+ Option.builder("o")
+ .longOpt(OUTPUT_FILE_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Output file (will be truncated if it already exists)")
+ .build(),
+ Option.builder("r")
+ .longOpt(RECIPIENT_PUBLIC_KEY_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Recipient X25519 public key in Base64 encoded format")
+ .build(),
+ Option.builder("i")
+ .longOpt(KEY_ID_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Numeric ID of recipient key")
+ .build());
+
+ @Override
+ public String name() {
+ return "encrypt";
+ }
+
+ @Override
+ public ToolDescription description() {
+ return new ToolDescription(
+ "<input file> <options>",
+ "One-way encrypts a file using the public key of a recipient. A public token is printed on " +
+ "standard out. The recipient can use this token to decrypt the file using their private key. " +
+ "The token does not have to be kept secret.",
+ "Note: this is a BETA tool version; its interface may be changed at any time",
+ OPTIONS);
+ }
+
+ @Override
+ public int invoke(ToolInvocation invocation) {
+ try {
+ var arguments = invocation.arguments();
+ var leftoverArgs = arguments.getArgs();
+ if (leftoverArgs.length != 1) {
+ throw new IllegalArgumentException("Expected exactly 1 file argument to encrypt");
+ }
+ var inputPath = Paths.get(leftoverArgs[0]);
+ if (!inputPath.toFile().exists()) {
+ throw new IllegalArgumentException("Cannot encrypt file '%s' as it does not exist".formatted(inputPath.toString()));
+ }
+ var outputPath = Paths.get(CliUtils.optionOrThrow(arguments, OUTPUT_FILE_OPTION));
+
+ var recipientPubKey = KeyUtils.fromBase64EncodedX25519PublicKey(CliUtils.optionOrThrow(arguments, RECIPIENT_PUBLIC_KEY_OPTION).strip());
+ int keyId = Integer.parseInt(CliUtils.optionOrThrow(arguments, KEY_ID_OPTION));
+ var shared = SharedKeyGenerator.generateForReceiverPublicKey(recipientPubKey, keyId);
+ var cipher = SharedKeyGenerator.makeAesGcmEncryptionCipher(shared);
+
+ CipherUtils.streamEncipherFileContents(inputPath, outputPath, cipher);
+
+ invocation.stdOut().println(shared.sealedSharedKey().toTokenString());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return 0;
+ }
+}
diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java
new file mode 100644
index 00000000000..a0b9cce710b
--- /dev/null
+++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/KeygenTool.java
@@ -0,0 +1,105 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool.crypto;
+
+import com.yahoo.security.KeyUtils;
+import com.yahoo.vespa.security.tool.CliUtils;
+import com.yahoo.vespa.security.tool.Tool;
+import com.yahoo.vespa.security.tool.ToolDescription;
+import com.yahoo.vespa.security.tool.ToolInvocation;
+import org.apache.commons.cli.Option;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.security.interfaces.XECPrivateKey;
+import java.security.interfaces.XECPublicKey;
+import java.util.List;
+
+/**
+ * Tooling to generate random X25519 key pairs.
+ *
+ * @author vekterli
+ */
+public class KeygenTool implements Tool {
+
+ static final String PRIVATE_OUT_FILE_OPTION = "private-out-file";
+ static final String PUBLIC_OUT_FILE_OPTION = "public-out-file";
+ static final String OVERWRITE_EXISTING_OPTION = "overwrite-existing";
+
+ private static final List<Option> OPTIONS = List.of(
+ Option.builder("k")
+ .longOpt(PRIVATE_OUT_FILE_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Output file for private (secret) key. Will be created with restrictive file permissions.")
+ .build(),
+ Option.builder("p")
+ .longOpt(PUBLIC_OUT_FILE_OPTION)
+ .hasArg(true)
+ .required(false)
+ .desc("Output file for public key")
+ .build(),
+ Option.builder()
+ .longOpt(OVERWRITE_EXISTING_OPTION)
+ .hasArg(false)
+ .required(false)
+ .desc("Overwrite existing key files instead of failing key generation if " +
+ "any files already exist. Use with great caution!")
+ .build());
+
+ @Override
+ public String name() {
+ return "keygen";
+ }
+
+ @Override
+ public ToolDescription description() {
+ return new ToolDescription(
+ "<options>",
+ "Generates an X25519 key pair and stores its private/public parts in " +
+ "separate files in Base64 encoded form.",
+ "Note: this is a BETA tool version; its interface may be changed at any time",
+ OPTIONS);
+ }
+
+ private void handleExistingFileIfAny(Path filePath, boolean allowOverwrite) throws IOException {
+ if (filePath.toFile().exists()) {
+ if (!allowOverwrite) {
+ throw new IllegalArgumentException(("Output file '%s' already exists. No keys written. " +
+ "If you want to overwrite existing files, specify --%s.")
+ .formatted(filePath.toAbsolutePath().toString(), OVERWRITE_EXISTING_OPTION));
+ } else {
+ // Explicitly delete the file since Files.createFile() will fail if it already exists.
+ Files.delete(filePath);
+ }
+ }
+ }
+
+ @Override
+ public int invoke(ToolInvocation invocation) {
+ try {
+ var arguments = invocation.arguments();
+ var privOutPath = Paths.get(CliUtils.optionOrThrow(arguments, PRIVATE_OUT_FILE_OPTION));
+ var pubOutPath = Paths.get(CliUtils.optionOrThrow(arguments, PUBLIC_OUT_FILE_OPTION));
+
+ boolean allowOverwrite = arguments.hasOption(OVERWRITE_EXISTING_OPTION);
+ handleExistingFileIfAny(privOutPath, allowOverwrite);
+ handleExistingFileIfAny(pubOutPath, allowOverwrite);
+
+ var keyPair = KeyUtils.generateX25519KeyPair();
+ var privKey = (XECPrivateKey) keyPair.getPrivate();
+ var pubKey = (XECPublicKey) keyPair.getPublic();
+
+ var privFilePerms = PosixFilePermissions.fromString("rw-------");
+ Files.createFile( privOutPath, PosixFilePermissions.asFileAttribute(privFilePerms));
+ Files.writeString(privOutPath, KeyUtils.toBase64EncodedX25519PrivateKey(privKey) + "\n");
+ Files.writeString(pubOutPath, KeyUtils.toBase64EncodedX25519PublicKey(pubKey) + "\n");
+
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return 0;
+ }
+}
diff --git a/vespaclient-java/src/test/java/com/yahoo/vespa/security/tool/CryptoToolsTest.java b/vespaclient-java/src/test/java/com/yahoo/vespa/security/tool/CryptoToolsTest.java
new file mode 100644
index 00000000000..bb8024544cb
--- /dev/null
+++ b/vespaclient-java/src/test/java/com/yahoo/vespa/security/tool/CryptoToolsTest.java
@@ -0,0 +1,296 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.security.tool;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * @author vekterli
+ */
+public class CryptoToolsTest {
+
+ private static record ProcessOutput(int exitCode, String stdOut, String stdErr) {}
+
+ @TempDir
+ public File tmpFolder;
+
+ private void verifyStdoutMatchesFile(List<String> args, String expectedFile) throws IOException {
+ var procOut = runMain(args, Map.of());
+ assertEquals(0, procOut.exitCode());
+ assertEquals(readTestResource(expectedFile), procOut.stdOut());
+ }
+
+ private void verifyStderrEquals(List<String> args, String expectedMessage) throws IOException {
+ var procOut = runMain(args, Map.of());
+ assertEquals(1, procOut.exitCode()); // Assume checking stderr is because of a failure.
+ assertEquals(expectedMessage, procOut.stdErr());
+ }
+
+ @Test
+ void top_level_help_page_printed_if_help_option_given() throws IOException {
+ verifyStdoutMatchesFile(List.of("--help"), "expected-help-output.txt");
+ }
+
+ @Test
+ void top_level_help_page_printed_if_no_option_given() throws IOException {
+ verifyStdoutMatchesFile(List.of(), "expected-help-output.txt");
+ }
+
+ @Test
+ void keygen_help_printed_if_help_option_given_to_subtool() throws IOException {
+ verifyStdoutMatchesFile(List.of("keygen", "--help"), "expected-keygen-help-output.txt");
+ }
+
+ @Test
+ void encrypt_help_printed_if_help_option_given_to_subtool() throws IOException {
+ verifyStdoutMatchesFile(List.of("encrypt", "--help"), "expected-encrypt-help-output.txt");
+ }
+
+ @Test
+ void decrypt_help_printed_if_help_option_given_to_subtool() throws IOException {
+ verifyStdoutMatchesFile(List.of("decrypt", "--help"), "expected-decrypt-help-output.txt");
+ }
+
+ @Test
+ void missing_required_parameter_prints_error_message() throws IOException {
+ // We don't test all possible input arguments to all tools, since it'd be too closely
+ // bound to the order in which the implementation checks for argument presence.
+ // This primarily verifies that IllegalArgumentExceptions thrown by a tool will be caught
+ // and printed to stderr as expected.
+ verifyStderrEquals(List.of("keygen"),
+ "Invalid command line arguments: Required argument '--private-out-file' must be provided\n");
+ verifyStderrEquals(List.of("keygen", "--private-out-file", "foo.txt"),
+ "Invalid command line arguments: Required argument '--public-out-file' must be provided\n");
+ }
+
+ // We don't want to casually overwrite key material if someone runs a command twice by accident.
+ @Test
+ void keygen_fails_by_default_if_output_file_exists() throws IOException {
+ Path privKeyFile = pathInTemp("priv.txt");
+ Path pubKeyFile = pathInTemp("pub.txt");
+ Files.writeString(privKeyFile, TEST_PRIV_KEY);
+
+ verifyStderrEquals(List.of("keygen",
+ "--private-out-file", absPathOf(privKeyFile),
+ "--public-out-file", absPathOf(pubKeyFile)),
+ ("Invalid command line arguments: Output file '%s' already exists. No keys written. " +
+ "If you want to overwrite existing files, specify --overwrite-existing.\n")
+ .formatted(absPathOf(privKeyFile)));
+
+ Files.delete(privKeyFile);
+ Files.writeString(pubKeyFile, TEST_PUB_KEY);
+
+ verifyStderrEquals(List.of("keygen",
+ "--private-out-file", absPathOf(privKeyFile),
+ "--public-out-file", absPathOf(pubKeyFile)),
+ ("Invalid command line arguments: Output file '%s' already exists. No keys written. " +
+ "If you want to overwrite existing files, specify --overwrite-existing.\n")
+ .formatted(absPathOf(pubKeyFile)));
+ }
+
+ // ... but we'll allow it if someone enables the foot-gun option.
+ @Test
+ void keygen_allowed_if_output_file_exists_and_explicit_overwrite_option_specified() throws IOException {
+ Path privKeyFile = pathInTemp("priv.txt");
+ Path pubKeyFile = pathInTemp("pub.txt");
+ Files.writeString(privKeyFile, TEST_PRIV_KEY);
+ Files.writeString(pubKeyFile, TEST_PUB_KEY);
+
+ var procOut = runMain(List.of("keygen",
+ "--private-out-file", absPathOf(privKeyFile),
+ "--public-out-file", absPathOf(pubKeyFile),
+ "--overwrite-existing"));
+ assertEquals(0, procOut.exitCode());
+
+ // Keys are random, so we don't know what they'll end up being. But the likelihood of them
+ // exactly matching the test keys is effectively and realistically zero.
+ assertNotEquals(TEST_PRIV_KEY, Files.readString(privKeyFile));
+ assertNotEquals(TEST_PUB_KEY, Files.readString(pubKeyFile));
+ }
+
+ @Test
+ void keygen_writes_private_key_with_user_only_rw_permissions() throws IOException {
+ Path privKeyFile = pathInTemp("priv.txt");
+ Path pubKeyFile = pathInTemp("pub.txt");
+
+ var procOut = runMain(List.of("keygen",
+ "--private-out-file", absPathOf(privKeyFile),
+ "--public-out-file", absPathOf(pubKeyFile)));
+ assertEquals(0, procOut.exitCode());
+ var privKeyPerms = Files.getPosixFilePermissions(privKeyFile);
+ var expectedPerms = PosixFilePermissions.fromString("rw-------");
+ assertEquals(expectedPerms, privKeyPerms);
+ }
+
+ private static final String TEST_PRIV_KEY = "4qGcntygFn_a3uqeBa1PbDlygQ-cpOuNznTPIz9ftWE";
+ private static final String TEST_PUB_KEY = "ROAH_S862tNMpbJ49lu1dPXFCPHFIXZK30pSrMZEmEg";
+ // Token created for the above public key (matching the above private key), using key id 1
+ private static final String TEST_TOKEN = "AQAAAQAgwyxd7bFNQB_2LdL3bw-xFlvrxXhs7WWNVCKZ4" +
+ "EFeNVtu42JMwM74bMN4E46v6mYcfQNPzcMGaP22Wl2cTnji0A";
+ private static final int TEST_TOKEN_KEY_ID = 1;
+
+ @Test
+ void encrypt_fails_with_error_message_if_no_input_file_is_given() throws IOException {
+ verifyStderrEquals(List.of("encrypt",
+ "--output-file", "foo",
+ "--recipient-public-key", TEST_PUB_KEY,
+ "--key-id", "1234"),
+ "Invalid command line arguments: Expected exactly 1 file argument to encrypt\n");
+ }
+
+ @Test
+ void encrypt_fails_with_error_message_if_input_file_does_not_exist() throws IOException {
+ verifyStderrEquals(List.of("encrypt",
+ "no-such-file",
+ "--output-file", "foo",
+ "--recipient-public-key", TEST_PUB_KEY,
+ "--key-id", "1234"),
+ "Invalid command line arguments: Cannot encrypt file 'no-such-file' as it does not exist\n");
+ }
+
+ @Test
+ void decrypt_fails_with_error_message_if_no_input_file_is_given() throws IOException {
+ Path privKeyFile = pathInTemp("priv.txt");
+ Files.writeString(privKeyFile, TEST_PRIV_KEY);
+
+ verifyStderrEquals(List.of("decrypt",
+ "--output-file", "foo",
+ "--recipient-private-key-file", absPathOf(privKeyFile),
+ "--token", TEST_TOKEN,
+ "--key-id", Integer.toString(TEST_TOKEN_KEY_ID)),
+ "Invalid command line arguments: Expected exactly 1 file argument to decrypt\n");
+ }
+
+ @Test
+ void decrypt_fails_with_error_message_if_input_file_does_not_exist() throws IOException {
+ Path privKeyFile = pathInTemp("priv.txt");
+ Files.writeString(privKeyFile, TEST_PRIV_KEY);
+
+ verifyStderrEquals(List.of("decrypt",
+ "no-such-file",
+ "--output-file", "foo",
+ "--recipient-private-key-file", absPathOf(privKeyFile),
+ "--token", TEST_TOKEN,
+ "--key-id", Integer.toString(TEST_TOKEN_KEY_ID)),
+ "Invalid command line arguments: Cannot decrypt file 'no-such-file' as it does not exist\n");
+ }
+
+ @Test
+ void decrypt_fails_with_error_message_if_expected_key_id_does_not_match_key_id_in_token() throws IOException {
+ Path privKeyFile = pathInTemp("priv.txt");
+ Files.writeString(privKeyFile, TEST_PRIV_KEY);
+
+ Path inputFile = pathInTemp("input.txt");
+ Files.writeString(inputFile, "dummy-not-actually-encrypted-data");
+
+ verifyStderrEquals(List.of("decrypt",
+ absPathOf(inputFile),
+ "--output-file", "foo",
+ "--recipient-private-key-file", absPathOf(privKeyFile),
+ "--token", TEST_TOKEN,
+ "--key-id", Integer.toString(TEST_TOKEN_KEY_ID + 1)),
+ "Invalid command line arguments: Key ID specified with --key-id (2) does not match " +
+ "key ID used when generating the supplied token (1)\n");
+ }
+
+ @Test
+ void can_end_to_end_keygen_encrypt_and_decrypt() throws IOException {
+ String greatSecret = "Dogs can't look up";
+
+ Path secretFile = pathInTemp("secret.txt");
+ Files.writeString(secretFile, greatSecret);
+
+ var privPath = pathInTemp("priv.txt");
+ var pubPath = pathInTemp("pub.txt");
+ var procOut = runMain(List.of(
+ "keygen",
+ "--private-out-file", absPathOf(privPath),
+ "--public-out-file", absPathOf(pubPath)));
+ assertEquals(0, procOut.exitCode());
+ assertEquals("", procOut.stdOut());
+ assertEquals("", procOut.stdErr());
+
+ assertTrue(privPath.toFile().exists());
+ assertTrue(pubPath.toFile().exists());
+
+ var encryptedPath = pathInTemp("encrypted.bin");
+ // TODO support (and test) public key via file
+ procOut = runMain(List.of(
+ "encrypt",
+ absPathOf(secretFile),
+ "--output-file", absPathOf(encryptedPath),
+ "--recipient-public-key", Files.readString(pubPath),
+ "--key-id", "1234"));
+ assertEquals(0, procOut.exitCode());
+ assertEquals("", procOut.stdErr());
+
+ var token = procOut.stdOut();
+ assertFalse(token.isBlank());
+
+ assertTrue(encryptedPath.toFile().exists());
+
+ var decryptedPath = pathInTemp("decrypted.txt");
+ procOut = runMain(List.of(
+ "decrypt",
+ absPathOf(encryptedPath),
+ "--output-file", absPathOf(decryptedPath),
+ "--recipient-private-key-file", absPathOf(privPath),
+ "--key-id", "1234",
+ "--token", token
+ ));
+ assertEquals(0, procOut.exitCode());
+ assertEquals("", procOut.stdOut());
+ assertEquals("", procOut.stdErr());
+
+ assertEquals(greatSecret, Files.readString(decryptedPath));
+ }
+
+ private ProcessOutput runMain(List<String> args) {
+ // Expect that this is used for running a command that is not supposed to fail. But if it does,
+ // include exception trace in stderr to make it easier to debug.
+ return runMain(args, Map.of("VESPA_DEBUG", "true"));
+ }
+
+ private ProcessOutput runMain(List<String> args, Map<String, String> env) {
+ var stdOutBytes = new ByteArrayOutputStream();
+ var stdErrBytes = new ByteArrayOutputStream();
+ var stdOut = new PrintStream(stdOutBytes);
+ var stdError = new PrintStream(stdErrBytes);
+
+ int exitCode = new Main(stdOut, stdError).execute(args.toArray(new String[0]), env);
+
+ stdOut.flush();
+ stdError.flush();
+
+ return new ProcessOutput(exitCode, stdOutBytes.toString(), stdErrBytes.toString());
+ }
+
+ private static String readTestResource(String fileName) throws IOException {
+ return Files.readString(Paths.get(CryptoToolsTest.class.getResource('/' + fileName).getFile()));
+ }
+
+ private Path pathInTemp(String fileName) {
+ return tmpFolder.toPath().resolve(fileName);
+ }
+
+ private static String absPathOf(Path path) {
+ return path.toAbsolutePath().toString();
+ }
+
+}
diff --git a/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt b/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt
new file mode 100644
index 00000000000..979133a562b
--- /dev/null
+++ b/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt
@@ -0,0 +1,16 @@
+usage: vespa-security decrypt <encrypted file> <options>
+Decrypts a file using a provided token and a secret private key. The file
+must previously have been encrypted using the public key component of the
+given private key.
+ -h,--help Show help
+ -i,--key-id <arg> Numeric ID of recipient key. If
+ this is not provided, the key ID
+ stored as part of the token is
+ not verified.
+ -k,--recipient-private-key-file <arg> Recipient private key file
+ -o,--output-file <arg> Output file for decrypted
+ plaintext
+ -t,--token <arg> Token generated when the input
+ file was encrypted
+Note: this is a BETA tool version; its interface may be changed at any
+time
diff --git a/vespaclient-java/src/test/resources/expected-encrypt-help-output.txt b/vespaclient-java/src/test/resources/expected-encrypt-help-output.txt
new file mode 100644
index 00000000000..848035f417e
--- /dev/null
+++ b/vespaclient-java/src/test/resources/expected-encrypt-help-output.txt
@@ -0,0 +1,13 @@
+usage: vespa-security encrypt <input file> <options>
+One-way encrypts a file using the public key of a recipient. A public
+token is printed on standard out. The recipient can use this token to
+decrypt the file using their private key. The token does not have to be
+kept secret.
+ -h,--help Show help
+ -i,--key-id <arg> Numeric ID of recipient key
+ -o,--output-file <arg> Output file (will be truncated if it
+ already exists)
+ -r,--recipient-public-key <arg> Recipient X25519 public key in Base64
+ encoded format
+Note: this is a BETA tool version; its interface may be changed at any
+time
diff --git a/vespaclient-java/src/test/resources/expected-help-output.txt b/vespaclient-java/src/test/resources/expected-help-output.txt
new file mode 100644
index 00000000000..45cf829c981
--- /dev/null
+++ b/vespaclient-java/src/test/resources/expected-help-output.txt
@@ -0,0 +1,4 @@
+usage: vespa-security <tool> [TOOL OPTIONS]
+Where <tool> is one of: keygen, encrypt, decrypt
+ -h,--help Show help
+Invoke vespa-security <tool> --help for tool-specific help
diff --git a/vespaclient-java/src/test/resources/expected-keygen-help-output.txt b/vespaclient-java/src/test/resources/expected-keygen-help-output.txt
new file mode 100644
index 00000000000..60629c4291f
--- /dev/null
+++ b/vespaclient-java/src/test/resources/expected-keygen-help-output.txt
@@ -0,0 +1,13 @@
+usage: vespa-security keygen <options>
+Generates an X25519 key pair and stores its private/public parts in
+separate files in Base64 encoded form.
+ -h,--help Show help
+ -k,--private-out-file <arg> Output file for private (secret) key. Will
+ be created with restrictive file
+ permissions.
+ --overwrite-existing Overwrite existing key files instead of
+ failing key generation if any files already
+ exist. Use with great caution!
+ -p,--public-out-file <arg> Output file for public key
+Note: this is a BETA tool version; its interface may be changed at any
+time