diff --git a/CHANGELOG.md b/CHANGELOG.md index 558b63a..d2db168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ SPDX-License-Identifier: Apache-2.0 # Changelog +## 1.2.0 +- `encrypt`, `decrypt`: Interpret arguments of `--with-password` and `--with-session-key` as indirect data types (e.g. file references instead of strings) + ## 1.1.0 - Initial release from new repository - Implement SOP specification version 3 \ No newline at end of file diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/FileUtil.java b/sop-java-picocli/src/main/java/sop/cli/picocli/FileUtil.java index cd92e6d..cbe2735 100644 --- a/sop-java-picocli/src/main/java/sop/cli/picocli/FileUtil.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/FileUtil.java @@ -4,12 +4,15 @@ package sop.cli.picocli; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; +import java.io.InputStream; import java.io.IOException; import sop.exception.SOPGPException; +import sop.util.UTF8Util; public class FileUtil { @@ -95,4 +98,17 @@ public static File createNewFileOrThrow(File file) throws IOException { } return file; } + + public static String stringFromInputStream(InputStream inputStream) throws IOException { + try { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; int read; + while ((read = inputStream.read(buf)) != -1) { + byteOut.write(buf, 0, read); + } + return UTF8Util.decodeUTF8(byteOut.toByteArray()); + } finally { + inputStream.close(); + } + } } diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DecryptCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DecryptCmd.java index 8fc4650..3a845b4 100644 --- a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DecryptCmd.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DecryptCmd.java @@ -47,13 +47,13 @@ public class DecryptCmd implements Runnable { @CommandLine.Option( names = {"--with-session-key"}, - description = "Enables decryption of the \"CIPHERTEXT\" using the session key directly against the \"SEIPD\" packet", + description = "Provide a session key file. Enables decryption of the \"CIPHERTEXT\" using the session key directly against the \"SEIPD\" packet", paramLabel = "SESSIONKEY") List withSessionKey = new ArrayList<>(); @CommandLine.Option( names = {"--with-password"}, - description = "Enables decryption based on any \"SKESK\" packets in the \"CIPHERTEXT\"", + description = "Provide a password file. Enables decryption based on any \"SKESK\" packets in the \"CIPHERTEXT\"", paramLabel = "PASSWORD") List withPassword = new ArrayList<>(); @@ -194,7 +194,13 @@ private void setVerifyWith(List certs, Decrypt decrypt) { private void setWithSessionKeys(List withSessionKey, Decrypt decrypt) { Pattern sessionKeyPattern = Pattern.compile("^\\d+:[0-9A-F]+$"); - for (String sessionKey : withSessionKey) { + for (String sessionKeyFile : withSessionKey) { + String sessionKey; + try { + sessionKey = FileUtil.stringFromInputStream(FileUtil.getFileInputStream(sessionKeyFile)); + } catch (IOException e) { + throw new RuntimeException(e); + } if (!sessionKeyPattern.matcher(sessionKey).matches()) { throw new IllegalArgumentException("Session keys are expected in the format 'ALGONUM:HEXKEY'."); } @@ -211,11 +217,14 @@ private void setWithSessionKeys(List withSessionKey, Decrypt decrypt) { } private void setWithPasswords(List withPassword, Decrypt decrypt) { - for (String password : withPassword) { + for (String passwordFile : withPassword) { try { + String password = FileUtil.stringFromInputStream(FileUtil.getFileInputStream(passwordFile)); decrypt.withPassword(password); } catch (SOPGPException.UnsupportedOption unsupportedOption) { throw new SOPGPException.UnsupportedOption(String.format(ERROR_UNSUPPORTED_OPTION, "--with-password"), unsupportedOption); + } catch (IOException e) { + throw new RuntimeException(e); } } } diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/EncryptCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/EncryptCmd.java index 0634240..8d26742 100644 --- a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/EncryptCmd.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/EncryptCmd.java @@ -13,6 +13,7 @@ import picocli.CommandLine; import sop.Ready; +import sop.cli.picocli.FileUtil; import sop.cli.picocli.SopCLI; import sop.enums.EncryptAs; import sop.exception.SOPGPException; @@ -34,7 +35,7 @@ public class EncryptCmd implements Runnable { EncryptAs type; @CommandLine.Option(names = "--with-password", - description = "Encrypt the message with a password", + description = "Encrypt the message with a password provided by the given password file", paramLabel = "PASSWORD") List withPassword = new ArrayList<>(); @@ -64,14 +65,17 @@ public void run() { } if (withPassword.isEmpty() && certs.isEmpty()) { - throw new SOPGPException.MissingArg("At least one password or cert file required for encryption."); + throw new SOPGPException.MissingArg("At least one password file or cert file required for encryption."); } - for (String password : withPassword) { + for (String passwordFileName : withPassword) { try { + String password = FileUtil.stringFromInputStream(FileUtil.getFileInputStream(passwordFileName)); encrypt.withPassword(password); } catch (SOPGPException.UnsupportedOption unsupportedOption) { throw new SOPGPException.UnsupportedOption("Unsupported option '--with-password'.", unsupportedOption); + } catch (IOException e) { + throw new RuntimeException(e); } } diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/TestFileUtil.java b/sop-java-picocli/src/test/java/sop/cli/picocli/TestFileUtil.java new file mode 100644 index 0000000..385fe1a --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/TestFileUtil.java @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.cli.picocli; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +public class TestFileUtil { + + public static File writeTempStringFile(String string) throws IOException { + File tempDir = Files.createTempDirectory("tmpDir").toFile(); + tempDir.deleteOnExit(); + tempDir.mkdirs(); + + File passwordFile = new File(tempDir, "file"); + passwordFile.createNewFile(); + + FileOutputStream fileOut = new FileOutputStream(passwordFile); + fileOut.write(string.getBytes(StandardCharsets.UTF_8)); + fileOut.close(); + + return passwordFile; + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DecryptCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DecryptCmdTest.java index 9e1c35b..f04026d 100644 --- a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DecryptCmdTest.java +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DecryptCmdTest.java @@ -38,6 +38,7 @@ import sop.Verification; import sop.cli.picocli.DateParser; import sop.cli.picocli.SopCLI; +import sop.cli.picocli.TestFileUtil; import sop.exception.SOPGPException; import sop.operation.Decrypt; import sop.util.HexUtil; @@ -90,22 +91,25 @@ public void badDataExceptionCausesExit41() throws SOPGPException.MissingArg, SOP @Test @ExpectSystemExitWithStatus(31) public void assertNotHumanReadablePasswordCausesExit31() throws SOPGPException.PasswordNotHumanReadable, - SOPGPException.UnsupportedOption { + SOPGPException.UnsupportedOption, IOException { + File passwordFile = TestFileUtil.writeTempStringFile("pretendThisIsNotReadable"); when(decrypt.withPassword(any())).thenThrow(new SOPGPException.PasswordNotHumanReadable()); - SopCLI.main(new String[] {"decrypt", "--with-password", "pretendThisIsNotReadable"}); + SopCLI.main(new String[] {"decrypt", "--with-password", passwordFile.getAbsolutePath()}); } @Test - public void assertWithPasswordPassesPasswordDown() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { - SopCLI.main(new String[] {"decrypt", "--with-password", "orange"}); + public void assertWithPasswordPassesPasswordDown() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption, IOException { + File passwordFile = TestFileUtil.writeTempStringFile("orange"); + SopCLI.main(new String[] {"decrypt", "--with-password", passwordFile.getAbsolutePath()}); verify(decrypt, times(1)).withPassword("orange"); } @Test @ExpectSystemExitWithStatus(37) - public void assertUnsupportedWithPasswordCausesExit37() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + public void assertUnsupportedWithPasswordCausesExit37() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption, IOException { + File passwordFile = TestFileUtil.writeTempStringFile("swordfish"); when(decrypt.withPassword(any())).thenThrow(new SOPGPException.UnsupportedOption("Decrypting with password not supported.")); - SopCLI.main(new String[] {"decrypt", "--with-password", "swordfish"}); + SopCLI.main(new String[] {"decrypt", "--with-password", passwordFile.getAbsolutePath()}); } @Test @@ -289,21 +293,26 @@ public DecryptionResult writeTo(OutputStream outputStream) { } @Test - public void assertWithSessionKeyIsPassedDown() throws SOPGPException.UnsupportedOption { + public void assertWithSessionKeyIsPassedDown() throws SOPGPException.UnsupportedOption, IOException { SessionKey key1 = new SessionKey((byte) 9, HexUtil.hexToBytes("C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137")); SessionKey key2 = new SessionKey((byte) 9, HexUtil.hexToBytes("FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD")); + + File sessionKeyFile1 = TestFileUtil.writeTempStringFile(key1.toString()); + File sessionKeyFile2 = TestFileUtil.writeTempStringFile(key2.toString()); + SopCLI.main(new String[] {"decrypt", - "--with-session-key", "9:C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137", - "--with-session-key", "9:FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD"}); + "--with-session-key", sessionKeyFile1.getAbsolutePath(), + "--with-session-key", sessionKeyFile2.getAbsolutePath()}); verify(decrypt).withSessionKey(key1); verify(decrypt).withSessionKey(key2); } @Test @ExpectSystemExitWithStatus(1) - public void assertMalformedSessionKeysResultInExit1() { + public void assertMalformedSessionKeysResultInExit1() throws IOException { + File sessionKeyFile = TestFileUtil.writeTempStringFile("C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137"); SopCLI.main(new String[] {"decrypt", - "--with-session-key", "C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137"}); + "--with-session-key", sessionKeyFile.getAbsolutePath()}); } @Test @@ -336,6 +345,18 @@ public void assertUnsupportedAlgorithmExceptionCausesExit13() throws SOPGPExcept SopCLI.main(new String[] {"decrypt", tempKeyFile.getAbsolutePath()}); } + @Test + @ExpectSystemExitWithStatus(61) + public void assertMissingPassphraseFileCausesExit61() { + SopCLI.main(new String[] {"decrypt", "--with-password", "missing"}); + } + + @Test + @ExpectSystemExitWithStatus(61) + public void assertMissingSessionKeyFileCausesExit61() { + SopCLI.main(new String[] {"decrypt", "--with-session-key", "missing"}); + } + @Test @ExpectSystemExitWithStatus(23) public void verifyOutWithoutVerifyWithCausesExit23() { diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/EncryptCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/EncryptCmdTest.java index 91f0a1e..4edb5e6 100644 --- a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/EncryptCmdTest.java +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/EncryptCmdTest.java @@ -22,6 +22,7 @@ import sop.Ready; import sop.SOP; import sop.cli.picocli.SopCLI; +import sop.cli.picocli.TestFileUtil; import sop.enums.EncryptAs; import sop.exception.SOPGPException; import sop.operation.Encrypt; @@ -67,35 +68,36 @@ public void as_invalidModeOptionCausesExit37() { } @Test - public void as_modeIsPassedDown() throws SOPGPException.UnsupportedOption { + public void as_modeIsPassedDown() throws SOPGPException.UnsupportedOption, IOException { + File passwordFile = TestFileUtil.writeTempStringFile("0rbit"); for (EncryptAs mode : EncryptAs.values()) { - SopCLI.main(new String[] {"encrypt", "--as", mode.name(), "--with-password", "0rbit"}); + SopCLI.main(new String[] {"encrypt", "--as", mode.name(), "--with-password", passwordFile.getAbsolutePath()}); verify(encrypt, times(1)).mode(mode); } } @Test @ExpectSystemExitWithStatus(31) - public void withPassword_notHumanReadablePasswordCausesExit31() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + public void withPassword_notHumanReadablePasswordCausesExit31() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption, IOException { when(encrypt.withPassword("pretendThisIsNotReadable")).thenThrow(new SOPGPException.PasswordNotHumanReadable()); - - SopCLI.main(new String[] {"encrypt", "--with-password", "pretendThisIsNotReadable"}); + File passwordFile = TestFileUtil.writeTempStringFile("pretendThisIsNotReadable"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath()}); } @Test @ExpectSystemExitWithStatus(37) - public void withPassword_unsupportedWithPasswordCausesExit37() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + public void withPassword_unsupportedWithPasswordCausesExit37() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption, IOException { when(encrypt.withPassword(any())).thenThrow(new SOPGPException.UnsupportedOption("Encrypting with password not supported.")); - - SopCLI.main(new String[] {"encrypt", "--with-password", "orange"}); + File passwordFile = TestFileUtil.writeTempStringFile("orange"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath()}); } @Test public void signWith_multipleTimesGetPassedDown() throws IOException, SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.KeyCannotSign, SOPGPException.BadData { File keyFile1 = File.createTempFile("sign-with-1-", ".asc"); File keyFile2 = File.createTempFile("sign-with-2-", ".asc"); - - SopCLI.main(new String[] {"encrypt", "--with-password", "password", "--sign-with", keyFile1.getAbsolutePath(), "--sign-with", keyFile2.getAbsolutePath()}); + File passwordFile = TestFileUtil.writeTempStringFile("password"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath(), "--sign-with", keyFile1.getAbsolutePath(), "--sign-with", keyFile2.getAbsolutePath()}); verify(encrypt, times(2)).signWith((InputStream) any()); } @@ -110,7 +112,8 @@ public void signWith_nonExistentKeyFileCausesExit61() { public void signWith_keyIsProtectedCausesExit67() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.KeyCannotSign, SOPGPException.BadData, IOException { when(encrypt.signWith((InputStream) any())).thenThrow(new SOPGPException.KeyIsProtected()); File keyFile = File.createTempFile("sign-with", ".asc"); - SopCLI.main(new String[] {"encrypt", "--sign-with", keyFile.getAbsolutePath(), "--with-password", "starship"}); + File passwordFile = TestFileUtil.writeTempStringFile("starship"); + SopCLI.main(new String[] {"encrypt", "--sign-with", keyFile.getAbsolutePath(), "--with-password", passwordFile.getAbsolutePath()}); } @Test @@ -118,7 +121,8 @@ public void signWith_keyIsProtectedCausesExit67() throws SOPGPException.KeyIsPro public void signWith_unsupportedAsymmetricAlgoCausesExit13() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.KeyCannotSign, SOPGPException.BadData, IOException { when(encrypt.signWith((InputStream) any())).thenThrow(new SOPGPException.UnsupportedAsymmetricAlgo("Unsupported asymmetric algorithm.", new Exception())); File keyFile = File.createTempFile("sign-with", ".asc"); - SopCLI.main(new String[] {"encrypt", "--with-password", "123456", "--sign-with", keyFile.getAbsolutePath()}); + File passwordFile = TestFileUtil.writeTempStringFile("123456"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath(), "--sign-with", keyFile.getAbsolutePath()}); } @Test @@ -126,7 +130,8 @@ public void signWith_unsupportedAsymmetricAlgoCausesExit13() throws SOPGPExcepti public void signWith_certCannotSignCausesExit1() throws IOException, SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.KeyCannotSign, SOPGPException.BadData { when(encrypt.signWith((InputStream) any())).thenThrow(new SOPGPException.KeyCannotSign()); File keyFile = File.createTempFile("sign-with", ".asc"); - SopCLI.main(new String[] {"encrypt", "--with-password", "dragon", "--sign-with", keyFile.getAbsolutePath()}); + File passwordFile = TestFileUtil.writeTempStringFile("dragon"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath(), "--sign-with", keyFile.getAbsolutePath()}); } @Test @@ -134,7 +139,8 @@ public void signWith_certCannotSignCausesExit1() throws IOException, SOPGPExcept public void signWith_badDataCausesExit41() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.KeyCannotSign, SOPGPException.BadData, IOException { when(encrypt.signWith((InputStream) any())).thenThrow(new SOPGPException.BadData(new IOException())); File keyFile = File.createTempFile("sign-with", ".asc"); - SopCLI.main(new String[] {"encrypt", "--with-password", "orange", "--sign-with", keyFile.getAbsolutePath()}); + File passwordFile = TestFileUtil.writeTempStringFile("orange"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath(), "--sign-with", keyFile.getAbsolutePath()}); } @Test @@ -168,14 +174,16 @@ public void cert_badDataCausesExit41() throws IOException, SOPGPException.Unsupp } @Test - public void noArmor_notCalledByDefault() { - SopCLI.main(new String[] {"encrypt", "--with-password", "clownfish"}); + public void noArmor_notCalledByDefault() throws IOException { + File passwordFile = TestFileUtil.writeTempStringFile("clownfish"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath()}); verify(encrypt, never()).noArmor(); } @Test - public void noArmor_callGetsPassedDown() { - SopCLI.main(new String[] {"encrypt", "--with-password", "monkey", "--no-armor"}); + public void noArmor_callGetsPassedDown() throws IOException { + File passwordFile = TestFileUtil.writeTempStringFile("monkey"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath(), "--no-armor"}); verify(encrypt, times(1)).noArmor(); } @@ -188,7 +196,7 @@ public void writeTo(OutputStream outputStream) throws IOException { throw new IOException(); } }); - - SopCLI.main(new String[] {"encrypt", "--with-password", "wildcat"}); + File passwordFile = TestFileUtil.writeTempStringFile("wildcat"); + SopCLI.main(new String[] {"encrypt", "--with-password", passwordFile.getAbsolutePath()}); } } diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VersionCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VersionCmdTest.java index 98ea58e..d11352d 100644 --- a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VersionCmdTest.java +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VersionCmdTest.java @@ -26,6 +26,8 @@ public void mockComponents() { version = mock(Version.class); when(version.getName()).thenReturn("MockSop"); when(version.getVersion()).thenReturn("1.0"); + when(version.getExtendedVersion()).thenReturn("MockSop Extended Version Information"); + when(version.getBackendVersion()).thenReturn("Foo"); when(sop.version()).thenReturn(version); SopCLI.setSopInstance(sop); @@ -38,6 +40,18 @@ public void assertVersionCommandWorks() { verify(version, times(1)).getName(); } + @Test + public void assertExtendedVersionCommandWorks() { + SopCLI.main(new String[] {"version", "--extended"}); + verify(version, times(1)).getExtendedVersion(); + } + + @Test + public void assertBackendVersionCommandWorks() { + SopCLI.main(new String[] {"version", "--backend"}); + verify(version, times(1)).getBackendVersion(); + } + @Test @ExpectSystemExitWithStatus(37) public void assertInvalidOptionResultsInExit37() { diff --git a/sop-java/src/main/java/sop/exception/SOPGPException.java b/sop-java/src/main/java/sop/exception/SOPGPException.java index 6b844f5..93f565e 100644 --- a/sop-java/src/main/java/sop/exception/SOPGPException.java +++ b/sop-java/src/main/java/sop/exception/SOPGPException.java @@ -128,6 +128,10 @@ public static class PasswordNotHumanReadable extends SOPGPException { public static final int EXIT_CODE = 31; + public PasswordNotHumanReadable() { + super(); + } + @Override public int getExitCode() { return EXIT_CODE; @@ -162,12 +166,16 @@ public static class BadData extends SOPGPException { public static final int EXIT_CODE = 41; - public BadData(Throwable e) { - super(e); + public BadData(String message) { + super(message); + } + + public BadData(Throwable throwable) { + super(throwable); } - public BadData(String message, BadData badData) { - super(message, badData); + public BadData(String message, Throwable throwable) { + super(message, throwable); } @Override diff --git a/sop-java/src/main/java/sop/util/UTF8Util.java b/sop-java/src/main/java/sop/util/UTF8Util.java new file mode 100644 index 0000000..fa00b8e --- /dev/null +++ b/sop-java/src/main/java/sop/util/UTF8Util.java @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.util; + +import sop.exception.SOPGPException; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; + +public class UTF8Util { + + private static final CharsetDecoder UTF8Decoder = Charset.forName("UTF8") + .newDecoder() + .onUnmappableCharacter(CodingErrorAction.REPORT) + .onMalformedInput(CodingErrorAction.REPORT); + + /** + * Detect non-valid UTF8 data. + * + * @see ante on StackOverflow + * @param data + * @return + */ + public static String decodeUTF8(byte[] data) { + ByteBuffer byteBuffer = ByteBuffer.wrap(data); + try { + CharBuffer charBuffer = UTF8Decoder.decode(byteBuffer); + return charBuffer.toString(); + } catch (CharacterCodingException e) { + throw new SOPGPException.PasswordNotHumanReadable(); + } + } +} diff --git a/sop-java/src/test/java/sop/util/UTF8UtilTest.java b/sop-java/src/test/java/sop/util/UTF8UtilTest.java new file mode 100644 index 0000000..775d273 --- /dev/null +++ b/sop-java/src/test/java/sop/util/UTF8UtilTest.java @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package sop.util; + +import org.junit.jupiter.api.Test; +import sop.exception.SOPGPException; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class UTF8UtilTest { + + @Test + public void testValidUtf8Decoding() { + String utf8String = "Hello, World\n"; + String decoded = UTF8Util.decodeUTF8(utf8String.getBytes(StandardCharsets.UTF_8)); + + assertEquals(utf8String, decoded); + } + + /** + * Test detection of non-uft8 data. + * @see + * Markus Kuhn's UTF8 decoder capability and stress test file + */ + @Test + public void testInvalidUtf8StringThrows() { + assertThrows(SOPGPException.PasswordNotHumanReadable.class, + () -> UTF8Util.decodeUTF8(new byte[] {(byte) 0xa0, (byte) 0xa1})); + assertThrows(SOPGPException.PasswordNotHumanReadable.class, + () -> UTF8Util.decodeUTF8(new byte[] {(byte) 0xc0, (byte) 0xaf})); + assertThrows(SOPGPException.PasswordNotHumanReadable.class, + () -> UTF8Util.decodeUTF8(new byte[] {(byte) 0x80, (byte) 0xbf})); + } +}