From 7e1377a28cb15eb42d2386757346f9f8688eb405 Mon Sep 17 00:00:00 2001 From: Paul Schaub Date: Wed, 12 Jul 2023 00:42:02 +0200 Subject: [PATCH] Initial implementation of 'change-key-password' command of SOP-07 --- .../main/java/sop/external/ExternalSOP.java | 7 ++ .../operation/ChangeKeyPasswordExternal.java | 57 +++++++++++ .../operation/ExternalRevokeKeyTest.java | 2 + .../src/main/java/sop/cli/picocli/SopCLI.java | 2 + .../commands/ChangeKeyPasswordCmd.java | 54 ++++++++++ .../test/java/sop/cli/picocli/SOPTest.java | 6 ++ sop-java/src/main/java/sop/SOP.java | 8 ++ .../java/sop/operation/ChangeKeyPassword.java | 83 ++++++++++++++++ .../operation/ChangeKeyPasswordTest.java | 98 +++++++++++++++++++ 9 files changed, 317 insertions(+) create mode 100644 external-sop/src/main/java/sop/external/operation/ChangeKeyPasswordExternal.java create mode 100644 sop-java-picocli/src/main/java/sop/cli/picocli/commands/ChangeKeyPasswordCmd.java create mode 100644 sop-java/src/main/java/sop/operation/ChangeKeyPassword.java create mode 100644 sop-java/src/testFixtures/java/sop/testsuite/operation/ChangeKeyPasswordTest.java diff --git a/external-sop/src/main/java/sop/external/ExternalSOP.java b/external-sop/src/main/java/sop/external/ExternalSOP.java index c3041ae..376e54c 100644 --- a/external-sop/src/main/java/sop/external/ExternalSOP.java +++ b/external-sop/src/main/java/sop/external/ExternalSOP.java @@ -8,6 +8,7 @@ import sop.Ready; import sop.SOP; import sop.exception.SOPGPException; import sop.external.operation.ArmorExternal; +import sop.external.operation.ChangeKeyPasswordExternal; import sop.external.operation.DearmorExternal; import sop.external.operation.DecryptExternal; import sop.external.operation.DetachedSignExternal; @@ -22,6 +23,7 @@ import sop.external.operation.ListProfilesExternal; import sop.external.operation.RevokeKeyExternal; import sop.external.operation.VersionExternal; import sop.operation.Armor; +import sop.operation.ChangeKeyPassword; import sop.operation.Dearmor; import sop.operation.Decrypt; import sop.operation.DetachedSign; @@ -168,6 +170,11 @@ public class ExternalSOP implements SOP { return new RevokeKeyExternal(binaryName, properties); } + @Override + public ChangeKeyPassword changeKeyPassword() { + return new ChangeKeyPasswordExternal(binaryName, properties); + } + @Override public Dearmor dearmor() { return new DearmorExternal(binaryName, properties); diff --git a/external-sop/src/main/java/sop/external/operation/ChangeKeyPasswordExternal.java b/external-sop/src/main/java/sop/external/operation/ChangeKeyPasswordExternal.java new file mode 100644 index 0000000..210152f --- /dev/null +++ b/external-sop/src/main/java/sop/external/operation/ChangeKeyPasswordExternal.java @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2023 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.external.operation; + +import sop.Ready; +import sop.exception.SOPGPException; +import sop.external.ExternalSOP; +import sop.operation.ChangeKeyPassword; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +public class ChangeKeyPasswordExternal implements ChangeKeyPassword { + private final List commandList = new ArrayList<>(); + private final List envList; + + private int keyPasswordCounter = 0; + + public ChangeKeyPasswordExternal(String binary, Properties environment) { + this.commandList.add(binary); + this.commandList.add("decrypt"); + this.envList = ExternalSOP.propertiesToEnv(environment); + } + + @Override + public ChangeKeyPassword noArmor() { + this.commandList.add("--no-armor"); + return this; + } + + @Override + public ChangeKeyPassword oldKeyPassphrase(String oldPassphrase) { + this.commandList.add("--old-key-password=@ENV:KEY_PASSWORD_" + keyPasswordCounter); + this.envList.add("KEY_PASSWORD_" + keyPasswordCounter + "=" + oldPassphrase); + keyPasswordCounter++; + + return this; + } + + @Override + public ChangeKeyPassword newKeyPassphrase(String newPassphrase) { + this.commandList.add("--new-key-password=@ENV:KEY_PASSWORD_" + keyPasswordCounter); + this.envList.add("KEY_PASSWORD_" + keyPasswordCounter + "=" + newPassphrase); + keyPasswordCounter++; + + return this; + } + + @Override + public Ready keys(InputStream inputStream) throws SOPGPException.KeyIsProtected, SOPGPException.BadData { + return ExternalSOP.executeTransformingOperation(Runtime.getRuntime(), commandList, envList, inputStream); + } +} diff --git a/external-sop/src/test/java/sop/testsuite/external/operation/ExternalRevokeKeyTest.java b/external-sop/src/test/java/sop/testsuite/external/operation/ExternalRevokeKeyTest.java index 2318abd..e2efe03 100644 --- a/external-sop/src/test/java/sop/testsuite/external/operation/ExternalRevokeKeyTest.java +++ b/external-sop/src/test/java/sop/testsuite/external/operation/ExternalRevokeKeyTest.java @@ -4,8 +4,10 @@ package sop.testsuite.external.operation; +import org.junit.jupiter.api.condition.EnabledIf; import sop.testsuite.operation.RevokeKeyTest; +@EnabledIf("sop.testsuite.operation.AbstractSOPTest#hasBackends") public class ExternalRevokeKeyTest extends RevokeKeyTest { } diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/SopCLI.java b/sop-java-picocli/src/main/java/sop/cli/picocli/SopCLI.java index 2f98061..e80c887 100644 --- a/sop-java-picocli/src/main/java/sop/cli/picocli/SopCLI.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/SopCLI.java @@ -8,6 +8,7 @@ import picocli.AutoComplete; import picocli.CommandLine; import sop.SOP; import sop.cli.picocli.commands.ArmorCmd; +import sop.cli.picocli.commands.ChangeKeyPasswordCmd; import sop.cli.picocli.commands.DearmorCmd; import sop.cli.picocli.commands.DecryptCmd; import sop.cli.picocli.commands.InlineDetachCmd; @@ -45,6 +46,7 @@ import java.util.ResourceBundle; InlineVerifyCmd.class, ListProfilesCmd.class, RevokeKeyCmd.class, + ChangeKeyPasswordCmd.class, VersionCmd.class, AutoComplete.GenerateCompletion.class } diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ChangeKeyPasswordCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ChangeKeyPasswordCmd.java new file mode 100644 index 0000000..2f44ec7 --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ChangeKeyPasswordCmd.java @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.cli.picocli.commands; + +import picocli.CommandLine; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.ChangeKeyPassword; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@CommandLine.Command(name = "change-key-password", + resourceBundle = "msg_dearmor", // TODO + exitCodeOnInvalidInput = SOPGPException.UnsupportedOption.EXIT_CODE) +public class ChangeKeyPasswordCmd extends AbstractSopCmd { + + @CommandLine.Option(names = "--no-armor", + negatable = true) + boolean armor = true; + + @CommandLine.Option(names = {"--old-key-password"}) + List oldKeyPasswords = new ArrayList<>(); + + @CommandLine.Option(names = {"--new-key-password"}, arity = "0..1") + String newKeyPassword = null; + + @Override + public void run() { + ChangeKeyPassword changeKeyPassword = throwIfUnsupportedSubcommand( + SopCLI.getSop().changeKeyPassword(), "change-key-password"); + + if (!armor) { + changeKeyPassword.noArmor(); + } + + for (String oldKeyPassword : oldKeyPasswords) { + changeKeyPassword.oldKeyPassphrase(oldKeyPassword); + } + + if (newKeyPassword != null) { + changeKeyPassword.newKeyPassphrase(newKeyPassword); + } + + try { + changeKeyPassword.keys(System.in).writeTo(System.out); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/SOPTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/SOPTest.java index 67a951c..47b6123 100644 --- a/sop-java-picocli/src/test/java/sop/cli/picocli/SOPTest.java +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/SOPTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import sop.SOP; import sop.exception.SOPGPException; import sop.operation.Armor; +import sop.operation.ChangeKeyPassword; import sop.operation.Dearmor; import sop.operation.Decrypt; import sop.operation.InlineDetach; @@ -107,6 +108,11 @@ public class SOPTest { return null; } + @Override + public ChangeKeyPassword changeKeyPassword() { + return null; + } + @Override public InlineDetach inlineDetach() { return null; diff --git a/sop-java/src/main/java/sop/SOP.java b/sop-java/src/main/java/sop/SOP.java index 460e6c1..1200e21 100644 --- a/sop-java/src/main/java/sop/SOP.java +++ b/sop-java/src/main/java/sop/SOP.java @@ -5,6 +5,7 @@ package sop; import sop.operation.Armor; +import sop.operation.ChangeKeyPassword; import sop.operation.Dearmor; import sop.operation.Decrypt; import sop.operation.Encrypt; @@ -166,4 +167,11 @@ public interface SOP { * @return builder instance */ RevokeKey revokeKey(); + + /** + * Update a key's password. + * + * @return builder instance + */ + ChangeKeyPassword changeKeyPassword(); } diff --git a/sop-java/src/main/java/sop/operation/ChangeKeyPassword.java b/sop-java/src/main/java/sop/operation/ChangeKeyPassword.java new file mode 100644 index 0000000..460a20a --- /dev/null +++ b/sop-java/src/main/java/sop/operation/ChangeKeyPassword.java @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2023 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.operation; + +import sop.Ready; +import sop.exception.SOPGPException; +import sop.util.UTF8Util; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.CharacterCodingException; + +public interface ChangeKeyPassword { + + /** + * Disable ASCII armoring of the output. + * + * @return builder instance + */ + ChangeKeyPassword noArmor(); + + default ChangeKeyPassword oldKeyPassphrase(byte[] password) { + try { + return oldKeyPassphrase(UTF8Util.decodeUTF8(password)); + } catch (CharacterCodingException e) { + throw new SOPGPException.PasswordNotHumanReadable("Password MUST be a valid UTF8 string."); + } + } + + /** + * Provide a passphrase to unlock the secret key. + * This method can be provided multiple times to provide separate passphrases that are tried as a + * means to unlock any secret key material encountered. + * + * @param oldPassphrase old passphrase + * @return builder instance + */ + ChangeKeyPassword oldKeyPassphrase(String oldPassphrase); + + /** + * Provide a passphrase to re-lock the secret key with. + * This method can only be used once, and all key material encountered will be encrypted with the given passphrase. + * If this method is not called, the key material will not be protected. + * + * @param newPassphrase new passphrase + * @return builder instance + */ + default ChangeKeyPassword newKeyPassphrase(byte[] newPassphrase) { + try { + return newKeyPassphrase(UTF8Util.decodeUTF8(newPassphrase)); + } catch (CharacterCodingException e) { + throw new SOPGPException.PasswordNotHumanReadable("Password MUST be a valid UTF8 string."); + } + } + + /** + * Provide a passphrase to re-lock the secret key with. + * This method can only be used once, and all key material encountered will be encrypted with the given passphrase. + * If this method is not called, the key material will not be protected. + * + * @param newPassphrase new passphrase + * @return builder instance + */ + ChangeKeyPassword newKeyPassphrase(String newPassphrase); + + default Ready keys(byte[] keys) throws SOPGPException.KeyIsProtected, SOPGPException.BadData { + return keys(new ByteArrayInputStream(keys)); + } + + /** + * Provide the key material. + * + * @param inputStream input stream of secret key material + * @return ready + * + * @throws sop.exception.SOPGPException.KeyIsProtected if any (sub-) key encountered cannot be unlocked. + * @throws sop.exception.SOPGPException.BadData if the key material is malformed + */ + Ready keys(InputStream inputStream) throws SOPGPException.KeyIsProtected, SOPGPException.BadData; + +} diff --git a/sop-java/src/testFixtures/java/sop/testsuite/operation/ChangeKeyPasswordTest.java b/sop-java/src/testFixtures/java/sop/testsuite/operation/ChangeKeyPasswordTest.java new file mode 100644 index 0000000..b575047 --- /dev/null +++ b/sop-java/src/testFixtures/java/sop/testsuite/operation/ChangeKeyPasswordTest.java @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2023 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.testsuite.operation; + +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import sop.SOP; +import sop.exception.SOPGPException; +import sop.util.UTF8Util; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@EnabledIf("sop.testsuite.operation.AbstractSOPTest#hasBackends") +public class ChangeKeyPasswordTest extends AbstractSOPTest { + + static Stream provideInstances() { + return AbstractSOPTest.provideBackends(); + } + + @ParameterizedTest + @MethodSource("provideInstances") + public void changePasswordFromUnprotectedToProtected(SOP sop) throws IOException { + byte[] unprotectedKey = sop.generateKey().generate().getBytes(); + byte[] password = "sw0rdf1sh".getBytes(UTF8Util.UTF8); + byte[] protectedKey = sop.changeKeyPassword().newKeyPassphrase(password).keys(unprotectedKey).getBytes(); + + sop.sign().withKeyPassword(password).key(protectedKey).data("Test123".getBytes(StandardCharsets.UTF_8)); + } + + @ParameterizedTest + @MethodSource("provideInstances") + public void changePasswordFromUnprotectedToUnprotected(SOP sop) throws IOException { + byte[] unprotectedKey = sop.generateKey().noArmor().generate().getBytes(); + byte[] stillUnprotectedKey = sop.changeKeyPassword().noArmor().keys(unprotectedKey).getBytes(); + + assertArrayEquals(unprotectedKey, stillUnprotectedKey); + } + + @ParameterizedTest + @MethodSource("provideInstances") + public void changePasswordFromProtectedToUnprotected(SOP sop) throws IOException { + byte[] password = "sw0rdf1sh".getBytes(UTF8Util.UTF8); + byte[] protectedKey = sop.generateKey().withKeyPassword(password).generate().getBytes(); + byte[] unprotectedKey = sop.changeKeyPassword() + .oldKeyPassphrase(password) + .keys(protectedKey).getBytes(); + + sop.sign().key(unprotectedKey).data("Test123".getBytes(StandardCharsets.UTF_8)); + } + + @ParameterizedTest + @MethodSource("provideInstances") + public void changePasswordFromProtectedToDifferentProtected(SOP sop) throws IOException { + byte[] oldPassword = "sw0rdf1sh".getBytes(UTF8Util.UTF8); + byte[] newPassword = "0r4ng3".getBytes(UTF8Util.UTF8); + byte[] protectedKey = sop.generateKey().withKeyPassword(oldPassword).generate().getBytes(); + byte[] reprotectedKey = sop.changeKeyPassword() + .oldKeyPassphrase(oldPassword) + .newKeyPassphrase(newPassword) + .keys(protectedKey).getBytes(); + + sop.sign().key(reprotectedKey).withKeyPassword(newPassword).data("Test123".getBytes(StandardCharsets.UTF_8)); + } + + + @ParameterizedTest + @MethodSource("provideInstances") + public void changePasswordWithWrongOldPasswordFails(SOP sop) throws IOException { + byte[] oldPassword = "sw0rdf1sh".getBytes(UTF8Util.UTF8); + byte[] newPassword = "monkey123".getBytes(UTF8Util.UTF8); + byte[] wrongPassword = "0r4ng3".getBytes(UTF8Util.UTF8); + + byte[] protectedKey = sop.generateKey().withKeyPassword(oldPassword).generate().getBytes(); + assertThrows(SOPGPException.KeyIsProtected.class, () -> sop.changeKeyPassword() + .oldKeyPassphrase(wrongPassword) + .newKeyPassphrase(newPassword) + .keys(protectedKey).getBytes()); + } + + @ParameterizedTest + @MethodSource("provideInstances") + public void nonUtf8PasswordsFail(SOP sop) { + assertThrows(SOPGPException.PasswordNotHumanReadable.class, () -> + sop.changeKeyPassword().oldKeyPassphrase(new byte[] {(byte) 0xff, (byte) 0xfe})); + assertThrows(SOPGPException.PasswordNotHumanReadable.class, () -> + sop.changeKeyPassword().newKeyPassphrase(new byte[] {(byte) 0xff, (byte) 0xfe})); + + } +}