diff options
author | Tor Brede Vekterli <vekterli@yahooinc.com> | 2022-11-11 12:58:46 +0100 |
---|---|---|
committer | Tor Brede Vekterli <vekterli@yahooinc.com> | 2022-11-11 13:17:28 +0100 |
commit | 7855a0d37241e87afe514ec25bf7a00289b556d7 (patch) | |
tree | c21ced3800c3f846cdd55bcf5c15355330a7eb42 /vespaclient-java | |
parent | f2ce165982217902ea84bcb12e7a10fe008bacd4 (diff) |
Add support for token resealing
Adds underlying support--and tooling--for resealing a token for
another recipient. This allows for delegating decryption to another
party without having to reveal the private key of the original
recipient (or having to send the raw underlying secret key over a
potentially insecure channel). Key ID can/should change as part of
this operation.
Diffstat (limited to 'vespaclient-java')
10 files changed, 232 insertions, 61 deletions
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 index 0498154aa91..26868207bd3 100644 --- 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 @@ -5,6 +5,7 @@ import com.yahoo.vespa.security.tool.crypto.ConvertBaseTool; 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 com.yahoo.vespa.security.tool.crypto.ResealTool; import com.yahoo.vespa.security.tool.crypto.TokenInfoTool; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.CommandLineParser; @@ -47,7 +48,7 @@ public class Main { private static final List<Tool> TOOLS = List.of( new KeygenTool(), new EncryptTool(), new DecryptTool(), new TokenInfoTool(), - new ConvertBaseTool()); + new ConvertBaseTool(), new ResealTool()); private static Optional<Tool> toolFromCliArgs(String[] args) { if (args.length == 0) { 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 index f1c166ba934..2cc724538d4 100644 --- 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 @@ -27,10 +27,10 @@ import java.util.Optional; */ 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"; + static final String OUTPUT_FILE_OPTION = "output-file"; + static final String PRIVATE_KEY_FILE_OPTION = "private-key-file"; + static final String EXPECTED_KEY_ID_OPTION = "expected-key-id"; + static final String TOKEN_OPTION = "token"; private static final List<Option> OPTIONS = List.of( Option.builder("o") @@ -41,17 +41,16 @@ public class DecryptTool implements Tool { "quotes) to write plaintext to STDOUT instead of a file.") .build(), Option.builder("k") - .longOpt(RECIPIENT_PRIVATE_KEY_FILE_OPTION) + .longOpt(PRIVATE_KEY_FILE_OPTION) .hasArg(true) .required(false) - .desc("Recipient private key file in Base58 encoded format") + .desc("Private key file in Base58 encoded format") .build(), - Option.builder("i") - .longOpt(KEY_ID_OPTION) + Option.builder("e") + .longOpt(EXPECTED_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.") + .desc("Expected key ID in token. If this is not provided, the key ID is not verified.") .build(), Option.builder("t") .longOpt(TOKEN_OPTION) @@ -85,21 +84,15 @@ public class DecryptTool implements Tool { throw new IllegalArgumentException("Expected exactly 1 file argument to decrypt"); } var inputArg = leftoverArgs[0]; - var maybeKeyId = Optional.ofNullable(arguments.hasOption(KEY_ID_OPTION) - ? arguments.getOptionValue(KEY_ID_OPTION) + var maybeKeyId = Optional.ofNullable(arguments.hasOption(EXPECTED_KEY_ID_OPTION) + ? arguments.getOptionValue(EXPECTED_KEY_ID_OPTION) : null); var outputArg = 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()) { - var myKeyId = KeyId.ofString(maybeKeyId.get()); - if (!myKeyId.equals(sealedSharedKey.keyId())) { - // Don't include raw key bytes array verbatim in message (may contain control chars etc). - throw new IllegalArgumentException("Key ID specified with --key-id does not match key ID " + - "used when generating the supplied token"); - } - } + ToolUtils.verifyExpectedKeyId(sealedSharedKey, maybeKeyId); + + var privKeyPath = Paths.get(CliUtils.optionOrThrow(arguments, PRIVATE_KEY_FILE_OPTION)); var privateKey = KeyUtils.fromBase58EncodedX25519PrivateKey(Files.readString(privKeyPath).strip()); var secretShared = SharedKeyGenerator.fromSealedKey(sealedSharedKey, privateKey); var cipher = SharedKeyGenerator.makeAesGcmDecryptionCipher(secretShared); @@ -108,7 +101,6 @@ public class DecryptTool implements Tool { var outStream = CliUtils.outputStreamToFileOrStream(outputArg, invocation.stdOut())) { CipherUtils.streamEncipher(inStream, outStream, cipher); } - } catch (IOException e) { throw new RuntimeException(e); } 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 index 886433f00f8..962b42f4c22 100644 --- 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 @@ -46,7 +46,7 @@ public class EncryptTool implements Tool { .longOpt(KEY_ID_OPTION) .hasArg(true) .required(false) - .desc("Numeric ID of recipient key") + .desc("ID of recipient key") .build()); @Override diff --git a/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/ResealTool.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/ResealTool.java new file mode 100644 index 00000000000..e9bc0ae8fee --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/ResealTool.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.crypto; + +import com.yahoo.security.KeyId; +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 resealing a token for another recipient. This allows for delegating + * decryption to another party without having to reveal the private key of the original + * recipient. + * + * @author vekterli + */ +public class ResealTool implements Tool { + + static final String PRIVATE_KEY_FILE_OPTION = "private-key-file"; + static final String EXPECTED_KEY_ID_OPTION = "expected-key-id"; + static final String RECIPIENT_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("k") + .longOpt(PRIVATE_KEY_FILE_OPTION) + .hasArg(true) + .required(false) + .desc("Private key file in Base58 encoded format") + .build(), + Option.builder("e") + .longOpt(EXPECTED_KEY_ID_OPTION) + .hasArg(true) + .required(false) + .desc("Expected key ID in token. If this is not provided, the key ID is not verified.") + .build(), + Option.builder("r") + .longOpt(RECIPIENT_PUBLIC_KEY_OPTION) + .hasArg(true) + .required(false) + .desc("Recipient X25519 public key in Base58 encoded format") + .build(), + Option.builder("i") + .longOpt(RECIPIENT_KEY_ID_OPTION) + .hasArg(true) + .required(false) + .desc("ID of recipient key") + .build()); + + @Override + public String name() { + return "reseal"; + } + + @Override + public ToolDescription description() { + return new ToolDescription( + "<token> <options>", + "Reseals the input token for another recipient, allowing that recipient to " + + "decrypt the file that the input token was originally created for.\n" + + "Prints new token to STDOUT.", + "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 token argument to re-seal"); + } + var tokenString = leftoverArgs[0]; + var maybeKeyId = Optional.ofNullable(arguments.hasOption(EXPECTED_KEY_ID_OPTION) + ? arguments.getOptionValue(EXPECTED_KEY_ID_OPTION) + : null); + var sealedSharedKey = SealedSharedKey.fromTokenString(tokenString.strip()); + ToolUtils.verifyExpectedKeyId(sealedSharedKey, maybeKeyId); + + var recipientPubKey = KeyUtils.fromBase58EncodedX25519PublicKey(CliUtils.optionOrThrow(arguments, RECIPIENT_PUBLIC_KEY_OPTION).strip()); + var recipientKeyId = KeyId.ofString(CliUtils.optionOrThrow(arguments, RECIPIENT_KEY_ID_OPTION)); + var privKeyPath = Paths.get(CliUtils.optionOrThrow(arguments, PRIVATE_KEY_FILE_OPTION)); + var privateKey = KeyUtils.fromBase58EncodedX25519PrivateKey(Files.readString(privKeyPath).strip()); + var secretShared = SharedKeyGenerator.fromSealedKey(sealedSharedKey, privateKey); + var resealedShared = SharedKeyGenerator.reseal(secretShared, recipientPubKey, recipientKeyId); + + invocation.stdOut().println(resealedShared.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/ToolUtils.java b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/ToolUtils.java new file mode 100644 index 00000000000..32e9c6679f6 --- /dev/null +++ b/vespaclient-java/src/main/java/com/yahoo/vespa/security/tool/crypto/ToolUtils.java @@ -0,0 +1,25 @@ +// 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.KeyId; +import com.yahoo.security.SealedSharedKey; + +import java.util.Optional; + +/** + * @author vekterli + */ +public class ToolUtils { + + static void verifyExpectedKeyId(SealedSharedKey sealedSharedKey, Optional<String> maybeKeyId) { + if (maybeKeyId.isPresent()) { + var myKeyId = KeyId.ofString(maybeKeyId.get()); + if (!myKeyId.equals(sealedSharedKey.keyId())) { + // Don't include raw key bytes array verbatim in message (may contain control chars etc.) + throw new IllegalArgumentException("Key ID specified with --expected-key-id does not match key ID " + + "used when generating the supplied token"); + } + } + } + +} 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 index d4992e89802..42b747df678 100644 --- 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 @@ -1,6 +1,10 @@ // 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.security.KeyId; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SealedSharedKey; +import com.yahoo.security.SharedKeyGenerator; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -16,6 +20,7 @@ import java.nio.file.attribute.PosixFilePermissions; import java.util.List; import java.util.Map; +import static com.yahoo.security.ArrayUtils.hex; import static com.yahoo.security.ArrayUtils.toUtf8Bytes; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -27,7 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ public class CryptoToolsTest { - private static record ProcessOutput(int exitCode, String stdOut, String stdErr) {} + private record ProcessOutput(int exitCode, String stdOut, String stdErr) {} private static final byte[] EMPTY_BYTES = new byte[0]; @@ -88,6 +93,11 @@ public class CryptoToolsTest { } @Test + void reseal_help_printed_if_help_option_given_to_subtool() throws IOException { + verifyStdoutMatchesFile(List.of("reseal", "--help"), "expected-reseal-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. @@ -174,6 +184,7 @@ public class CryptoToolsTest { private static final String TEST_TOKEN = "OntP9gRVAjXeZIr4zkYqRJFcnA993v7ZEE7VbcNs1NcR3HdE7Mp" + "wlwi3r3anF1kVa5fn7O1CyeHQpBWpdayUTKkrtyFepG6WJrZdE"; private static final String TEST_TOKEN_KEY_ID = "my key ID"; + private static final String TEST_TOKEN_SECRET = "1b33b4dcd6a94e5a4a1ee6d208197d01"; @Test void encrypt_fails_with_error_message_if_no_input_file_is_given() throws IOException { @@ -200,10 +211,10 @@ public class CryptoToolsTest { Files.writeString(privKeyFile, TEST_PRIV_KEY); verifyStderrEquals(List.of("decrypt", - "--output-file", "foo", - "--recipient-private-key-file", absPathOf(privKeyFile), - "--token", TEST_TOKEN, - "--key-id", TEST_TOKEN_KEY_ID), + "--output-file", "foo", + "--private-key-file", absPathOf(privKeyFile), + "--token", TEST_TOKEN, + "--expected-key-id", TEST_TOKEN_KEY_ID), "Invalid command line arguments: Expected exactly 1 file argument to decrypt\n"); } @@ -214,10 +225,10 @@ public class CryptoToolsTest { verifyStderrEquals(List.of("decrypt", "no-such-file", - "--output-file", "foo", - "--recipient-private-key-file", absPathOf(privKeyFile), - "--token", TEST_TOKEN, - "--key-id", TEST_TOKEN_KEY_ID), + "--output-file", "foo", + "--private-key-file", absPathOf(privKeyFile), + "--token", TEST_TOKEN, + "--expected-key-id", TEST_TOKEN_KEY_ID), "Invalid command line arguments: Input file 'no-such-file' does not exist\n"); } @@ -231,11 +242,11 @@ public class CryptoToolsTest { verifyStderrEquals(List.of("decrypt", absPathOf(inputFile), - "--output-file", "foo", - "--recipient-private-key-file", absPathOf(privKeyFile), - "--token", TEST_TOKEN, - "--key-id", TEST_TOKEN_KEY_ID + "-wrong"), - "Invalid command line arguments: Key ID specified with --key-id does not " + + "--output-file", "foo", + "--private-key-file", absPathOf(privKeyFile), + "--token", TEST_TOKEN, + "--expected-key-id", TEST_TOKEN_KEY_ID + "-wrong"), + "Invalid command line arguments: Key ID specified with --expected-key-id does not " + "match key ID used when generating the supplied token\n"); } @@ -271,6 +282,32 @@ public class CryptoToolsTest { } @Test + void can_reseal_a_token_to_another_recipient() throws IOException { + String recipientPrivKeyStr = "GdgfBZzPDqrCVs5f1xaYJpXVGwJzgdTAF1NNWiDk16YZ"; + String recipientPubKeyStr = "AiUirFvFuLJ6s71QBNxiRcctB4umzM6r2roP4Rf8WDKM"; + + Path privKeyFile = pathInTemp("my-priv.txt"); + Files.writeString(privKeyFile, TEST_PRIV_KEY); + + var procOut = runMain(List.of( + "reseal", + TEST_TOKEN, + "--private-key-file", absPathOf(privKeyFile), + "--recipient-public-key", recipientPubKeyStr, + "--expected-key-id", TEST_TOKEN_KEY_ID, + "--key-id", "some-recipient-key-id")); + assertEquals(0, procOut.exitCode()); + assertEquals("", procOut.stdErr()); + var resealedToken = procOut.stdOut().strip(); + + // Verify that the resealed token wraps the same secret as the original one + var recipientPrivKey = KeyUtils.fromBase58EncodedX25519PrivateKey(recipientPrivKeyStr); + var recvShared = SharedKeyGenerator.fromSealedKey(SealedSharedKey.fromTokenString(resealedToken), recipientPrivKey); + assertEquals(KeyId.ofString("some-recipient-key-id"), recvShared.sealedSharedKey().keyId()); + assertEquals(TEST_TOKEN_SECRET, hex(recvShared.secretKey().getEncoded())); + } + + @Test void can_end_to_end_keygen_encrypt_and_decrypt_via_files() throws IOException { String greatSecret = "Dogs can't look up"; @@ -310,10 +347,10 @@ public class CryptoToolsTest { procOut = runMain(List.of( "decrypt", absPathOf(encryptedPath), - "--output-file", absPathOf(decryptedPath), - "--recipient-private-key-file", absPathOf(privPath), - "--key-id", "1234", - "--token", token + "--output-file", absPathOf(decryptedPath), + "--private-key-file", absPathOf(privPath), + "--expected-key-id", "1234", + "--token", token )); assertEquals(0, procOut.exitCode()); assertEquals("", procOut.stdOut()); @@ -359,10 +396,10 @@ public class CryptoToolsTest { procOut = runMain(List.of( "decrypt", "-", // Decrypt stdin - "--output-file", "-", // Plaintext to stdout - "--recipient-private-key-file", absPathOf(privPath), - "--key-id", "1234", - "--token", token + "--output-file", "-", // Plaintext to stdout + "--private-key-file", absPathOf(privPath), + "--expected-key-id", "1234", + "--token", token ), Files.readAllBytes(encryptedPath)); assertEquals(0, procOut.exitCode()); diff --git a/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt b/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt index ddf91c779e2..291fc3f4b96 100644 --- a/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt +++ b/vespaclient-java/src/test/resources/expected-decrypt-help-output.txt @@ -5,18 +5,14 @@ given private key. To decrypt the contents of STDIN, specify an input file of '-' (without the quotes). - -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 in - Base58 encoded format - -o,--output-file <arg> Output file for decrypted - plaintext. Specify '-' (without - the quotes) to write plaintext to - STDOUT instead of a file. - -t,--token <arg> Token generated when the input - file was encrypted + -e,--expected-key-id <arg> Expected key ID in token. If this is not + provided, the key ID is not verified. + -h,--help Show help + -k,--private-key-file <arg> Private key file in Base58 encoded format + -o,--output-file <arg> Output file for decrypted plaintext. + Specify '-' (without the quotes) to write + plaintext to STDOUT instead of a file. + -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 index beddc69855b..46185b29986 100644 --- a/vespaclient-java/src/test/resources/expected-encrypt-help-output.txt +++ b/vespaclient-java/src/test/resources/expected-encrypt-help-output.txt @@ -7,7 +7,7 @@ kept secret. To encrypt the contents of STDIN, specify an input file of '-' (without the quotes). -h,--help Show help - -i,--key-id <arg> Numeric ID of recipient key + -i,--key-id <arg> 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 Base58 diff --git a/vespaclient-java/src/test/resources/expected-help-output.txt b/vespaclient-java/src/test/resources/expected-help-output.txt index 8cea1366973..e3183f54a7c 100644 --- a/vespaclient-java/src/test/resources/expected-help-output.txt +++ b/vespaclient-java/src/test/resources/expected-help-output.txt @@ -1,4 +1,5 @@ usage: vespa-security <tool> [TOOL OPTIONS] -Where <tool> is one of: keygen, encrypt, decrypt, token-info, convert-base +Where <tool> is one of: keygen, encrypt, decrypt, token-info, +convert-base, reseal -h,--help Show help Invoke vespa-security <tool> --help for tool-specific help diff --git a/vespaclient-java/src/test/resources/expected-reseal-help-output.txt b/vespaclient-java/src/test/resources/expected-reseal-help-output.txt new file mode 100644 index 00000000000..dcfea6c28bc --- /dev/null +++ b/vespaclient-java/src/test/resources/expected-reseal-help-output.txt @@ -0,0 +1,15 @@ +usage: vespa-security reseal <token> <options> +Reseals the input token for another recipient, allowing that recipient to +decrypt the file that the input token was originally created for. +Prints new token to STDOUT. + -e,--expected-key-id <arg> Expected key ID in token. If this is + not provided, the key ID is not + verified. + -h,--help Show help + -i,--key-id <arg> ID of recipient key + -k,--private-key-file <arg> Private key file in Base58 encoded + format + -r,--recipient-public-key <arg> Recipient X25519 public key in Base58 + encoded format +Note: this is a BETA tool version; its interface may be changed at any +time |