diff --git a/build.gradle b/build.gradle index dbcd1038..5ae633c7 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ allprojects { apply plugin: 'checkstyle' // For non-sop modules, enable android api compatibility check - if (!it.name.equals('pgpainless-sop')) { + if (it.name.equals('pgpainless-core') || it.name.equals('sop-java') || it.name.equals('pgpainless-sop')) { // animalsniffer apply plugin: 'ru.vyarus.animalsniffer' dependencies { diff --git a/pgpainless-sop/README.md b/pgpainless-cli/README.md similarity index 100% rename from pgpainless-sop/README.md rename to pgpainless-cli/README.md diff --git a/pgpainless-cli/build.gradle b/pgpainless-cli/build.gradle new file mode 100644 index 00000000..cae9c78f --- /dev/null +++ b/pgpainless-cli/build.gradle @@ -0,0 +1,66 @@ +plugins { + id 'application' +} +def generatedVersionDir = "${buildDir}/generated-version" + +sourceSets { + main { + output.dir(generatedVersionDir, builtBy: 'generateVersionProperties') + } +} + +task generateVersionProperties { + doLast { + def propertiesFile = file "$generatedVersionDir/version.properties" + propertiesFile.parentFile.mkdirs() + propertiesFile.createNewFile() + // Instead of using a Properties object here, we directly write to the file + // since Properties adds a timestamp, ruining reproducibility + propertiesFile.write("version="+rootProject.version.toString()) + } +} +processResources.dependsOn generateVersionProperties + +dependencies { + implementation(project(":pgpainless-sop")) + implementation(project(":sop-java")) + + implementation(project(":sop-java-picocli")) + implementation 'info.picocli:picocli:4.5.2' + + testImplementation(project(":pgpainless-core")) + + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // https://todd.ginsberg.com/post/testing-system-exit/ + testImplementation 'com.ginsberg:junit5-system-exit:1.1.1' + + /* + implementation "org.bouncycastle:bcprov-debug-jdk15on:$bouncyCastleVersion" + /*/ + implementation "org.bouncycastle:bcprov-jdk15on:$bouncyCastleVersion" + //*/ + implementation "org.bouncycastle:bcpg-jdk15on:$bouncyCastleVersion" + + // https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305 + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2' +} + +mainClassName = 'org.pgpainless.cli.PGPainlessCLI' + +jar { + manifest { + attributes 'Main-Class': "$mainClassName" + } + + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } { + exclude "META-INF/*.SF" + exclude "META-INF/*.DSA" + exclude "META-INF/*.RSA" + } +} + +tasks."jar".dependsOn(":pgpainless-core:assemble") diff --git a/pgpainless-sop/pgpainless b/pgpainless-cli/pgpainless similarity index 100% rename from pgpainless-sop/pgpainless rename to pgpainless-cli/pgpainless diff --git a/pgpainless-cli/src/main/java/org/pgpainless/cli/PGPainlessCLI.java b/pgpainless-cli/src/main/java/org/pgpainless/cli/PGPainlessCLI.java new file mode 100644 index 00000000..736607eb --- /dev/null +++ b/pgpainless-cli/src/main/java/org/pgpainless/cli/PGPainlessCLI.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.cli; + +import org.pgpainless.sop.SOPImpl; +import sop.cli.picocli.SopCLI; + +public class PGPainlessCLI { + + static { + SopCLI.setSopInstance(new SOPImpl()); + } + + public static void main(String[] args) { + int result = execute(args); + if (result != 0) { + System.exit(result); + } + } + + public static int execute(String... args) { + return SopCLI.execute(args); + } +} diff --git a/pgpainless-cli/src/main/java/org/pgpainless/cli/package-info.java b/pgpainless-cli/src/main/java/org/pgpainless/cli/package-info.java new file mode 100644 index 00000000..2eabeaba --- /dev/null +++ b/pgpainless-cli/src/main/java/org/pgpainless/cli/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * PGPainless SOP implementing a Stateless OpenPGP Command Line Interface. + * @see + * Stateless OpenPGP Command Line Interface + * draft-dkg-openpgp-stateless-cli-01 + */ +package org.pgpainless.cli; diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/ExitCodeTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/ExitCodeTest.java similarity index 62% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/ExitCodeTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/ExitCodeTest.java index e8933b19..c9f88cca 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/ExitCodeTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/ExitCodeTest.java @@ -13,27 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop; - -import static org.junit.jupiter.api.Assertions.assertEquals; +package org.pgpainless.cli; +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.junit.jupiter.api.Test; -import picocli.CommandLine; public class ExitCodeTest { @Test + @ExpectSystemExitWithStatus(69) public void testUnknownCommand_69() { - assertEquals(69, new CommandLine(new PGPainlessCLI()).execute("generate-kex")); + PGPainlessCLI.main(new String[] {"generate-kex"}); } @Test + @ExpectSystemExitWithStatus(37) public void testCommandWithUnknownOption_37() { - assertEquals(37, new CommandLine(new PGPainlessCLI()).execute("generate-key", "-k", "\"k is unknown\"")); + PGPainlessCLI.main(new String[] {"generate-key", "-k", "\"k is unknown\""}); } @Test - public void successfulVersion_0 () { - assertEquals(0, new CommandLine(new PGPainlessCLI()).execute("version")); + @FailOnSystemExit + public void successfulExecutionDoesNotTerminateJVM() { + PGPainlessCLI.main(new String[] {"version"}); } } diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/TestUtils.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/TestUtils.java similarity index 98% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/TestUtils.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/TestUtils.java index 9f5e74a2..ca525814 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/TestUtils.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/TestUtils.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop; +package org.pgpainless.cli; import java.io.File; import java.io.IOException; diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/ArmorTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/ArmorTest.java similarity index 91% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/commands/ArmorTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/commands/ArmorTest.java index 8df3ea87..0e938fa8 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/ArmorTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/ArmorTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; +package org.pgpainless.cli.commands; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -28,6 +28,7 @@ import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.bouncycastle.openpgp.PGPSecretKeyRing; @@ -35,8 +36,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pgpainless.PGPainless; -import org.pgpainless.sop.PGPainlessCLI; -import picocli.CommandLine; +import org.pgpainless.cli.PGPainlessCLI; public class ArmorTest { @@ -53,6 +53,7 @@ public class ArmorTest { } @Test + @FailOnSystemExit public void armorSecretKey() throws IOException, PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException { PGPSecretKeyRing secretKey = PGPainless.generateKeyRing() .modernKeyRing("alice@pgpainless.org", null); @@ -61,13 +62,14 @@ public class ArmorTest { System.setIn(new ByteArrayInputStream(bytes)); ByteArrayOutputStream armorOut = new ByteArrayOutputStream(); System.setOut(new PrintStream(armorOut)); - new CommandLine(new PGPainlessCLI()).execute("armor"); + PGPainlessCLI.execute("armor"); PGPSecretKeyRing armored = PGPainless.readKeyRing().secretKeyRing(armorOut.toString()); assertArrayEquals(secretKey.getEncoded(), armored.getEncoded()); } @Test + @FailOnSystemExit public void armorPublicKey() throws PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IOException { PGPSecretKeyRing secretKey = PGPainless.generateKeyRing() .modernKeyRing("alice@pgpainless.org", null); @@ -77,20 +79,21 @@ public class ArmorTest { System.setIn(new ByteArrayInputStream(bytes)); ByteArrayOutputStream armorOut = new ByteArrayOutputStream(); System.setOut(new PrintStream(armorOut)); - new CommandLine(new PGPainlessCLI()).execute("armor"); + PGPainlessCLI.execute("armor"); PGPPublicKeyRing armored = PGPainless.readKeyRing().publicKeyRing(armorOut.toString()); assertArrayEquals(publicKey.getEncoded(), armored.getEncoded()); } @Test + @FailOnSystemExit public void armorMessage() { String message = "Hello, World!\n"; System.setIn(new ByteArrayInputStream(message.getBytes(StandardCharsets.UTF_8))); ByteArrayOutputStream armorOut = new ByteArrayOutputStream(); System.setOut(new PrintStream(armorOut)); - new CommandLine(new PGPainlessCLI()).execute("armor"); + PGPainlessCLI.execute("armor"); String armored = armorOut.toString(); @@ -99,6 +102,7 @@ public class ArmorTest { } @Test + @FailOnSystemExit public void doesNotNestArmorByDefault() { String armored = "-----BEGIN PGP MESSAGE-----\n" + "Version: BCPG v1.69\n" + @@ -110,12 +114,13 @@ public class ArmorTest { System.setIn(new ByteArrayInputStream(armored.getBytes(StandardCharsets.UTF_8))); ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("armor"); + PGPainlessCLI.execute("armor"); assertEquals(armored, out.toString()); } @Test + @FailOnSystemExit public void testAllowNested() { String armored = "-----BEGIN PGP MESSAGE-----\n" + "Version: BCPG v1.69\n" + @@ -127,7 +132,7 @@ public class ArmorTest { System.setIn(new ByteArrayInputStream(armored.getBytes(StandardCharsets.UTF_8))); ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("armor", "--allow-nested"); + PGPainlessCLI.execute("armor", "--allow-nested"); assertNotEquals(armored, out.toString()); assertTrue(out.toString().contains( diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/DearmorTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/DearmorTest.java similarity index 91% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/commands/DearmorTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/commands/DearmorTest.java index 0703693d..d735adab 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/DearmorTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/DearmorTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; +package org.pgpainless.cli.commands; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -26,6 +26,7 @@ import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.bouncycastle.openpgp.PGPSecretKeyRing; @@ -33,8 +34,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pgpainless.PGPainless; -import org.pgpainless.sop.PGPainlessCLI; -import picocli.CommandLine; +import org.pgpainless.cli.PGPainlessCLI; public class DearmorTest { @@ -51,6 +51,7 @@ public class DearmorTest { } @Test + @FailOnSystemExit public void dearmorSecretKey() throws PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IOException { PGPSecretKeyRing secretKey = PGPainless.generateKeyRing() .modernKeyRing("alice@pgpainless.org", null); @@ -59,13 +60,14 @@ public class DearmorTest { System.setIn(new ByteArrayInputStream(armored.getBytes(StandardCharsets.UTF_8))); ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("dearmor"); + PGPainlessCLI.execute("dearmor"); assertArrayEquals(secretKey.getEncoded(), out.toByteArray()); } @Test + @FailOnSystemExit public void dearmorCertificate() throws PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IOException { PGPSecretKeyRing secretKey = PGPainless.generateKeyRing() .modernKeyRing("alice@pgpainless.org", null); @@ -75,12 +77,13 @@ public class DearmorTest { System.setIn(new ByteArrayInputStream(armored.getBytes(StandardCharsets.UTF_8))); ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("dearmor"); + PGPainlessCLI.execute("dearmor"); assertArrayEquals(certificate.getEncoded(), out.toByteArray()); } @Test + @FailOnSystemExit public void dearmorMessage() { String armored = "-----BEGIN PGP MESSAGE-----\n" + "Version: BCPG v1.69\n" + @@ -92,7 +95,7 @@ public class DearmorTest { System.setIn(new ByteArrayInputStream(armored.getBytes(StandardCharsets.UTF_8))); ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("dearmor"); + PGPainlessCLI.execute("dearmor"); assertEquals("Hello, World\n", out.toString()); } diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/EncryptDecryptTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/EncryptDecryptTest.java similarity index 85% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/commands/EncryptDecryptTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/commands/EncryptDecryptTest.java index a521faf2..96d7ef70 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/EncryptDecryptTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/EncryptDecryptTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; +package org.pgpainless.cli.commands; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,12 +28,12 @@ import java.io.OutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.pgpainless.sop.PGPainlessCLI; -import org.pgpainless.sop.TestUtils; -import picocli.CommandLine; +import org.pgpainless.cli.PGPainlessCLI; +import org.pgpainless.cli.TestUtils; public class EncryptDecryptTest { @@ -46,7 +46,8 @@ public class EncryptDecryptTest { } @Test - public void test() throws IOException { + @FailOnSystemExit + public void encryptAndDecryptAMessage() throws IOException { originalSout = System.out; File julietKeyFile = new File(tempDir, "juliet.key"); assertTrue(julietKeyFile.createNewFile()); @@ -65,27 +66,27 @@ public class EncryptDecryptTest { OutputStream julietKeyOut = new FileOutputStream(julietKeyFile); System.setOut(new PrintStream(julietKeyOut)); - new CommandLine(new PGPainlessCLI()).execute("generate-key", "Juliet Capulet "); + PGPainlessCLI.execute("generate-key", "Juliet Capulet "); julietKeyOut.close(); FileInputStream julietKeyIn = new FileInputStream(julietKeyFile); System.setIn(julietKeyIn); OutputStream julietCertOut = new FileOutputStream(julietCertFile); System.setOut(new PrintStream(julietCertOut)); - new CommandLine(new PGPainlessCLI()).execute("extract-cert"); + PGPainlessCLI.execute("extract-cert"); julietKeyIn.close(); julietCertOut.close(); OutputStream romeoKeyOut = new FileOutputStream(romeoKeyFile); System.setOut(new PrintStream(romeoKeyOut)); - new CommandLine(new PGPainlessCLI()).execute("generate-key", "Romeo Montague "); + PGPainlessCLI.execute("generate-key", "Romeo Montague "); romeoKeyOut.close(); FileInputStream romeoKeyIn = new FileInputStream(romeoKeyFile); System.setIn(romeoKeyIn); OutputStream romeoCertOut = new FileOutputStream(romeoCertFile); System.setOut(new PrintStream(romeoCertOut)); - new CommandLine(new PGPainlessCLI()).execute("extract-cert"); + PGPainlessCLI.execute("extract-cert"); romeoKeyIn.close(); romeoCertOut.close(); @@ -94,7 +95,7 @@ public class EncryptDecryptTest { System.setIn(msgIn); OutputStream msgAscOut = new FileOutputStream(msgAscFile); System.setOut(new PrintStream(msgAscOut)); - new CommandLine(new PGPainlessCLI()).execute("encrypt", + PGPainlessCLI.execute("encrypt", "--sign-with", romeoKeyFile.getAbsolutePath(), julietCertFile.getAbsolutePath()); msgAscOut.close(); @@ -107,7 +108,7 @@ public class EncryptDecryptTest { ByteArrayOutputStream out = new ByteArrayOutputStream(); PrintStream pOut = new PrintStream(out); System.setOut(pOut); - new CommandLine(new PGPainlessCLI()).execute("decrypt", + PGPainlessCLI.execute("decrypt", "--verify-out", verifyFile.getAbsolutePath(), "--verify-with", romeoCertFile.getAbsolutePath(), julietKeyFile.getAbsolutePath()); diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/ExtractCertTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/ExtractCertTest.java similarity index 91% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/commands/ExtractCertTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/commands/ExtractCertTest.java index e9e52e75..e3d0175b 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/ExtractCertTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/ExtractCertTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; +package org.pgpainless.cli.commands; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -25,18 +25,19 @@ import java.io.PrintStream; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.bouncycastle.openpgp.PGPSecretKeyRing; import org.junit.jupiter.api.Test; import org.pgpainless.PGPainless; +import org.pgpainless.cli.PGPainlessCLI; import org.pgpainless.key.info.KeyRingInfo; -import org.pgpainless.sop.PGPainlessCLI; -import picocli.CommandLine; public class ExtractCertTest { @Test + @FailOnSystemExit public void testExtractCert() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, PGPException, IOException { PGPSecretKeyRing secretKeys = PGPainless.generateKeyRing() .simpleEcKeyRing("Juliet Capulet "); @@ -46,7 +47,7 @@ public class ExtractCertTest { ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("extract-cert"); + PGPainlessCLI.execute("extract-cert"); PGPPublicKeyRing publicKeys = PGPainless.readKeyRing().publicKeyRing(out.toByteArray()); KeyRingInfo info = PGPainless.inspectKeyRing(publicKeys); assertFalse(info.isSecretKey()); diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/GenerateCertTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/GenerateCertTest.java similarity index 83% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/commands/GenerateCertTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/commands/GenerateCertTest.java index 91e7d335..e693c719 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/GenerateCertTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/GenerateCertTest.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; +package org.pgpainless.cli.commands; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.pgpainless.sop.TestUtils.ARMOR_PRIVATE_KEY_HEADER_BYTES; +import static org.pgpainless.cli.TestUtils.ARMOR_PRIVATE_KEY_HEADER_BYTES; import java.io.ByteArrayOutputStream; import java.io.File; @@ -26,15 +26,15 @@ import java.io.IOException; import java.io.PrintStream; import java.util.Arrays; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPSecretKeyRing; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.pgpainless.PGPainless; +import org.pgpainless.cli.PGPainlessCLI; +import org.pgpainless.cli.TestUtils; import org.pgpainless.key.info.KeyRingInfo; -import org.pgpainless.sop.PGPainlessCLI; -import org.pgpainless.sop.TestUtils; -import picocli.CommandLine; public class GenerateCertTest { @@ -47,10 +47,11 @@ public class GenerateCertTest { } @Test + @FailOnSystemExit public void testKeyGeneration() throws IOException, PGPException { ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("generate-key", "--armor", "Juliet Capulet "); + PGPainlessCLI.execute("generate-key", "--armor", "Juliet Capulet "); PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(out.toByteArray()); KeyRingInfo info = PGPainless.inspectKeyRing(secretKeys); @@ -62,10 +63,11 @@ public class GenerateCertTest { } @Test + @FailOnSystemExit public void testNoArmor() { ByteArrayOutputStream out = new ByteArrayOutputStream(); System.setOut(new PrintStream(out)); - new CommandLine(new PGPainlessCLI()).execute("generate-key", "--no-armor", "Test "); + PGPainlessCLI.execute("generate-key", "--no-armor", "Test "); byte[] outBegin = new byte[37]; System.arraycopy(out.toByteArray(), 0, outBegin, 0, 37); diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/SignVerifyTest.java b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/SignVerifyTest.java similarity index 90% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/commands/SignVerifyTest.java rename to pgpainless-cli/src/test/java/org/pgpainless/cli/commands/SignVerifyTest.java index 76c28ba7..27fedbec 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/commands/SignVerifyTest.java +++ b/pgpainless-cli/src/test/java/org/pgpainless/cli/commands/SignVerifyTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; +package org.pgpainless.cli.commands; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -32,6 +32,7 @@ import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; import java.util.Date; +import com.ginsberg.junit.exit.FailOnSystemExit; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.bouncycastle.openpgp.PGPSecretKeyRing; @@ -40,12 +41,11 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.pgpainless.PGPainless; +import org.pgpainless.cli.PGPainlessCLI; +import org.pgpainless.cli.TestUtils; import org.pgpainless.key.OpenPgpV4Fingerprint; import org.pgpainless.key.info.KeyRingInfo; import org.pgpainless.key.util.KeyRingUtils; -import org.pgpainless.sop.PGPainlessCLI; -import org.pgpainless.sop.TestUtils; -import picocli.CommandLine; public class SignVerifyTest { @@ -60,6 +60,7 @@ public class SignVerifyTest { } @Test + @FailOnSystemExit public void testSignatureCreationAndVerification() throws PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IOException { originalSout = System.out; InputStream originalIn = System.in; @@ -95,7 +96,7 @@ public class SignVerifyTest { assertTrue(sigFile.createNewFile()); FileOutputStream sigOut = new FileOutputStream(sigFile); System.setOut(new PrintStream(sigOut)); - new CommandLine(new PGPainlessCLI()).execute("sign", "--armor", aliceKeyFile.getAbsolutePath()); + PGPainlessCLI.execute("sign", "--armor", aliceKeyFile.getAbsolutePath()); sigOut.close(); // verify test data signature @@ -103,7 +104,7 @@ public class SignVerifyTest { System.setOut(new PrintStream(verifyOut)); dataIn = new FileInputStream(dataFile); System.setIn(dataIn); - new CommandLine(new PGPainlessCLI()).execute("verify", sigFile.getAbsolutePath(), aliceCertFile.getAbsolutePath()); + PGPainlessCLI.execute("verify", sigFile.getAbsolutePath(), aliceCertFile.getAbsolutePath()); dataIn.close(); // Test verification output @@ -113,8 +114,8 @@ public class SignVerifyTest { String[] split = verification.split(" "); OpenPgpV4Fingerprint primaryKeyFingerprint = new OpenPgpV4Fingerprint(aliceKeys); OpenPgpV4Fingerprint signingKeyFingerprint = new OpenPgpV4Fingerprint(new KeyRingInfo(alicePub, new Date()).getSigningSubkeys().get(0)); - assertEquals(signingKeyFingerprint.toString(), split[1]); - assertEquals(primaryKeyFingerprint.toString(), split[2]); + assertEquals(signingKeyFingerprint.toString(), split[1].trim()); + assertEquals(primaryKeyFingerprint.toString(), split[2].trim()); System.setIn(originalIn); } diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java index 15695a6d..cdf79fa9 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java @@ -16,6 +16,7 @@ package org.pgpainless.decryption_verification; import java.io.BufferedInputStream; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -76,8 +77,10 @@ public final class DecryptionStreamFactory { private final ConsumerOptions options; private final OpenPgpMetadata.Builder resultBuilder = OpenPgpMetadata.getBuilder(); - private static final PGPContentVerifierBuilderProvider verifierBuilderProvider = ImplementationFactory.getInstance().getPGPContentVerifierBuilderProvider(); - private static final KeyFingerPrintCalculator keyFingerprintCalculator = ImplementationFactory.getInstance().getKeyFingerprintCalculator(); + private static final PGPContentVerifierBuilderProvider verifierBuilderProvider = + ImplementationFactory.getInstance().getPGPContentVerifierBuilderProvider(); + private static final KeyFingerPrintCalculator keyFingerprintCalculator = + ImplementationFactory.getInstance().getKeyFingerprintCalculator(); private final Map verifiableOnePassSignatures = new HashMap<>(); private final List integrityProtectedStreams = new ArrayList<>(); @@ -109,11 +112,23 @@ public final class DecryptionStreamFactory { try { // Parse OpenPGP message inputStream = factory.processPGPPackets(objectFactory, 1); - } catch (MissingLiteralDataException e) { - // Not an OpenPGP message. Reset the buffered stream to parse the message as arbitrary binary data + } catch (EOFException e) { + throw e; + } + catch (MissingLiteralDataException e) { + // Not an OpenPGP message. + // Reset the buffered stream to parse the message as arbitrary binary data // to allow for detached signature verification. bufferedIn.reset(); inputStream = bufferedIn; + } catch (IOException e) { + if (e.getMessage().contains("invalid armor")) { + // We falsely assumed the data to be armored. + bufferedIn.reset(); + inputStream = bufferedIn; + } else { + throw e; + } } return new DecryptionStream(inputStream, factory.resultBuilder, factory.integrityProtectedStreams); @@ -146,7 +161,9 @@ public final class DecryptionStreamFactory { throws PGPException, IOException { LOGGER.log(LEVEL, "Encountered PGPEncryptedDataList"); InputStream decryptedDataStream = decrypt(pgpEncryptedDataList); - return processPGPPackets(new PGPObjectFactory(PGPUtil.getDecoderStream(decryptedDataStream), keyFingerprintCalculator), ++depth); + InputStream decodedDataStream = PGPUtil.getDecoderStream(decryptedDataStream); + PGPObjectFactory factory = new PGPObjectFactory(decodedDataStream, keyFingerprintCalculator); + return processPGPPackets(factory, ++depth); } private InputStream processPGPCompressedData(PGPCompressedData pgpCompressedData, int depth) @@ -155,8 +172,9 @@ public final class DecryptionStreamFactory { LOGGER.log(LEVEL, "Encountered PGPCompressedData: " + compressionAlgorithm); resultBuilder.setCompressionAlgorithm(compressionAlgorithm); - InputStream dataStream = pgpCompressedData.getDataStream(); - PGPObjectFactory objectFactory = new PGPObjectFactory(PGPUtil.getDecoderStream(dataStream), keyFingerprintCalculator); + InputStream inflatedDataStream = pgpCompressedData.getDataStream(); + InputStream decodedDataStream = PGPUtil.getDecoderStream(inflatedDataStream); + PGPObjectFactory objectFactory = new PGPObjectFactory(decodedDataStream, keyFingerprintCalculator); return processPGPPackets(objectFactory, ++depth); } @@ -171,11 +189,10 @@ public final class DecryptionStreamFactory { private InputStream processPGPLiteralData(@Nonnull PGPObjectFactory objectFactory, PGPLiteralData pgpLiteralData) { LOGGER.log(LEVEL, "Found PGPLiteralData"); InputStream literalDataInputStream = pgpLiteralData.getInputStream(); - OpenPgpMetadata.FileInfo fileInfo = new OpenPgpMetadata.FileInfo( - pgpLiteralData.getFileName(), - pgpLiteralData.getModificationTime(), - StreamEncoding.fromCode(pgpLiteralData.getFormat())); - resultBuilder.setFileInfo(fileInfo); + + resultBuilder.setFileName(pgpLiteralData.getFileName()) + .setModificationDate(pgpLiteralData.getModificationTime()) + .setFileEncoding(StreamEncoding.fromCode(pgpLiteralData.getFormat())); if (verifiableOnePassSignatures.isEmpty()) { LOGGER.log(LEVEL, "No OnePassSignatures found -> We are done"); @@ -226,9 +243,6 @@ public final class DecryptionStreamFactory { // data is public key encrypted else if (encryptedData instanceof PGPPublicKeyEncryptedData) { - if (options.getDecryptionKeys().isEmpty()) { - - } PGPPublicKeyEncryptedData publicKeyEncryptedData = (PGPPublicKeyEncryptedData) encryptedData; long keyId = publicKeyEncryptedData.getKeyID(); if (!options.getDecryptionKeys().isEmpty()) { diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/OpenPgpMetadata.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/OpenPgpMetadata.java index 624ae3ca..cc658723 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/OpenPgpMetadata.java +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/OpenPgpMetadata.java @@ -24,6 +24,8 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.Nonnull; + import org.bouncycastle.openpgp.PGPLiteralData; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.openpgp.PGPPublicKeyRing; @@ -44,7 +46,9 @@ public class OpenPgpMetadata { private final List detachedSignatures; private final SymmetricKeyAlgorithm symmetricKeyAlgorithm; private final CompressionAlgorithm compressionAlgorithm; - private final FileInfo fileInfo; + private final String fileName; + private final Date modificationDate; + private final StreamEncoding fileEncoding; public OpenPgpMetadata(Set recipientKeyIds, SubkeyIdentifier decryptionKey, @@ -52,7 +56,9 @@ public class OpenPgpMetadata { CompressionAlgorithm algorithm, List onePassSignatures, List detachedSignatures, - FileInfo fileInfo) { + String fileName, + Date modificationDate, + StreamEncoding fileEncoding) { this.recipientKeyIds = Collections.unmodifiableSet(recipientKeyIds); this.decryptionKey = decryptionKey; @@ -60,7 +66,9 @@ public class OpenPgpMetadata { this.compressionAlgorithm = algorithm; this.detachedSignatures = Collections.unmodifiableList(detachedSignatures); this.onePassSignatures = Collections.unmodifiableList(onePassSignatures); - this.fileInfo = fileInfo; + this.fileName = fileName; + this.modificationDate = modificationDate; + this.fileEncoding = fileEncoding; } public Set getRecipientKeyIds() { @@ -148,12 +156,59 @@ public class OpenPgpMetadata { } } + /** + * Return information about the encrypted/signed file. + * + * @deprecated use {@link #getFileName()}, {@link #getModificationDate()} and {@link #getFileEncoding()} instead. + * @return file info + */ + @Deprecated public FileInfo getFileInfo() { - return fileInfo; + return new FileInfo( + getFileName(), + getModificationDate(), + getFileEncoding() + ); } + /** + * Return the name of the encrypted / signed file. + * + * @return file name + */ + public String getFileName() { + return fileName; + } + + /** + * Return true, if the encrypted data is intended for your eyes only. + * + * @return true if for-your-eyes-only + */ + public boolean isForYourEyesOnly() { + return PGPLiteralData.CONSOLE.equals(getFileName()); + } + + /** + * Return the modification date of the encrypted / signed file. + * + * @return modification date + */ + public Date getModificationDate() { + return modificationDate; + } + + /** + * Return the encoding format of the encrypted / signed file. + * + * @return encoding + */ + public StreamEncoding getFileEncoding() { + return fileEncoding; + } + + @Deprecated public static class FileInfo { - public static final String FOR_YOUR_EYES_ONLY = PGPLiteralData.CONSOLE; protected final String fileName; protected final Date modificationDate; @@ -165,22 +220,10 @@ public class OpenPgpMetadata { this.streamEncoding = streamEncoding; } - public static FileInfo binaryStream() { - return new FileInfo("", null, StreamEncoding.BINARY); - } - - public static FileInfo forYourEyesOnly() { - return new FileInfo(FOR_YOUR_EYES_ONLY, null, StreamEncoding.BINARY); - } - public String getFileName() { return fileName; } - public boolean isForYourEyesOnly() { - return FOR_YOUR_EYES_ONLY.equals(fileName); - } - public Date getModificationDate() { return modificationDate; } @@ -243,7 +286,9 @@ public class OpenPgpMetadata { private final List onePassSignatures = new ArrayList<>(); private SymmetricKeyAlgorithm symmetricKeyAlgorithm = SymmetricKeyAlgorithm.NULL; private CompressionAlgorithm compressionAlgorithm = CompressionAlgorithm.UNCOMPRESSED; - private FileInfo fileInfo; + private String fileName; + private StreamEncoding fileEncoding; + private Date modificationDate; public Builder addRecipientKeyId(Long keyId) { this.recipientFingerprints.add(keyId); @@ -269,6 +314,21 @@ public class OpenPgpMetadata { return this; } + public Builder setFileName(@Nonnull String fileName) { + this.fileName = fileName; + return this; + } + + public Builder setModificationDate(Date modificationDate) { + this.modificationDate = modificationDate; + return this; + } + + public Builder setFileEncoding(StreamEncoding encoding) { + this.fileEncoding = encoding; + return this; + } + public void addDetachedSignature(DetachedSignature signature) { this.detachedSignatures.add(signature); } @@ -277,15 +337,10 @@ public class OpenPgpMetadata { this.onePassSignatures.add(onePassSignature); } - public Builder setFileInfo(FileInfo fileInfo) { - this.fileInfo = fileInfo; - return this; - } - public OpenPgpMetadata build() { return new OpenPgpMetadata(recipientFingerprints, decryptionKey, symmetricKeyAlgorithm, compressionAlgorithm, - onePassSignatures, detachedSignatures, fileInfo); + onePassSignatures, detachedSignatures, fileName, modificationDate, fileEncoding); } } } diff --git a/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionResult.java b/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionResult.java index e050ad04..d488e76b 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionResult.java +++ b/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionResult.java @@ -16,11 +16,16 @@ package org.pgpainless.encryption_signing; import java.util.Collections; +import java.util.Date; import java.util.HashSet; import java.util.Set; +import javax.annotation.Nonnull; + +import org.bouncycastle.openpgp.PGPLiteralData; import org.bouncycastle.openpgp.PGPSignature; import org.pgpainless.algorithm.CompressionAlgorithm; +import org.pgpainless.algorithm.StreamEncoding; import org.pgpainless.algorithm.SymmetricKeyAlgorithm; import org.pgpainless.decryption_verification.OpenPgpMetadata; import org.pgpainless.key.SubkeyIdentifier; @@ -33,18 +38,24 @@ public final class EncryptionResult { private final MultiMap detachedSignatures; private final Set recipients; - private final OpenPgpMetadata.FileInfo fileInfo; + private final String fileName; + private final Date modificationDate; + private final StreamEncoding fileEncoding; private EncryptionResult(SymmetricKeyAlgorithm encryptionAlgorithm, CompressionAlgorithm compressionAlgorithm, MultiMap detachedSignatures, Set recipients, - OpenPgpMetadata.FileInfo fileInfo) { + String fileName, + Date modificationDate, + StreamEncoding encoding) { this.encryptionAlgorithm = encryptionAlgorithm; this.compressionAlgorithm = compressionAlgorithm; this.detachedSignatures = detachedSignatures; this.recipients = Collections.unmodifiableSet(recipients); - this.fileInfo = fileInfo; + this.fileName = fileName; + this.modificationDate = modificationDate; + this.fileEncoding = encoding; } @Deprecated @@ -68,14 +79,37 @@ public final class EncryptionResult { return recipients; } + /** + * Return information about the encrypted / signed data. + * + * @deprecated use {@link #getFileName()}, {@link #getModificationDate()} and {@link #getFileEncoding()} instead. + * @return info + */ + @Deprecated public OpenPgpMetadata.FileInfo getFileInfo() { - return fileInfo; + return new OpenPgpMetadata.FileInfo(getFileName(), getModificationDate(), getFileEncoding()); + } + + public String getFileName() { + return fileName; + } + + public Date getModificationDate() { + return modificationDate; + } + + public StreamEncoding getFileEncoding() { + return fileEncoding; } public static Builder builder() { return new Builder(); } + public boolean isForYourEyesOnly() { + return PGPLiteralData.CONSOLE.equals(getFileName()); + } + public static class Builder { private SymmetricKeyAlgorithm encryptionAlgorithm; @@ -83,7 +117,9 @@ public final class EncryptionResult { private final MultiMap detachedSignatures = new MultiMap<>(); private Set recipients = new HashSet<>(); - private OpenPgpMetadata.FileInfo fileInfo; + private String fileName = ""; + private Date modificationDate = new Date(0L); // NOW + private StreamEncoding encoding = StreamEncoding.BINARY; public Builder setEncryptionAlgorithm(SymmetricKeyAlgorithm encryptionAlgorithm) { this.encryptionAlgorithm = encryptionAlgorithm; @@ -105,8 +141,18 @@ public final class EncryptionResult { return this; } - public Builder setFileInfo(OpenPgpMetadata.FileInfo fileInfo) { - this.fileInfo = fileInfo; + public Builder setFileName(@Nonnull String fileName) { + this.fileName = fileName; + return this; + } + + public Builder setModificationDate(@Nonnull Date modificationDate) { + this.modificationDate = modificationDate; + return this; + } + + public Builder setFileEncoding(StreamEncoding fileEncoding) { + this.encoding = fileEncoding; return this; } @@ -117,11 +163,9 @@ public final class EncryptionResult { if (compressionAlgorithm == null) { throw new IllegalStateException("Compression algorithm not set."); } - if (fileInfo == null) { - throw new IllegalStateException("File info not set."); - } - return new EncryptionResult(encryptionAlgorithm, compressionAlgorithm, detachedSignatures, recipients, fileInfo); + return new EncryptionResult(encryptionAlgorithm, compressionAlgorithm, detachedSignatures, recipients, + fileName, modificationDate, encoding); } } } diff --git a/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionStream.java b/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionStream.java index 8b9b9a19..1b00856c 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionStream.java +++ b/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/EncryptionStream.java @@ -33,7 +33,6 @@ import org.bouncycastle.openpgp.operator.PGPDataEncryptorBuilder; import org.bouncycastle.openpgp.operator.PGPKeyEncryptionMethodGenerator; import org.pgpainless.algorithm.CompressionAlgorithm; import org.pgpainless.algorithm.SymmetricKeyAlgorithm; -import org.pgpainless.decryption_verification.OpenPgpMetadata; import org.pgpainless.implementation.ImplementationFactory; import org.pgpainless.key.SubkeyIdentifier; import org.pgpainless.util.ArmoredOutputStreamFactory; @@ -154,8 +153,9 @@ public final class EncryptionStream extends OutputStream { new byte[BUFFER_SIZE]); outermostStream = literalDataStream; - resultBuilder.setFileInfo(new OpenPgpMetadata.FileInfo( - options.getFileName(), options.getModificationDate(), options.getEncoding())); + resultBuilder.setFileName(options.getFileName()) + .setModificationDate(options.getModificationDate()) + .setFileEncoding(options.getEncoding()); } @Override @@ -256,4 +256,8 @@ public final class EncryptionStream extends OutputStream { } return resultBuilder.build(); } + + public boolean isClosed() { + return closed; + } } diff --git a/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/SigningOptions.java b/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/SigningOptions.java index 099dcd17..8f19ad4b 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/SigningOptions.java +++ b/pgpainless-core/src/main/java/org/pgpainless/encryption_signing/SigningOptions.java @@ -33,11 +33,14 @@ import org.bouncycastle.openpgp.operator.PGPContentSignerBuilder; import org.pgpainless.PGPainless; import org.pgpainless.algorithm.DocumentSignatureType; import org.pgpainless.algorithm.HashAlgorithm; +import org.pgpainless.exception.KeyCannotSignException; import org.pgpainless.exception.KeyValidationException; import org.pgpainless.implementation.ImplementationFactory; +import org.pgpainless.key.OpenPgpV4Fingerprint; import org.pgpainless.key.SubkeyIdentifier; import org.pgpainless.key.info.KeyRingInfo; import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.key.protection.UnlockSecretKey; import org.pgpainless.policy.Policy; public final class SigningOptions { @@ -161,12 +164,12 @@ public final class SigningOptions { List signingPubKeys = keyRingInfo.getSigningSubkeys(); if (signingPubKeys.isEmpty()) { - throw new AssertionError("Key has no valid signing key."); + throw new KeyCannotSignException("Key " + new OpenPgpV4Fingerprint(secretKey) + " has no valid signing key."); } for (PGPPublicKey signingPubKey : signingPubKeys) { PGPSecretKey signingSecKey = secretKey.getSecretKey(signingPubKey.getKeyID()); - PGPPrivateKey signingSubkey = signingSecKey.extractPrivateKey(secretKeyDecryptor.getDecryptor(signingPubKey.getKeyID())); + PGPPrivateKey signingSubkey = UnlockSecretKey.unlockSecretKey(signingSecKey, secretKeyDecryptor); Set hashAlgorithms = keyRingInfo.getPreferredHashAlgorithms(userId, signingPubKey.getKeyID()); HashAlgorithm hashAlgorithm = negotiateHashAlgorithm(hashAlgorithms, PGPainless.getPolicy()); addSigningMethod(secretKey, signingSubkey, hashAlgorithm, signatureType, false); @@ -242,7 +245,7 @@ public final class SigningOptions { List signingPubKeys = keyRingInfo.getSigningSubkeys(); if (signingPubKeys.isEmpty()) { - throw new AssertionError("Key has no valid signing key."); + throw new KeyCannotSignException("Key has no valid signing key."); } for (PGPPublicKey signingPubKey : signingPubKeys) { diff --git a/pgpainless-core/src/main/java/org/pgpainless/exception/KeyCannotSignException.java b/pgpainless-core/src/main/java/org/pgpainless/exception/KeyCannotSignException.java new file mode 100644 index 00000000..bb3d6574 --- /dev/null +++ b/pgpainless-core/src/main/java/org/pgpainless/exception/KeyCannotSignException.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.exception; + +import org.bouncycastle.openpgp.PGPException; + +public class KeyCannotSignException extends PGPException { + public KeyCannotSignException(String message) { + super(message); + } +} diff --git a/pgpainless-core/src/main/java/org/pgpainless/util/ArmorUtils.java b/pgpainless-core/src/main/java/org/pgpainless/util/ArmorUtils.java index bb6bad9e..10855877 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/util/ArmorUtils.java +++ b/pgpainless-core/src/main/java/org/pgpainless/util/ArmorUtils.java @@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -68,6 +69,23 @@ public class ArmorUtils { return sb.toString(); } + public static ArmoredOutputStream toAsciiArmoredStream(PGPKeyRing keyRing, OutputStream outputStream) { + MultiMap header = keyToHeader(keyRing); + return toAsciiArmoredStream(outputStream, header); + } + + public static ArmoredOutputStream toAsciiArmoredStream(OutputStream outputStream, MultiMap header) { + ArmoredOutputStream armoredOutputStream = ArmoredOutputStreamFactory.get(outputStream); + if (header != null) { + for (String headerKey : header.keySet()) { + for (String headerValue : header.get(headerKey)) { + armoredOutputStream.addHeader(headerKey, headerValue); + } + } + } + return armoredOutputStream; + } + public static String toAsciiArmoredString(PGPPublicKeyRingCollection publicKeyRings) throws IOException { StringBuilder sb = new StringBuilder(); for (Iterator iterator = publicKeyRings.iterator(); iterator.hasNext(); ) { @@ -124,20 +142,25 @@ public class ArmorUtils { public static String toAsciiArmoredString(InputStream inputStream, MultiMap additionalHeaderValues) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); - ArmoredOutputStream armor = ArmoredOutputStreamFactory.get(out); - if (additionalHeaderValues != null) { - for (String header : additionalHeaderValues.keySet()) { - for (String value : additionalHeaderValues.get(header)) { - armor.addHeader(header, value); - } - } - } + ArmoredOutputStream armor = toAsciiArmoredStream(out, additionalHeaderValues); Streams.pipeAll(inputStream, armor); armor.close(); return out.toString(); } + public static ArmoredOutputStream createArmoredOutputStreamFor(PGPKeyRing keyRing, OutputStream outputStream) { + ArmoredOutputStream armor = ArmoredOutputStreamFactory.get(outputStream); + MultiMap headerMap = keyToHeader(keyRing); + for (String header : headerMap.keySet()) { + for (String value : headerMap.get(header)) { + armor.addHeader(header, value); + } + } + + return armor; + } + public static List getCommendHeaderValues(ArmoredInputStream armor) { return getArmorHeaderValues(armor, HEADER_COMMENT); } diff --git a/pgpainless-core/src/test/java/org/junit/JUtils.java b/pgpainless-core/src/test/java/org/junit/JUtils.java index c053027f..6fced9c7 100644 --- a/pgpainless-core/src/test/java/org/junit/JUtils.java +++ b/pgpainless-core/src/test/java/org/junit/JUtils.java @@ -17,9 +17,17 @@ package org.junit; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Date; + +import org.pgpainless.util.DateUtil; + public class JUtils { public static void assertEquals(long a, long b, long delta) { assertTrue(a - delta <= b && a + delta >= b); } + + public static void assertDateEquals(Date a, Date b) { + org.junit.jupiter.api.Assertions.assertEquals(DateUtil.formatUTCDate(a), DateUtil.formatUTCDate(b)); + } } diff --git a/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/FileInfoTest.java b/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/FileInfoTest.java deleted file mode 100644 index ad83cdc6..00000000 --- a/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/FileInfoTest.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright 2021 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.encryption_signing; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.InvalidAlgorithmParameterException; -import java.security.NoSuchAlgorithmException; -import java.util.Date; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.bouncycastle.util.io.Streams; -import org.junit.jupiter.api.Test; -import org.pgpainless.PGPainless; -import org.pgpainless.algorithm.StreamEncoding; -import org.pgpainless.decryption_verification.ConsumerOptions; -import org.pgpainless.decryption_verification.DecryptionStream; -import org.pgpainless.decryption_verification.OpenPgpMetadata; -import org.pgpainless.key.util.KeyRingUtils; - -public class FileInfoTest { - - @Test - public void textFile() throws NoSuchAlgorithmException, PGPException, InvalidAlgorithmParameterException, IOException { - OpenPgpMetadata.FileInfo fileInfo = new OpenPgpMetadata.FileInfo("message.txt", new Date(), StreamEncoding.TEXT); - executeWith(fileInfo); - } - - @Test - public void binaryStream() throws NoSuchAlgorithmException, PGPException, InvalidAlgorithmParameterException, IOException { - OpenPgpMetadata.FileInfo fileInfo = OpenPgpMetadata.FileInfo.binaryStream(); - executeWith(fileInfo); - } - - @Test - public void forYourEyesOnly() throws NoSuchAlgorithmException, PGPException, InvalidAlgorithmParameterException, IOException { - OpenPgpMetadata.FileInfo fileInfo = OpenPgpMetadata.FileInfo.forYourEyesOnly(); - executeWith(fileInfo); - } - - public void executeWith(OpenPgpMetadata.FileInfo fileInfo) throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, PGPException, IOException { - PGPSecretKeyRing secretKeys = PGPainless.generateKeyRing().simpleEcKeyRing("alice@wonderland.lit"); - PGPPublicKeyRing publicKeys = KeyRingUtils.publicKeyRingFrom(secretKeys); - - String data = "Hello, World!"; - - ByteArrayInputStream dataIn = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); - ByteArrayOutputStream dataOut = new ByteArrayOutputStream(); - EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() - .onOutputStream(dataOut) - .withOptions(ProducerOptions.encrypt( - EncryptionOptions - .encryptCommunications() - .addRecipient(publicKeys)) - .setEncoding(fileInfo.getStreamFormat()) - .setFileName(fileInfo.getFileName()) - .setModificationDate(fileInfo.getModificationDate()) - ); - - Streams.pipeAll(dataIn, encryptionStream); - encryptionStream.close(); - - OpenPgpMetadata.FileInfo cryptInfo = encryptionStream.getResult().getFileInfo(); - assertEquals(fileInfo, cryptInfo); - - ByteArrayInputStream cryptIn = new ByteArrayInputStream(dataOut.toByteArray()); - ByteArrayOutputStream plainOut = new ByteArrayOutputStream(); - - DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() - .onInputStream(cryptIn) - .withOptions(new ConsumerOptions() - .addDecryptionKey(secretKeys)); - Streams.pipeAll(decryptionStream, plainOut); - - decryptionStream.close(); - - OpenPgpMetadata.FileInfo decryptInfo = decryptionStream.getResult().getFileInfo(); - assertEquals(fileInfo, decryptInfo); - } -} diff --git a/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/FileInformationTest.java b/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/FileInformationTest.java new file mode 100644 index 00000000..9e825e46 --- /dev/null +++ b/pgpainless-core/src/test/java/org/pgpainless/encryption_signing/FileInformationTest.java @@ -0,0 +1,181 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.encryption_signing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.util.Date; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPLiteralData; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.util.io.Streams; +import org.junit.JUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.StreamEncoding; +import org.pgpainless.decryption_verification.ConsumerOptions; +import org.pgpainless.decryption_verification.DecryptionStream; +import org.pgpainless.decryption_verification.OpenPgpMetadata; + +public class FileInformationTest { + + private static final String data = "Hello, World!\n"; + private static PGPSecretKeyRing secretKey; + private static PGPPublicKeyRing certificate; + + @BeforeAll + public static void generateKey() throws PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException { + secretKey = PGPainless.generateKeyRing().modernKeyRing("alice@wonderland.lit", null); + certificate = PGPainless.extractCertificate(secretKey); + } + + @Test + public void testTextFile() throws PGPException, IOException { + String fileName = "message.txt"; + Date modificationDate = new Date(); + StreamEncoding encoding = StreamEncoding.TEXT; + + ByteArrayInputStream dataIn = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream dataOut = new ByteArrayOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(dataOut) + .withOptions(ProducerOptions.encrypt( + EncryptionOptions + .encryptCommunications() + .addRecipient(certificate)) + .setFileName(fileName) + .setModificationDate(modificationDate) + .setEncoding(encoding) + ); + + Streams.pipeAll(dataIn, encryptionStream); + encryptionStream.close(); + + EncryptionResult encResult = encryptionStream.getResult(); + assertEquals(fileName, encResult.getFileName()); + JUtils.assertDateEquals(modificationDate, encResult.getModificationDate()); + assertEquals(encoding, encResult.getFileEncoding()); + + ByteArrayInputStream cryptIn = new ByteArrayInputStream(dataOut.toByteArray()); + ByteArrayOutputStream plainOut = new ByteArrayOutputStream(); + + DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(cryptIn) + .withOptions(new ConsumerOptions() + .addDecryptionKey(secretKey)); + Streams.pipeAll(decryptionStream, plainOut); + + decryptionStream.close(); + + OpenPgpMetadata decResult = decryptionStream.getResult(); + + assertEquals(fileName, decResult.getFileName()); + JUtils.assertDateEquals(modificationDate, decResult.getModificationDate()); + assertEquals(encoding, decResult.getFileEncoding()); + } + + @Test + public void testDefaults() throws PGPException, IOException { + ByteArrayInputStream dataIn = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream dataOut = new ByteArrayOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(dataOut) + .withOptions(ProducerOptions.encrypt( + EncryptionOptions + .encryptCommunications() + .addRecipient(certificate)) + ); + + Streams.pipeAll(dataIn, encryptionStream); + encryptionStream.close(); + + EncryptionResult encResult = encryptionStream.getResult(); + assertEquals("", encResult.getFileName()); + JUtils.assertDateEquals(PGPLiteralData.NOW, encResult.getModificationDate()); + assertEquals(PGPLiteralData.BINARY, encResult.getFileEncoding().getCode()); + assertFalse(encResult.isForYourEyesOnly()); + + ByteArrayInputStream cryptIn = new ByteArrayInputStream(dataOut.toByteArray()); + ByteArrayOutputStream plainOut = new ByteArrayOutputStream(); + + DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(cryptIn) + .withOptions(new ConsumerOptions() + .addDecryptionKey(secretKey)); + Streams.pipeAll(decryptionStream, plainOut); + + decryptionStream.close(); + + OpenPgpMetadata decResult = decryptionStream.getResult(); + + assertEquals("", decResult.getFileName()); + JUtils.assertDateEquals(PGPLiteralData.NOW, decResult.getModificationDate()); + assertEquals(PGPLiteralData.BINARY, decResult.getFileEncoding().getCode()); + assertFalse(decResult.isForYourEyesOnly()); + } + + @Test + public void testForYourEyesOnly() throws PGPException, IOException { + ByteArrayInputStream dataIn = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream dataOut = new ByteArrayOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(dataOut) + .withOptions(ProducerOptions.encrypt( + EncryptionOptions + .encryptCommunications() + .addRecipient(certificate)) + .setForYourEyesOnly() + ); + + Streams.pipeAll(dataIn, encryptionStream); + encryptionStream.close(); + + EncryptionResult encResult = encryptionStream.getResult(); + assertEquals(PGPLiteralData.CONSOLE, encResult.getFileName()); + JUtils.assertDateEquals(PGPLiteralData.NOW, encResult.getModificationDate()); + assertEquals(PGPLiteralData.BINARY, encResult.getFileEncoding().getCode()); + assertTrue(encResult.isForYourEyesOnly()); + + ByteArrayInputStream cryptIn = new ByteArrayInputStream(dataOut.toByteArray()); + ByteArrayOutputStream plainOut = new ByteArrayOutputStream(); + + DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(cryptIn) + .withOptions(new ConsumerOptions() + .addDecryptionKey(secretKey)); + Streams.pipeAll(decryptionStream, plainOut); + + decryptionStream.close(); + + OpenPgpMetadata decResult = decryptionStream.getResult(); + + assertEquals(PGPLiteralData.CONSOLE, decResult.getFileName()); + JUtils.assertDateEquals(PGPLiteralData.NOW, decResult.getModificationDate()); + assertEquals(PGPLiteralData.BINARY, decResult.getFileEncoding().getCode()); + assertTrue(decResult.isForYourEyesOnly()); + } +} diff --git a/pgpainless-sop/build.gradle b/pgpainless-sop/build.gradle index 054e1fdf..02f6adb8 100644 --- a/pgpainless-sop/build.gradle +++ b/pgpainless-sop/build.gradle @@ -1,59 +1,23 @@ plugins { - id 'application' + id 'java' +} + +group 'org.pgpainless' +version '0.2.1-SNAPSHOT' + +repositories { + mavenCentral() } dependencies { + implementation(project(":pgpainless-core")) + implementation(project(":sop-java")) - implementation 'info.picocli:picocli:4.5.2' - - testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" - testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" - - /* - implementation "org.bouncycastle:bcprov-debug-jdk15on:$bouncyCastleVersion" - /*/ - implementation "org.bouncycastle:bcprov-jdk15on:$bouncyCastleVersion" - //*/ - implementation "org.bouncycastle:bcpg-jdk15on:$bouncyCastleVersion" - - // https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305 - implementation group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2' -} - -mainClassName = 'org.pgpainless.sop.PGPainlessCLI' - -def generatedVersionDir = "${buildDir}/generated-version" - -sourceSets { - main { - output.dir(generatedVersionDir, builtBy: 'generateVersionProperties') - } -} - -task generateVersionProperties { - doLast { - def propertiesFile = file "$generatedVersionDir/version.properties" - propertiesFile.parentFile.mkdirs() - propertiesFile.createNewFile() - // Instead of using a Properties object here, we directly write to the file - // since Properties adds a timestamp, ruining reproducibility - propertiesFile.write("version="+rootProject.version.toString()) - } -} -processResources.dependsOn generateVersionProperties - -jar { - manifest { - attributes 'Main-Class': "$mainClassName" - } - - from { - configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } - } { - exclude "META-INF/*.SF" - exclude "META-INF/*.DSA" - exclude "META-INF/*.RSA" - } + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' } +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/ArmorImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/ArmorImpl.java new file mode 100644 index 00000000..2d2b93c4 --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/ArmorImpl.java @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PushbackInputStream; +import java.nio.charset.Charset; +import java.util.Arrays; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.util.io.Streams; +import org.pgpainless.util.ArmoredOutputStreamFactory; +import sop.Ready; +import sop.enums.ArmorLabel; +import sop.exception.SOPGPException; +import sop.operation.Armor; + +public class ArmorImpl implements Armor { + + public static final byte[] ARMOR_START = "-----BEGIN PGP".getBytes(Charset.forName("UTF8")); + + boolean allowNested = false; + + @Override + public Armor label(ArmorLabel label) throws SOPGPException.UnsupportedOption { + throw new SOPGPException.UnsupportedOption(); + } + + @Override + public Armor allowNested() throws SOPGPException.UnsupportedOption { + allowNested = true; + return this; + } + + @Override + public Ready data(InputStream data) throws SOPGPException.BadData { + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + PushbackInputStream pbIn = new PushbackInputStream(data, ARMOR_START.length); + byte[] buffer = new byte[ARMOR_START.length]; + int read = pbIn.read(buffer); + pbIn.unread(buffer, 0, read); + if (!allowNested && Arrays.equals(ARMOR_START, buffer)) { + Streams.pipeAll(pbIn, System.out); + } else { + ArmoredOutputStream armor = ArmoredOutputStreamFactory.get(System.out); + Streams.pipeAll(pbIn, armor); + armor.close(); + } + } + }; + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/DearmorImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/DearmorImpl.java new file mode 100644 index 00000000..e4b05a4b --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/DearmorImpl.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.util.io.Streams; +import sop.Ready; +import sop.operation.Dearmor; + +public class DearmorImpl implements Dearmor { + + @Override + public Ready data(InputStream data) throws IOException { + InputStream decoder = PGPUtil.getDecoderStream(data); + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + Streams.pipeAll(decoder, outputStream); + decoder.close(); + } + }; + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/DecryptImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/DecryptImpl.java new file mode 100644 index 00000000..94535e90 --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/DecryptImpl.java @@ -0,0 +1,184 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.io.Streams; +import org.pgpainless.PGPainless; +import org.pgpainless.decryption_verification.ConsumerOptions; +import org.pgpainless.decryption_verification.DecryptionStream; +import org.pgpainless.decryption_verification.OpenPgpMetadata; +import org.pgpainless.exception.NotYetImplementedException; +import org.pgpainless.key.SubkeyIdentifier; +import org.pgpainless.key.info.KeyRingInfo; +import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.util.Passphrase; +import sop.DecryptionResult; +import sop.ReadyWithResult; +import sop.SessionKey; +import sop.Verification; +import sop.exception.SOPGPException; +import sop.operation.Decrypt; + +public class DecryptImpl implements Decrypt { + + private final ConsumerOptions consumerOptions = new ConsumerOptions(); + + @Override + public DecryptImpl verifyNotBefore(Date timestamp) throws SOPGPException.UnsupportedOption { + try { + consumerOptions.verifyNotBefore(timestamp); + } catch (NotYetImplementedException e) { + // throw new SOPGPException.UnsupportedOption(); + } + return this; + } + + @Override + public DecryptImpl verifyNotAfter(Date timestamp) throws SOPGPException.UnsupportedOption { + try { + consumerOptions.verifyNotAfter(timestamp); + } catch (NotYetImplementedException e) { + // throw new SOPGPException.UnsupportedOption(); + } + return this; + } + + @Override + public DecryptImpl verifyWithCert(InputStream certIn) throws SOPGPException.BadData, IOException { + try { + PGPPublicKeyRingCollection certs = PGPainless.readKeyRing().keyRingCollection(certIn, false) + .getPgpPublicKeyRingCollection(); + if (certs == null) { + throw new SOPGPException.BadData(new PGPException("No certificates provided.")); + } + + consumerOptions.addVerificationCerts(certs); + + } catch (PGPException e) { + throw new SOPGPException.BadData(e); + } + return this; + } + + @Override + public DecryptImpl withSessionKey(SessionKey sessionKey) throws SOPGPException.UnsupportedOption { + throw new SOPGPException.UnsupportedOption(); + } + + @Override + public DecryptImpl withPassword(String password) throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + consumerOptions.addDecryptionPassphrase(Passphrase.fromPassword(password)); + String withoutTrailingWhitespace = removeTrailingWhitespace(password); + if (!password.equals(withoutTrailingWhitespace)) { + consumerOptions.addDecryptionPassphrase(Passphrase.fromPassword(withoutTrailingWhitespace)); + } + return this; + } + + private static String removeTrailingWhitespace(String passphrase) { + int i = passphrase.length() - 1; + // Find index of first non-whitespace character from the back + while (i > 0 && Character.isWhitespace(passphrase.charAt(i))) { + i--; + } + return passphrase.substring(0, i); + } + + @Override + public DecryptImpl withKey(InputStream keyIn) throws SOPGPException.KeyIsProtected, SOPGPException.BadData, SOPGPException.UnsupportedAsymmetricAlgo { + try { + PGPSecretKeyRingCollection secretKeys = PGPainless.readKeyRing() + .keyRingCollection(keyIn, true) + .getPGPSecretKeyRingCollection(); + + for (PGPSecretKeyRing secretKey : secretKeys) { + KeyRingInfo info = new KeyRingInfo(secretKey); + if (!info.isFullyDecrypted()) { + throw new SOPGPException.KeyIsProtected(); + } + } + + consumerOptions.addDecryptionKeys(secretKeys, SecretKeyRingProtector.unprotectedKeys()); + } catch (IOException | PGPException e) { + throw new SOPGPException.BadData(e); + } + return this; + } + + @Override + public ReadyWithResult ciphertext(InputStream ciphertext) + throws SOPGPException.BadData, + SOPGPException.MissingArg { + + if (consumerOptions.getDecryptionKeys().isEmpty() && consumerOptions.getDecryptionPassphrases().isEmpty()) { + throw new SOPGPException.MissingArg("Missing decryption key or passphrase."); + } + + DecryptionStream decryptionStream; + try { + decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(ciphertext) + .withOptions(consumerOptions); + } catch (PGPException | IOException e) { + throw new SOPGPException.BadData(e); + } + + return new ReadyWithResult() { + @Override + public DecryptionResult writeTo(OutputStream outputStream) throws IOException, SOPGPException.NoSignature { + Streams.pipeAll(decryptionStream, outputStream); + decryptionStream.close(); + OpenPgpMetadata metadata = decryptionStream.getResult(); + + List verificationList = new ArrayList<>(); + for (SubkeyIdentifier verifiedSigningKey : metadata.getVerifiedSignatures().keySet()) { + PGPSignature signature = metadata.getVerifiedSignatures().get(verifiedSigningKey); + Date verifyNotBefore = consumerOptions.getVerifyNotBefore(); + Date verifyNotAfter = consumerOptions.getVerifyNotAfter(); + + if (verifyNotAfter == null || !signature.getCreationTime().after(verifyNotAfter)) { + if (verifyNotBefore == null || !signature.getCreationTime().before(verifyNotBefore)) { + verificationList.add(new Verification( + signature.getCreationTime(), + verifiedSigningKey.getSubkeyFingerprint().toString(), + verifiedSigningKey.getPrimaryKeyFingerprint().toString())); + } + } + } + + if (!consumerOptions.getCertificates().isEmpty()) { + if (verificationList.isEmpty()) { + throw new SOPGPException.NoSignature(); + } + } + + return new DecryptionResult(null, verificationList); + } + }; + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/EncryptImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/EncryptImpl.java new file mode 100644 index 00000000..548129fa --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/EncryptImpl.java @@ -0,0 +1,140 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; +import org.bouncycastle.util.io.Streams; +import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.DocumentSignatureType; +import org.pgpainless.algorithm.StreamEncoding; +import org.pgpainless.encryption_signing.EncryptionOptions; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; +import org.pgpainless.encryption_signing.SigningOptions; +import org.pgpainless.exception.WrongPassphraseException; +import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.util.Passphrase; +import sop.util.ProxyOutputStream; +import sop.Ready; +import sop.enums.EncryptAs; +import sop.exception.SOPGPException; +import sop.operation.Encrypt; + +public class EncryptImpl implements Encrypt { + + EncryptionOptions encryptionOptions = new EncryptionOptions(); + SigningOptions signingOptions = null; + + private EncryptAs encryptAs = EncryptAs.Binary; + boolean armor = true; + + @Override + public Encrypt noArmor() { + armor = false; + return this; + } + + @Override + public Encrypt mode(EncryptAs mode) throws SOPGPException.UnsupportedOption { + this.encryptAs = mode; + return this; + } + + @Override + public Encrypt signWith(InputStream keyIn) throws SOPGPException.KeyIsProtected, SOPGPException.CertCannotSign, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.BadData { + try { + PGPSecretKeyRingCollection keys = PGPainless.readKeyRing().secretKeyRingCollection(keyIn); + + if (signingOptions == null) { + signingOptions = SigningOptions.get(); + } + try { + signingOptions.addInlineSignatures(SecretKeyRingProtector.unprotectedKeys(), keys, DocumentSignatureType.BINARY_DOCUMENT); + } catch (IllegalArgumentException e) { + throw new SOPGPException.CertCannotSign(); + } catch (WrongPassphraseException e) { + throw new SOPGPException.KeyIsProtected(); + } + } catch (IOException | PGPException e) { + throw new SOPGPException.BadData(e); + } + return this; + } + + @Override + public Encrypt withPassword(String password) throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + encryptionOptions.addPassphrase(Passphrase.fromPassword(password)); + return this; + } + + @Override + public Encrypt withCert(InputStream cert) throws SOPGPException.CertCannotEncrypt, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.BadData { + try { + PGPPublicKeyRingCollection certificates = PGPainless.readKeyRing() + .keyRingCollection(cert, false) + .getPgpPublicKeyRingCollection(); + encryptionOptions.addRecipients(certificates); + } catch (IOException | PGPException e) { + throw new SOPGPException.BadData(e); + } + return this; + } + + @Override + public Ready plaintext(InputStream plaintext) throws IOException { + ProducerOptions producerOptions = signingOptions != null ? + ProducerOptions.signAndEncrypt(encryptionOptions, signingOptions) : + ProducerOptions.encrypt(encryptionOptions); + producerOptions.setAsciiArmor(armor); + producerOptions.setEncoding(encryptAsToStreamEncoding(encryptAs)); + + try { + ProxyOutputStream proxy = new ProxyOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(proxy) + .withOptions(producerOptions); + + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + proxy.replaceOutputStream(outputStream); + Streams.pipeAll(plaintext, encryptionStream); + encryptionStream.close(); + } + }; + } catch (PGPException e) { + throw new IOException(); + } + } + + private static StreamEncoding encryptAsToStreamEncoding(EncryptAs encryptAs) { + switch (encryptAs) { + case Binary: + return StreamEncoding.BINARY; + case Text: + return StreamEncoding.TEXT; + case MIME: + return StreamEncoding.UTF8; + } + throw new IllegalArgumentException("Invalid value encountered: " + encryptAs); + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/ExtractCertImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/ExtractCertImpl.java new file mode 100644 index 00000000..20431664 --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/ExtractCertImpl.java @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.pgpainless.PGPainless; +import org.pgpainless.key.util.KeyRingUtils; +import org.pgpainless.util.ArmorUtils; +import sop.operation.ExtractCert; +import sop.Ready; +import sop.exception.SOPGPException; + +public class ExtractCertImpl implements ExtractCert { + + private boolean armor = true; + + @Override + public ExtractCert noArmor() { + armor = false; + return this; + } + + @Override + public Ready key(InputStream keyInputStream) throws IOException, SOPGPException.BadData { + try { + PGPSecretKeyRing key = PGPainless.readKeyRing().secretKeyRing(keyInputStream); + PGPPublicKeyRing cert = KeyRingUtils.publicKeyRingFrom(key); + + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + OutputStream out = armor ? ArmorUtils.createArmoredOutputStreamFor(cert, outputStream) : outputStream; + cert.encode(out); + + if (armor) { + out.close(); + } + } + }; + } catch (PGPException e) { + throw new SOPGPException.BadData(e); + } + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/GenerateKeyImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/GenerateKeyImpl.java new file mode 100644 index 00000000..2ff2273a --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/GenerateKeyImpl.java @@ -0,0 +1,95 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.pgpainless.PGPainless; +import org.pgpainless.key.modification.secretkeyring.SecretKeyRingEditorInterface; +import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.util.ArmorUtils; +import sop.Ready; +import sop.exception.SOPGPException; +import sop.operation.GenerateKey; + +public class GenerateKeyImpl implements GenerateKey { + + private boolean armor = true; + private final Set userIds = new LinkedHashSet<>(); + + @Override + public GenerateKey noArmor() { + this.armor = false; + return this; + } + + @Override + public GenerateKey userId(String userId) { + this.userIds.add(userId); + return this; + } + + @Override + public Ready generate() throws SOPGPException.MissingArg, SOPGPException.UnsupportedAsymmetricAlgo, IOException { + Iterator userIdIterator = userIds.iterator(); + if (!userIdIterator.hasNext()) { + throw new SOPGPException.MissingArg("Missing user-id."); + } + + PGPSecretKeyRing key; + try { + key = PGPainless.generateKeyRing() + .modernKeyRing(userIdIterator.next(), null); + + if (userIdIterator.hasNext()) { + SecretKeyRingEditorInterface editor = PGPainless.modifyKeyRing(key); + + while (userIdIterator.hasNext()) { + editor.addUserId(userIdIterator.next(), SecretKeyRingProtector.unprotectedKeys()); + } + + key = editor.done(); + } + + PGPSecretKeyRing finalKey = key; + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + if (armor) { + ArmoredOutputStream armoredOutputStream = ArmorUtils.toAsciiArmoredStream(finalKey, outputStream); + finalKey.encode(armoredOutputStream); + armoredOutputStream.close(); + } else { + finalKey.encode(outputStream); + } + } + }; + } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException e) { + throw new SOPGPException.UnsupportedAsymmetricAlgo(e); + } catch (PGPException e) { + throw new RuntimeException(e); + } + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/PGPainlessCLI.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/PGPainlessCLI.java deleted file mode 100644 index b1d85dae..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/PGPainlessCLI.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop; - -import org.pgpainless.sop.commands.Armor; -import org.pgpainless.sop.commands.Dearmor; -import org.pgpainless.sop.commands.Decrypt; -import org.pgpainless.sop.commands.Encrypt; -import org.pgpainless.sop.commands.ExtractCert; -import org.pgpainless.sop.commands.GenerateKey; -import org.pgpainless.sop.commands.Sign; -import org.pgpainless.sop.commands.Verify; -import org.pgpainless.sop.commands.Version; -import picocli.CommandLine; - -@CommandLine.Command(exitCodeOnInvalidInput = 69, - subcommands = { - Armor.class, - Dearmor.class, - Decrypt.class, - Encrypt.class, - ExtractCert.class, - GenerateKey.class, - Sign.class, - Verify.class, - Version.class - } -) -public class PGPainlessCLI implements Runnable { - - public PGPainlessCLI() { - - } - - public static void main(String[] args) { - int code = new CommandLine(new PGPainlessCLI()) - .execute(args); - System.exit(code); - } - - @Override - public void run() { - - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/Print.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/Print.java deleted file mode 100644 index 24721870..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/Print.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop; - -import java.io.IOException; - -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.pgpainless.util.ArmorUtils; - -public class Print { - - public static String toString(PGPSecretKeyRing keyRing, boolean armor) throws IOException { - if (armor) { - return ArmorUtils.toAsciiArmoredString(keyRing); - } else { - return new String(keyRing.getEncoded(), "UTF-8"); - } - } - - public static String toString(PGPPublicKeyRing keyRing, boolean armor) throws IOException { - if (armor) { - return ArmorUtils.toAsciiArmoredString(keyRing); - } else { - return new String(keyRing.getEncoded(), "UTF-8"); - } - } - - public static String toString(byte[] bytes, boolean armor) throws IOException { - if (armor) { - return ArmorUtils.toAsciiArmoredString(bytes); - } else { - return new String(bytes, "UTF-8"); - } - } - - public static void print_ln(String msg) { - // CHECKSTYLE:OFF - System.out.println(msg); - // CHECKSTYLE:ON - } - - public static void err_ln(String msg) { - // CHECKSTYLE:OFF - System.err.println(msg); - // CHECKSTYLE:ON - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/SOPImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/SOPImpl.java new file mode 100644 index 00000000..fded2244 --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/SOPImpl.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import sop.SOP; +import sop.operation.Armor; +import sop.operation.Dearmor; +import sop.operation.Decrypt; +import sop.operation.Encrypt; +import sop.operation.ExtractCert; +import sop.operation.GenerateKey; +import sop.operation.Sign; +import sop.operation.Verify; +import sop.operation.Version; + +public class SOPImpl implements SOP { + + @Override + public Version version() { + return new VersionImpl(); + } + + @Override + public GenerateKey generateKey() { + return new GenerateKeyImpl(); + } + + @Override + public ExtractCert extractCert() { + return new ExtractCertImpl(); + } + + @Override + public Sign sign() { + return new SignImpl(); + } + + @Override + public Verify verify() { + return new VerifyImpl(); + } + + @Override + public Encrypt encrypt() { + return new EncryptImpl(); + } + + @Override + public Decrypt decrypt() { + return new DecryptImpl(); + } + + @Override + public Armor armor() { + return new ArmorImpl(); + } + + @Override + public Dearmor dearmor() { + return new DearmorImpl(); + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/SignImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/SignImpl.java new file mode 100644 index 00000000..cd6bff94 --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/SignImpl.java @@ -0,0 +1,128 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.io.Streams; +import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.DocumentSignatureType; +import org.pgpainless.encryption_signing.EncryptionResult; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; +import org.pgpainless.encryption_signing.SigningOptions; +import org.pgpainless.key.SubkeyIdentifier; +import org.pgpainless.key.info.KeyRingInfo; +import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.util.ArmoredOutputStreamFactory; +import sop.Ready; +import sop.enums.SignAs; +import sop.exception.SOPGPException; +import sop.operation.Sign; + +public class SignImpl implements Sign { + + private boolean armor = true; + private SignAs mode = SignAs.Binary; + private List keys = new ArrayList<>(); + private SigningOptions signingOptions = new SigningOptions(); + + @Override + public Sign noArmor() { + armor = false; + return this; + } + + @Override + public Sign mode(SignAs mode) { + this.mode = mode; + return this; + } + + @Override + public Sign key(InputStream keyIn) throws SOPGPException.KeyIsProtected, SOPGPException.BadData, IOException { + try { + PGPSecretKeyRing key = PGPainless.readKeyRing().secretKeyRing(keyIn); + KeyRingInfo info = new KeyRingInfo(key); + if (!info.isFullyDecrypted()) { + throw new SOPGPException.KeyIsProtected(); + } + signingOptions.addDetachedSignature(SecretKeyRingProtector.unprotectedKeys(), key, modeToSigType(mode)); + } catch (PGPException e) { + throw new SOPGPException.BadData(e); + } + return this; + } + + @Override + public Ready data(InputStream data) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try { + EncryptionStream signingStream = PGPainless.encryptAndOrSign() + .onOutputStream(buffer) + .withOptions(ProducerOptions.sign(signingOptions) + .setAsciiArmor(armor)); + + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + + if (signingStream.isClosed()) { + throw new IllegalStateException("EncryptionStream is already closed."); + } + + Streams.pipeAll(data, signingStream); + signingStream.close(); + EncryptionResult encryptionResult = signingStream.getResult(); + + List signatures = new ArrayList<>(); + for (SubkeyIdentifier key : encryptionResult.getDetachedSignatures().keySet()) { + signatures.addAll(encryptionResult.getDetachedSignatures().get(key)); + } + + OutputStream out; + if (armor) { + out = ArmoredOutputStreamFactory.get(outputStream); + } else { + out = outputStream; + } + for (PGPSignature sig : signatures) { + sig.encode(out); + } + out.close(); + outputStream.close(); // armor out does not close underlying stream + } + }; + + } catch (PGPException e) { + throw new RuntimeException(e); + } + + } + + private static DocumentSignatureType modeToSigType(SignAs mode) { + return mode == SignAs.Binary ? DocumentSignatureType.BINARY_DOCUMENT + : DocumentSignatureType.CANONICAL_TEXT_DOCUMENT; + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/SopKeyUtil.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/SopKeyUtil.java deleted file mode 100644 index f0082c10..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/SopKeyUtil.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop; - -import static org.pgpainless.sop.Print.err_ln; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.pgpainless.PGPainless; - -public class SopKeyUtil { - - public static List loadKeysFromFiles(File... files) throws IOException, PGPException { - List secretKeyRings = new ArrayList<>(); - for (File file : files) { - try (FileInputStream in = new FileInputStream(file)) { - secretKeyRings.add(PGPainless.readKeyRing().secretKeyRing(in)); - } catch (PGPException | IOException e) { - err_ln("Could not load secret key " + file.getName() + ": " + e.getMessage()); - throw e; - } - } - return secretKeyRings; - } - - public static List loadCertificatesFromFile(File... files) throws IOException { - List publicKeyRings = new ArrayList<>(); - for (File file : files) { - try (FileInputStream in = new FileInputStream(file)) { - PGPPublicKeyRingCollection collection = PGPainless.readKeyRing() - .keyRingCollection(in, true) - .getPgpPublicKeyRingCollection(); - if (collection == null) { - throw new PGPException("Provided file " + file.getName() + " does not contain a certificate."); - } - for (PGPPublicKeyRing keyRing : collection) { - publicKeyRings.add(keyRing); - } - } catch (IOException | PGPException e) { - err_ln("Could not read certificate from file " + file.getName() + ": " + e.getMessage()); - throw new IOException(e); - } - } - return publicKeyRings; - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/VerifyImpl.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/VerifyImpl.java new file mode 100644 index 00000000..047a7ab1 --- /dev/null +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/VerifyImpl.java @@ -0,0 +1,124 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pgpainless.sop; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.io.Streams; +import org.pgpainless.PGPainless; +import org.pgpainless.decryption_verification.ConsumerOptions; +import org.pgpainless.decryption_verification.DecryptionStream; +import org.pgpainless.decryption_verification.OpenPgpMetadata; +import org.pgpainless.exception.NotYetImplementedException; +import org.pgpainless.key.SubkeyIdentifier; +import sop.Verification; +import sop.exception.SOPGPException; +import sop.operation.Verify; + +public class VerifyImpl implements Verify { + + ConsumerOptions options = new ConsumerOptions(); + + @Override + public Verify notBefore(Date timestamp) throws SOPGPException.UnsupportedOption { + try { + options.verifyNotBefore(timestamp); + } catch (NotYetImplementedException e) { + // throw new SOPGPException.UnsupportedOption(); + } + return this; + } + + @Override + public Verify notAfter(Date timestamp) throws SOPGPException.UnsupportedOption { + try { + options.verifyNotAfter(timestamp); + } catch (NotYetImplementedException e) { + // throw new SOPGPException.UnsupportedOption(); + } + return this; + } + + @Override + public Verify cert(InputStream cert) throws SOPGPException.BadData { + PGPPublicKeyRingCollection certificates; + try { + certificates = PGPainless.readKeyRing().publicKeyRingCollection(cert); + } catch (IOException | PGPException e) { + throw new SOPGPException.BadData(e); + } + options.addVerificationCerts(certificates); + return this; + } + + @Override + public VerifyImpl signatures(InputStream signatures) throws SOPGPException.BadData { + try { + options.addVerificationOfDetachedSignatures(signatures); + } catch (IOException | PGPException e) { + throw new SOPGPException.BadData(e); + } + return this; + } + + @Override + public List data(InputStream data) throws IOException, SOPGPException.NoSignature, SOPGPException.BadData { + DecryptionStream decryptionStream; + try { + decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(data) + .withOptions(options); + + Streams.drain(decryptionStream); + decryptionStream.close(); + + OpenPgpMetadata metadata = decryptionStream.getResult(); + List verificationList = new ArrayList<>(); + + for (SubkeyIdentifier verifiedSigningKey : metadata.getVerifiedSignatures().keySet()) { + PGPSignature signature = metadata.getVerifiedSignatures().get(verifiedSigningKey); + Date verifyNotBefore = options.getVerifyNotBefore(); + Date verifyNotAfter = options.getVerifyNotAfter(); + + if (verifyNotAfter == null || !signature.getCreationTime().after(verifyNotAfter)) { + if (verifyNotBefore == null || !signature.getCreationTime().before(verifyNotBefore)) { + verificationList.add(new Verification( + signature.getCreationTime(), + verifiedSigningKey.getSubkeyFingerprint().toString(), + verifiedSigningKey.getPrimaryKeyFingerprint().toString())); + } + } + } + + if (!options.getCertificates().isEmpty()) { + if (verificationList.isEmpty()) { + throw new SOPGPException.NoSignature(); + } + } + + return verificationList; + } catch (PGPException e) { + throw new SOPGPException.BadData(e); + } + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Version.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/VersionImpl.java similarity index 71% rename from pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Version.java rename to pgpainless-sop/src/main/java/org/pgpainless/sop/VersionImpl.java index e3974739..adee014f 100644 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Version.java +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/VersionImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 Paul Schaub. + * Copyright 2021 Paul Schaub. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; - -import static org.pgpainless.sop.Print.print_ln; +package org.pgpainless.sop; import java.io.IOException; import java.util.Properties; -import picocli.CommandLine; +import sop.operation.Version; -@CommandLine.Command(name = "version", description = "Display version information about the tool", - exitCodeOnInvalidInput = 37) -public class Version implements Runnable { +public class VersionImpl implements Version { + @Override + public String getName() { + return "PGPainless-SOP"; + } @Override - public void run() { + public String getVersion() { // See https://stackoverflow.com/a/50119235 String version; try { @@ -37,6 +37,6 @@ public class Version implements Runnable { } catch (IOException e) { version = "DEVELOPMENT"; } - print_ln("PGPainlessCLI " + version); + return version; } } diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Armor.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Armor.java deleted file mode 100644 index a70556ef..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Armor.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop.commands; - -import org.bouncycastle.bcpg.ArmoredOutputStream; -import org.bouncycastle.util.io.Streams; -import org.pgpainless.util.ArmoredOutputStreamFactory; -import picocli.CommandLine; - -import java.io.IOException; -import java.io.PushbackInputStream; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; - -import static org.pgpainless.sop.Print.err_ln; - -@CommandLine.Command(name = "armor", - description = "Add ASCII Armor to standard input", - exitCodeOnInvalidInput = 37) -public class Armor implements Runnable { - - private static final byte[] BEGIN_ARMOR = "-----BEGIN PGP".getBytes(StandardCharsets.UTF_8); - - private enum Label { - auto, - sig, - key, - cert, - message - } - - @CommandLine.Option(names = {"--label"}, description = "Label to be used in the header and tail of the armoring.", paramLabel = "{auto|sig|key|cert|message}") - Label label; - - @CommandLine.Option(names = {"--allow-nested"}, description = "Allow additional armoring of already armored input") - boolean allowNested = false; - - @Override - public void run() { - - try (PushbackInputStream pbIn = new PushbackInputStream(System.in, BEGIN_ARMOR.length); - ArmoredOutputStream armoredOutputStream = ArmoredOutputStreamFactory.get(System.out)) { - - // take a peek - byte[] firstBytes = new byte[BEGIN_ARMOR.length]; - int readByteCount = pbIn.read(firstBytes); - if (readByteCount != -1) { - pbIn.unread(firstBytes, 0, readByteCount); - } - - if (Arrays.equals(BEGIN_ARMOR, firstBytes) && !allowNested) { - Streams.pipeAll(pbIn, System.out); - } else { - Streams.pipeAll(pbIn, armoredOutputStream); - } - } catch (IOException e) { - err_ln("Input data cannot be ASCII armored."); - err_ln(e.getMessage()); - System.exit(1); - } - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Decrypt.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Decrypt.java deleted file mode 100644 index 8ea4c964..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Decrypt.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop.commands; - -import static org.pgpainless.sop.Print.err_ln; -import static org.pgpainless.sop.SopKeyUtil.loadKeysFromFiles; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.PrintStream; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.List; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.bouncycastle.openpgp.PGPSignature; -import org.bouncycastle.util.io.Streams; -import org.pgpainless.PGPainless; -import org.pgpainless.decryption_verification.ConsumerOptions; -import org.pgpainless.decryption_verification.DecryptionStream; -import org.pgpainless.decryption_verification.OpenPgpMetadata; -import org.pgpainless.key.SubkeyIdentifier; -import org.pgpainless.sop.SopKeyUtil; -import picocli.CommandLine; - -@CommandLine.Command(name = "decrypt", - description = "Decrypt a message from standard input", - exitCodeOnInvalidInput = 37) -public class Decrypt implements Runnable { - - private static final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"); - - @CommandLine.Option( - names = {"--session-key-out"}, - description = "Can be used to learn the session key on successful decryption", - paramLabel = "SESSIONKEY") - File sessionKeyOut; - - @CommandLine.Option( - names = {"--with-session-key"}, - description = "Enables decryption of the \"CIPHERTEXT\" using the session key directly against the \"SEIPD\" packet", - paramLabel = "SESSIONKEY") - File[] withSessionKey; - - @CommandLine.Option( - names = {"--with-password"}, - description = "Enables decryption based on any \"SKESK\" packets in the \"CIPHERTEXT\"", - paramLabel = "PASSWORD") - String[] withPassword; - - @CommandLine.Option(names = {"--verify-out"}, - description = "Produces signature verification status to the designated file", - paramLabel = "VERIFICATIONS") - File verifyOut; - - @CommandLine.Option(names = {"--verify-with"}, - description = "Certificates whose signatures would be acceptable for signatures over this message", - paramLabel = "CERT") - File[] certs; - - @CommandLine.Option(names = {"--not-before"}, - description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + - "Reject signatures with a creation date not in range.\n" + - "Defaults to beginning of time (\"-\").", - paramLabel = "DATE") - String notBefore = "-"; - - @CommandLine.Option(names = {"--not-after"}, - description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + - "Reject signatures with a creation date not in range.\n" + - "Defaults to current system time (\"now\").\n" + - "Accepts special value \"-\" for end of time.", - paramLabel = "DATE") - String notAfter = "now"; - - @CommandLine.Parameters(index = "0..*", - description = "Secret keys to attempt decryption with", - paramLabel = "KEY") - File[] keys; - - @Override - public void run() { - if (verifyOut == null ^ certs == null) { - err_ln("To enable signature verification, both --verify-out and at least one --verify-with argument must be supplied."); - System.exit(23); - } - - if (sessionKeyOut != null || withSessionKey != null) { - err_ln("session key in and out are not yet supported."); - System.exit(1); - } - - ConsumerOptions options = new ConsumerOptions(); - - List verifyWith = null; - try { - - List secretKeyRings = loadKeysFromFiles(keys); - for (PGPSecretKeyRing secretKey : secretKeyRings) { - options.addDecryptionKey(secretKey); - } - - if (certs != null) { - verifyWith = SopKeyUtil.loadCertificatesFromFile(certs); - for (PGPPublicKeyRing cert : verifyWith) { - options.addVerificationCert(cert); - } - } - - } catch (IOException | PGPException e) { - err_ln(e.getMessage()); - System.exit(1); - return; - } - - DecryptionStream decryptionStream; - try { - decryptionStream = PGPainless.decryptAndOrVerify() - .onInputStream(System.in) - .withOptions(options); - } catch (IOException | PGPException e) { - err_ln("Error constructing decryption stream: " + e.getMessage()); - System.exit(1); - return; - } - - try { - Streams.pipeAll(decryptionStream, System.out); - System.out.flush(); - decryptionStream.close(); - } catch (IOException e) { - err_ln("Unable to decrypt: " + e.getMessage()); - System.exit(29); - } - if (verifyOut == null) { - return; - } - - OpenPgpMetadata metadata = decryptionStream.getResult(); - StringBuilder sb = new StringBuilder(); - - if (verifyWith != null) { - for (SubkeyIdentifier signingKey : metadata.getVerifiedSignatures().keySet()) { - PGPSignature signature = metadata.getVerifiedSignatures().get(signingKey); - sb.append(df.format(signature.getCreationTime())).append(' ') - .append(signingKey.getSubkeyFingerprint()).append(' ') - .append(signingKey.getPrimaryKeyFingerprint()).append('\n'); - } - - try { - verifyOut.createNewFile(); - PrintStream verifyPrinter = new PrintStream(new FileOutputStream(verifyOut)); - // CHECKSTYLE:OFF - verifyPrinter.println(sb); - // CHECKSTYLE:ON - verifyPrinter.close(); - } catch (IOException e) { - err_ln("Error writing verifications file: " + e); - } - } - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Encrypt.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Encrypt.java deleted file mode 100644 index 7c2a9a99..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Encrypt.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop.commands; - -import static org.pgpainless.sop.Print.err_ln; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.util.List; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.bouncycastle.util.io.Streams; -import org.pgpainless.PGPainless; -import org.pgpainless.algorithm.DocumentSignatureType; -import org.pgpainless.encryption_signing.EncryptionOptions; -import org.pgpainless.encryption_signing.EncryptionStream; -import org.pgpainless.encryption_signing.ProducerOptions; -import org.pgpainless.encryption_signing.SigningOptions; -import org.pgpainless.key.protection.SecretKeyRingProtector; -import org.pgpainless.sop.SopKeyUtil; -import org.pgpainless.util.Passphrase; -import picocli.CommandLine; - -@CommandLine.Command(name = "encrypt", - description = "Encrypt a message from standard input", - exitCodeOnInvalidInput = 37) -public class Encrypt implements Runnable { - - public enum Type { - binary, - text, - mime - } - - @CommandLine.Option(names = "--no-armor", - description = "ASCII armor the output", - negatable = true) - boolean armor = true; - - @CommandLine.Option(names = {"--as"}, - description = "Type of the input data. Defaults to 'binary'", - paramLabel = "{binary|text|mime}") - Type type; - - @CommandLine.Option(names = "--with-password", - description = "Encrypt the message with a password", - paramLabel = "PASSWORD") - String[] withPassword = new String[0]; - - @CommandLine.Option(names = "--sign-with", - description = "Sign the output with a private key", - paramLabel = "KEY") - File[] signWith = new File[0]; - - @CommandLine.Parameters(description = "Certificates the message gets encrypted to", - index = "0..*", - paramLabel = "CERTS") - File[] certs = new File[0]; - - @Override - public void run() { - if (certs.length == 0 && withPassword.length == 0) { - err_ln("Please either provide --with-password or at least one CERT"); - System.exit(19); - } - - EncryptionOptions encOpt = new EncryptionOptions(); - SigningOptions signOpt = new SigningOptions(); - - try { - List encryptionKeys = SopKeyUtil.loadCertificatesFromFile(certs); - for (PGPPublicKeyRing key : encryptionKeys) { - encOpt.addRecipient(key); - } - } catch (IOException e) { - err_ln(e.getMessage()); - System.exit(1); - return; - } - - for (String s : withPassword) { - Passphrase passphrase = Passphrase.fromPassword(s); - encOpt.addPassphrase(passphrase); - } - - SecretKeyRingProtector protector = SecretKeyRingProtector.unprotectedKeys(); - - for (int i = 0; i < signWith.length; i++) { - try (FileInputStream fileIn = new FileInputStream(signWith[i])) { - PGPSecretKeyRing secretKey = PGPainless.readKeyRing().secretKeyRing(fileIn); - signOpt.addInlineSignature(protector, secretKey, parseType(type)); - } catch (IOException | PGPException e) { - err_ln("Cannot read secret key from file " + signWith[i].getName()); - err_ln(e.getMessage()); - System.exit(1); - } - } - - try { - EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() - .onOutputStream(System.out) - .withOptions(ProducerOptions - .signAndEncrypt(encOpt, signOpt) - .setAsciiArmor(armor)); - - Streams.pipeAll(System.in, encryptionStream); - - encryptionStream.close(); - } catch (IOException | PGPException e) { - err_ln("An error happened."); - err_ln(e.getMessage()); - System.exit(1); - } - } - - private static DocumentSignatureType parseType(Type type) { - return type == Type.binary ? DocumentSignatureType.BINARY_DOCUMENT : DocumentSignatureType.CANONICAL_TEXT_DOCUMENT; - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/GenerateKey.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/GenerateKey.java deleted file mode 100644 index 99a3b0d1..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/GenerateKey.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop.commands; - -import java.io.IOException; -import java.security.InvalidAlgorithmParameterException; -import java.security.NoSuchAlgorithmException; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.TimeZone; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.pgpainless.PGPainless; -import org.pgpainless.algorithm.KeyFlag; -import org.pgpainless.key.generation.KeyRingBuilderInterface; -import org.pgpainless.key.generation.KeySpec; -import org.pgpainless.key.generation.type.KeyType; -import org.pgpainless.key.generation.type.eddsa.EdDSACurve; -import org.pgpainless.key.generation.type.xdh.XDHSpec; -import org.pgpainless.sop.Print; -import picocli.CommandLine; - -import static org.pgpainless.sop.Print.err_ln; -import static org.pgpainless.sop.Print.print_ln; - -@CommandLine.Command(name = "generate-key", - description = "Generate a secret key", - exitCodeOnInvalidInput = 37) -public class GenerateKey implements Runnable { - - @CommandLine.Option(names = "--no-armor", - description = "ASCII armor the output", - negatable = true) - boolean armor = true; - - @CommandLine.Parameters(description = "User-ID, eg. \"Alice \"") - List userId; - - @Override - public void run() { - if (userId.isEmpty()) { - print_ln("At least one user-id expected."); - System.exit(1); - return; - } - - try { - KeyRingBuilderInterface.WithAdditionalUserIdOrPassphrase builder = PGPainless.generateKeyRing() - .withSubKey(KeySpec.getBuilder(KeyType.EDDSA(EdDSACurve._Ed25519)) - .withKeyFlags(KeyFlag.SIGN_DATA) - .withDefaultAlgorithms()) - .withSubKey(KeySpec.getBuilder(KeyType.XDH(XDHSpec._X25519)) - .withKeyFlags(KeyFlag.ENCRYPT_COMMS, KeyFlag.ENCRYPT_STORAGE) - .withDefaultAlgorithms()) - .withPrimaryKey(KeySpec.getBuilder(KeyType.EDDSA(EdDSACurve._Ed25519)) - .withKeyFlags(KeyFlag.CERTIFY_OTHER) - .withDefaultAlgorithms()) - .withPrimaryUserId(userId.get(0)); - - for (int i = 1; i < userId.size(); i++) { - builder.withAdditionalUserId(userId.get(i)); - } - - Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - calendar.add(Calendar.YEAR, 3); - Date expiration = calendar.getTime(); - - PGPSecretKeyRing secretKeys = builder.setExpirationDate(expiration) - .withoutPassphrase() - .build(); - - print_ln(Print.toString(secretKeys, armor)); - - } catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException | PGPException | IOException e) { - err_ln("Error creating OpenPGP key:"); - err_ln(e.getMessage()); - System.exit(1); - } - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Sign.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Sign.java deleted file mode 100644 index c7cd874a..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Sign.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop.commands; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.bouncycastle.openpgp.PGPSignature; -import org.bouncycastle.util.io.Streams; -import org.pgpainless.PGPainless; -import org.pgpainless.algorithm.DocumentSignatureType; -import org.pgpainless.encryption_signing.EncryptionResult; -import org.pgpainless.encryption_signing.EncryptionStream; -import org.pgpainless.encryption_signing.ProducerOptions; -import org.pgpainless.encryption_signing.SigningOptions; -import org.pgpainless.key.SubkeyIdentifier; -import org.pgpainless.key.protection.SecretKeyRingProtector; -import org.pgpainless.sop.Print; -import picocli.CommandLine; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; - -import static org.pgpainless.sop.Print.err_ln; -import static org.pgpainless.sop.Print.print_ln; - -@CommandLine.Command(name = "sign", - description = "Create a detached signature on the data from standard input", - exitCodeOnInvalidInput = 37) -public class Sign implements Runnable { - - public enum Type { - binary, - text - } - - @CommandLine.Option(names = "--no-armor", - description = "ASCII armor the output", - negatable = true) - boolean armor = true; - - @CommandLine.Option(names = "--as", description = "Defaults to 'binary'. If '--as=text' and the input data is not valid UTF-8, sign fails with return code 53.", - paramLabel = "{binary|text}") - Type type; - - @CommandLine.Parameters(description = "Secret keys used for signing", - paramLabel = "KEY", - arity = "1..*") - File[] secretKeyFile; - - @Override - public void run() { - PGPSecretKeyRing[] secretKeys = new PGPSecretKeyRing[secretKeyFile.length]; - for (int i = 0, secretKeyFileLength = secretKeyFile.length; i < secretKeyFileLength; i++) { - File file = secretKeyFile[i]; - try { - PGPSecretKeyRing secretKey = PGPainless.readKeyRing().secretKeyRing(new FileInputStream(file)); - secretKeys[i] = secretKey; - } catch (IOException | PGPException e) { - err_ln("Error reading secret key ring " + file.getName()); - err_ln(e.getMessage()); - System.exit(1); - return; - } - } - try { - SigningOptions signOpt = new SigningOptions(); - for (PGPSecretKeyRing signingKey : secretKeys) { - signOpt.addDetachedSignature(SecretKeyRingProtector.unprotectedKeys(), signingKey, - type == Type.text ? DocumentSignatureType.CANONICAL_TEXT_DOCUMENT : DocumentSignatureType.BINARY_DOCUMENT); - } - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() - .onOutputStream(out) - .withOptions(ProducerOptions - .sign(signOpt) - .setAsciiArmor(armor)); - - Streams.pipeAll(System.in, encryptionStream); - encryptionStream.close(); - - EncryptionResult result = encryptionStream.getResult(); - for (SubkeyIdentifier signingKey : result.getDetachedSignatures().keySet()) { - for (PGPSignature signature : result.getDetachedSignatures().get(signingKey)) { - print_ln(Print.toString(signature.getEncoded(), armor)); - } - } - } catch (PGPException | IOException e) { - err_ln("Error signing data."); - err_ln(e.getMessage()); - System.exit(1); - } - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Verify.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Verify.java deleted file mode 100644 index 460f83dc..00000000 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Verify.java +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright 2020 Paul Schaub. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.pgpainless.sop.commands; - -import static org.pgpainless.sop.Print.err_ln; -import static org.pgpainless.sop.Print.print_ln; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.TimeZone; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; -import org.bouncycastle.openpgp.PGPSignature; -import org.bouncycastle.util.io.Streams; -import org.pgpainless.PGPainless; -import org.pgpainless.decryption_verification.ConsumerOptions; -import org.pgpainless.decryption_verification.DecryptionStream; -import org.pgpainless.decryption_verification.OpenPgpMetadata; -import org.pgpainless.key.SubkeyIdentifier; -import picocli.CommandLine; - -@CommandLine.Command(name = "verify", - description = "Verify a detached signature over the data from standard input", - exitCodeOnInvalidInput = 37) -public class Verify implements Runnable { - - private static final TimeZone tz = TimeZone.getTimeZone("UTC"); - private static final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'"); - - private static final Date beginningOfTime = new Date(0); - private static final Date endOfTime = new Date(8640000000000000L); - - static { - df.setTimeZone(tz); - } - - @CommandLine.Parameters(index = "0", - description = "Detached signature", - paramLabel = "SIGNATURE") - File signature; - - @CommandLine.Parameters(index = "1..*", - arity = "1..*", - description = "Public key certificates", - paramLabel = "CERT") - File[] certificates; - - @CommandLine.Option(names = {"--not-before"}, - description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + - "Reject signatures with a creation date not in range.\n" + - "Defaults to beginning of time (\"-\").", - paramLabel = "DATE") - String notBefore = "-"; - - @CommandLine.Option(names = {"--not-after"}, - description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + - "Reject signatures with a creation date not in range.\n" + - "Defaults to current system time (\"now\").\n" + - "Accepts special value \"-\" for end of time.", - paramLabel = "DATE") - String notAfter = "now"; - - @Override - public void run() { - Date notBeforeDate = parseNotBefore(); - Date notAfterDate = parseNotAfter(); - - ConsumerOptions options = new ConsumerOptions(); - try (FileInputStream sigIn = new FileInputStream(signature)) { - options.addVerificationOfDetachedSignatures(sigIn); - } catch (IOException | PGPException e) { - err_ln("Cannot read detached signature: " + e.getMessage()); - System.exit(1); - } - - Map publicKeys = readCertificatesFromFiles(); - if (publicKeys.isEmpty()) { - err_ln("No certificates supplied."); - System.exit(19); - } - - for (PGPPublicKeyRing cert : publicKeys.keySet()) { - options.addVerificationCert(cert); - } - - OpenPgpMetadata metadata; - try { - DecryptionStream verifier = PGPainless.decryptAndOrVerify() - .onInputStream(System.in) - .withOptions(options); - - OutputStream out = new NullOutputStream(); - Streams.pipeAll(verifier, out); - verifier.close(); - - metadata = verifier.getResult(); - } catch (IOException | PGPException e) { - err_ln("Signature validation failed."); - err_ln(e.getMessage()); - System.exit(1); - return; - } - - Map signaturesInTimeRange = new HashMap<>(); - for (SubkeyIdentifier signingKey : metadata.getVerifiedSignatures().keySet()) { - PGPSignature signature = metadata.getVerifiedSignatures().get(signingKey); - Date creationTime = signature.getCreationTime(); - if (!creationTime.before(notBeforeDate) && !creationTime.after(notAfterDate)) { - signaturesInTimeRange.put(signingKey, signature); - } - } - - if (signaturesInTimeRange.isEmpty()) { - err_ln("No valid signatures found."); - System.exit(3); - } - - printValidSignatures(signaturesInTimeRange, publicKeys); - } - - private void printValidSignatures(Map validSignatures, Map publicKeys) { - for (SubkeyIdentifier signingKey : validSignatures.keySet()) { - PGPSignature signature = validSignatures.get(signingKey); - - for (PGPPublicKeyRing ring : publicKeys.keySet()) { - // Search signing key ring - File file = publicKeys.get(ring); - if (ring.getPublicKey(signingKey.getKeyId()) == null) { - continue; - } - - String utcSigDate = df.format(signature.getCreationTime()); - print_ln(utcSigDate + " " + signingKey.getSubkeyFingerprint() + " " + signingKey.getPrimaryKeyFingerprint() + - " signed by " + file.getName()); - } - } - } - - private Map readCertificatesFromFiles() { - Map publicKeys = new HashMap<>(); - for (File cert : certificates) { - try (FileInputStream in = new FileInputStream(cert)) { - PGPPublicKeyRingCollection collection = PGPainless.readKeyRing().publicKeyRingCollection(in); - for (PGPPublicKeyRing ring : collection) { - publicKeys.put(ring, cert); - } - } catch (IOException | PGPException e) { - err_ln("Cannot read certificate from file " + cert.getAbsolutePath() + ":"); - err_ln(e.getMessage()); - } - } - return publicKeys; - } - - private Date parseNotAfter() { - try { - return notAfter.equals("now") ? new Date() : notAfter.equals("-") ? endOfTime : df.parse(notAfter); - } catch (ParseException e) { - err_ln("Invalid date string supplied as value of --not-after."); - System.exit(1); - return null; - } - } - - private Date parseNotBefore() { - try { - return notBefore.equals("now") ? new Date() : notBefore.equals("-") ? beginningOfTime : df.parse(notBefore); - } catch (ParseException e) { - err_ln("Invalid date string supplied as value of --not-before."); - System.exit(1); - return null; - } - } - - private static class NullOutputStream extends OutputStream { - - @Override - public void write(int b) throws IOException { - // Nope - } - } -} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/package-info.java b/pgpainless-sop/src/main/java/org/pgpainless/sop/package-info.java index 43afb989..a480c702 100644 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/package-info.java +++ b/pgpainless-sop/src/main/java/org/pgpainless/sop/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2020 Paul Schaub. + * Copyright 2021 Paul Schaub. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,6 @@ * limitations under the License. */ /** - * PGPainless SOP implementing a Stateless OpenPGP Command Line Interface. - * @see - * Stateless OpenPGP Command Line Interface - * draft-dkg-openpgp-stateless-cli-01 + * Implementation of the java-sop package using pgpainless-core. */ package org.pgpainless.sop; diff --git a/pgpainless-sop/src/test/java/org/pgpainless/sop/DummyTest.java b/pgpainless-sop/src/test/java/org/pgpainless/sop/VersionTest.java similarity index 76% rename from pgpainless-sop/src/test/java/org/pgpainless/sop/DummyTest.java rename to pgpainless-sop/src/test/java/org/pgpainless/sop/VersionTest.java index e73fba3f..02a38217 100644 --- a/pgpainless-sop/src/test/java/org/pgpainless/sop/DummyTest.java +++ b/pgpainless-sop/src/test/java/org/pgpainless/sop/VersionTest.java @@ -15,13 +15,14 @@ */ package org.pgpainless.sop; -import org.junit.jupiter.api.Test; -import picocli.CommandLine; +import static org.junit.jupiter.api.Assertions.assertEquals; -public class DummyTest { +import org.junit.jupiter.api.Test; + +public class VersionTest { @Test - public void dummyTest() { - new CommandLine(new PGPainlessCLI()).execute("generate-key", "Ed Snowden "); + public void assertNameEqualsPGPainless() { + assertEquals("PGPainless-SOP", new SOPImpl().version().getName()); } } diff --git a/settings.gradle b/settings.gradle index 5cc5fd5c..2fb62820 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,8 @@ rootProject.name = 'PGPainless' include 'pgpainless-core', - 'pgpainless-sop' + 'sop-java', + 'pgpainless-sop', + 'sop-java-picocli', + 'pgpainless-cli' diff --git a/sop-java-picocli/build.gradle b/sop-java-picocli/build.gradle new file mode 100644 index 00000000..f48a891d --- /dev/null +++ b/sop-java-picocli/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'application' +} + +dependencies { + implementation(project(":sop-java")) + implementation 'info.picocli:picocli:4.6.1' + implementation 'com.google.inject:guice:5.0.1' + + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // https://todd.ginsberg.com/post/testing-system-exit/ + testImplementation 'com.ginsberg:junit5-system-exit:1.1.1' + + testImplementation "org.mockito:mockito-core:3.11.2" + + // https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305 + implementation group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2' +} + +mainClassName = 'sop.cli.picocli.SopCLI' + +jar { + manifest { + attributes 'Main-Class': "$mainClassName" + } + + from { + configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } + } { + exclude "META-INF/*.SF" + exclude "META-INF/*.DSA" + exclude "META-INF/*.RSA" + } +} + diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/DateParser.java b/sop-java-picocli/src/main/java/sop/cli/picocli/DateParser.java new file mode 100644 index 00000000..cfcdb870 --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/DateParser.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli; + +import java.util.Date; + +import sop.util.UTCUtil; + +public class DateParser { + + public static final Date BEGINNING_OF_TIME = new Date(0); + public static final Date END_OF_TIME = new Date(8640000000000000L); + + public static Date parseNotAfter(String notAfter) { + Date date = notAfter.equals("now") ? new Date() : notAfter.equals("-") ? END_OF_TIME : UTCUtil.parseUTCDate(notAfter); + if (date == null) { + Print.errln("Invalid date string supplied as value of --not-after."); + System.exit(1); + } + return date; + } + + public static Date parseNotBefore(String notBefore) { + Date date = notBefore.equals("now") ? new Date() : notBefore.equals("-") ? BEGINNING_OF_TIME : UTCUtil.parseUTCDate(notBefore); + if (date == null) { + Print.errln("Invalid date string supplied as value of --not-before."); + System.exit(1); + } + return date; + } +} diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/Print.java b/sop-java-picocli/src/main/java/sop/cli/picocli/Print.java new file mode 100644 index 00000000..fd8d98ea --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/Print.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli; + +public class Print { + + public static void errln(String string) { + // CHECKSTYLE:OFF + System.err.println(string); + // CHECKSTYLE:ON + } + + public static void trace(Throwable e) { + // CHECKSTYLE:OFF + e.printStackTrace(); + // CHECKSTYLE:ON + } + + public static void outln(String string) { + // CHECKSTYLE:OFF + System.out.println(string); + // CHECKSTYLE:ON + } +} 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 new file mode 100644 index 00000000..f57b2833 --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/SopCLI.java @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli; + +import picocli.CommandLine; +import sop.SOP; +import sop.cli.picocli.commands.ArmorCmd; +import sop.cli.picocli.commands.DearmorCmd; +import sop.cli.picocli.commands.DecryptCmd; +import sop.cli.picocli.commands.EncryptCmd; +import sop.cli.picocli.commands.ExtractCertCmd; +import sop.cli.picocli.commands.GenerateKeyCmd; +import sop.cli.picocli.commands.SignCmd; +import sop.cli.picocli.commands.VerifyCmd; +import sop.cli.picocli.commands.VersionCmd; + +@CommandLine.Command( + exitCodeOnInvalidInput = 69, + subcommands = { + ArmorCmd.class, + DearmorCmd.class, + DecryptCmd.class, + EncryptCmd.class, + ExtractCertCmd.class, + GenerateKeyCmd.class, + SignCmd.class, + VerifyCmd.class, + VersionCmd.class + } +) +public class SopCLI { + + static SOP SOP_INSTANCE; + + public static void main(String[] args) { + int exitCode = execute(args); + if (exitCode != 0) { + System.exit(exitCode); + } + } + + public static int execute(String[] args) { + return new CommandLine(SopCLI.class) + .setCaseInsensitiveEnumValuesAllowed(true) + .execute(args); + } + + public static SOP getSop() { + if (SOP_INSTANCE == null) { + throw new IllegalStateException("No SOP backend set."); + } + return SOP_INSTANCE; + } + + public static void setSopInstance(SOP instance) { + SOP_INSTANCE = instance; + } +} diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ArmorCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ArmorCmd.java new file mode 100644 index 00000000..4087b60c --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ArmorCmd.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import java.io.IOException; + +import picocli.CommandLine; +import sop.Ready; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.enums.ArmorLabel; +import sop.exception.SOPGPException; +import sop.operation.Armor; + +@CommandLine.Command(name = "armor", + description = "Add ASCII Armor to standard input", + exitCodeOnInvalidInput = SOPGPException.UnsupportedOption.EXIT_CODE) +public class ArmorCmd implements Runnable { + + @CommandLine.Option(names = {"--label"}, description = "Label to be used in the header and tail of the armoring.", paramLabel = "{auto|sig|key|cert|message}") + ArmorLabel label; + + @CommandLine.Option(names = {"--allow-nested"}, description = "Allow additional armoring of already armored input") + boolean allowNested = false; + + @Override + public void run() { + Armor armor = SopCLI.getSop().armor(); + if (allowNested) { + try { + armor.allowNested(); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Option --allow-nested is not supported."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + if (label != null) { + try { + armor.label(label); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Armor labels not supported."); + System.exit(unsupportedOption.getExitCode()); + } + } + + try { + Ready ready = armor.data(System.in); + ready.writeTo(System.out); + } catch (SOPGPException.BadData badData) { + Print.errln("Bad data."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Dearmor.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DearmorCmd.java similarity index 58% rename from pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Dearmor.java rename to sop-java-picocli/src/main/java/sop/cli/picocli/commands/DearmorCmd.java index 5de54b8c..c4d1d009 100644 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/Dearmor.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DearmorCmd.java @@ -13,28 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; - -import org.bouncycastle.bcpg.ArmoredInputStream; -import org.bouncycastle.util.io.Streams; -import picocli.CommandLine; +package sop.cli.picocli.commands; import java.io.IOException; -import static org.pgpainless.sop.Print.err_ln; +import picocli.CommandLine; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; @CommandLine.Command(name = "dearmor", description = "Remove ASCII Armor from standard input", - exitCodeOnInvalidInput = 37) -public class Dearmor implements Runnable { + exitCodeOnInvalidInput = SOPGPException.UnsupportedOption.EXIT_CODE) +public class DearmorCmd implements Runnable { @Override public void run() { - try (ArmoredInputStream in = new ArmoredInputStream(System.in, true)) { - Streams.pipeAll(in, System.out); + try { + SopCLI.getSop() + .dearmor() + .data(System.in) + .writeTo(System.out); + } catch (SOPGPException.BadData e) { + Print.errln("Bad data."); + Print.trace(e); + System.exit(e.getExitCode()); } catch (IOException e) { - err_ln("Data cannot be dearmored."); - err_ln(e.getMessage()); + Print.errln("IO Error."); + Print.trace(e); System.exit(1); } } 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 new file mode 100644 index 00000000..75ea2bca --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/DecryptCmd.java @@ -0,0 +1,290 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; + +import picocli.CommandLine; +import sop.DecryptionResult; +import sop.ReadyWithResult; +import sop.SessionKey; +import sop.Verification; +import sop.cli.picocli.DateParser; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.Decrypt; +import sop.util.HexUtil; + +@CommandLine.Command(name = "decrypt", + description = "Decrypt a message from standard input", + exitCodeOnInvalidInput = SOPGPException.UnsupportedOption.EXIT_CODE) +public class DecryptCmd implements Runnable { + + @CommandLine.Option( + names = {"--session-key-out"}, + description = "Can be used to learn the session key on successful decryption", + paramLabel = "SESSIONKEY") + File sessionKeyOut; + + @CommandLine.Option( + names = {"--with-session-key"}, + description = "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\"", + paramLabel = "PASSWORD") + List withPassword = new ArrayList<>(); + + @CommandLine.Option(names = {"--verify-out"}, + description = "Produces signature verification status to the designated file", + paramLabel = "VERIFICATIONS") + File verifyOut; + + @CommandLine.Option(names = {"--verify-with"}, + description = "Certificates whose signatures would be acceptable for signatures over this message", + paramLabel = "CERT") + List certs = new ArrayList<>(); + + @CommandLine.Option(names = {"--not-before"}, + description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + + "Reject signatures with a creation date not in range.\n" + + "Defaults to beginning of time (\"-\").", + paramLabel = "DATE") + String notBefore = "-"; + + @CommandLine.Option(names = {"--not-after"}, + description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + + "Reject signatures with a creation date not in range.\n" + + "Defaults to current system time (\"now\").\n" + + "Accepts special value \"-\" for end of time.", + paramLabel = "DATE") + String notAfter = "now"; + + @CommandLine.Parameters(index = "0..*", + description = "Secret keys to attempt decryption with", + paramLabel = "KEY") + List keys = new ArrayList<>(); + + @Override + public void run() { + unlinkExistingVerifyOut(verifyOut); + + Decrypt decrypt = SopCLI.getSop().decrypt(); + setNotAfter(notAfter, decrypt); + setNotBefore(notBefore, decrypt); + setWithPasswords(withPassword, decrypt); + setWithSessionKeys(withSessionKey, decrypt); + setVerifyWith(certs, decrypt); + setDecryptWith(keys, decrypt); + + if (verifyOut != null && certs.isEmpty()) { + Print.errln("--verify-out is requested, but no --verify-with was provided."); + System.exit(23); + } + + try { + ReadyWithResult ready = decrypt.ciphertext(System.in); + DecryptionResult result = ready.writeTo(System.out); + if (sessionKeyOut != null) { + if (sessionKeyOut.exists()) { + Print.errln("File " + sessionKeyOut.getAbsolutePath() + " already exists."); + Print.trace(new SOPGPException.OutputExists()); + System.exit(1); + } + + try (FileOutputStream outputStream = new FileOutputStream(sessionKeyOut)) { + if (!result.getSessionKey().isPresent()) { + Print.errln("Session key not extracted. Possibly the feature is not supported."); + System.exit(SOPGPException.UnsupportedOption.EXIT_CODE); + } else { + SessionKey sessionKey = result.getSessionKey().get(); + outputStream.write(sessionKey.getAlgorithm()); + outputStream.write(sessionKey.getKey()); + } + } + } + if (verifyOut != null) { + if (!verifyOut.createNewFile()) { + throw new IOException("Cannot create file " + verifyOut.getAbsolutePath()); + } + try (FileOutputStream outputStream = new FileOutputStream(verifyOut)) { + PrintWriter writer = new PrintWriter(outputStream); + for (Verification verification : result.getVerifications()) { + // CHECKSTYLE:OFF + writer.println(verification.toString()); + // CHECKSTYLE:ON + } + writer.flush(); + } + } + } catch (SOPGPException.BadData badData) { + Print.errln("No valid OpenPGP message found on Standard Input."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } catch (SOPGPException.MissingArg missingArg) { + Print.errln("Missing arguments."); + Print.trace(missingArg); + System.exit(missingArg.getExitCode()); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.NoSignature noSignature) { + Print.errln("No verifiable signature found."); + Print.trace(noSignature); + System.exit(noSignature.getExitCode()); + } catch (SOPGPException.CannotDecrypt cannotDecrypt) { + Print.errln("Cannot decrypt."); + Print.trace(cannotDecrypt); + System.exit(cannotDecrypt.getExitCode()); + } + } + + private void setDecryptWith(List keys, Decrypt decrypt) { + for (File key : keys) { + try (FileInputStream keyIn = new FileInputStream(key)) { + decrypt.withKey(keyIn); + } catch (SOPGPException.KeyIsProtected keyIsProtected) { + Print.errln("Key in file " + key.getAbsolutePath() + " is password protected."); + Print.trace(keyIsProtected); + System.exit(1); + } catch (SOPGPException.UnsupportedAsymmetricAlgo unsupportedAsymmetricAlgo) { + Print.errln("Key uses unsupported asymmetric algorithm."); + Print.trace(unsupportedAsymmetricAlgo); + System.exit(unsupportedAsymmetricAlgo.getExitCode()); + } catch (SOPGPException.BadData badData) { + Print.errln("File " + key.getAbsolutePath() + " does not contain a private key."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } catch (FileNotFoundException e) { + Print.errln("File " + key.getAbsolutePath() + " does not exist."); + Print.trace(e); + System.exit(1); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } + } + } + + private void setVerifyWith(List certs, Decrypt decrypt) { + for (File cert : certs) { + try (FileInputStream certIn = new FileInputStream(cert)) { + decrypt.verifyWithCert(certIn); + } catch (FileNotFoundException e) { + Print.errln("File " + cert.getAbsolutePath() + " does not exist."); + Print.trace(e); + System.exit(1); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("File " + cert.getAbsolutePath() + " does not contain a valid certificate."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + } + } + + private void unlinkExistingVerifyOut(File verifyOut) { + if (verifyOut == null) { + return; + } + + if (verifyOut.exists()) { + if (!verifyOut.delete()) { + Print.errln("Cannot delete existing verification file" + verifyOut.getAbsolutePath()); + System.exit(1); + } + } + } + + private void setWithSessionKeys(List withSessionKey, Decrypt decrypt) { + Pattern sessionKeyPattern = Pattern.compile("^\\d+:[0-9A-F]+$"); + for (String sessionKey : withSessionKey) { + if (!sessionKeyPattern.matcher(sessionKey).matches()) { + Print.errln("Invalid session key format."); + Print.errln("Session keys are expected in the format 'ALGONUM:HEXKEY'"); + System.exit(1); + } + String[] split = sessionKey.split(":"); + byte algorithm = (byte) Integer.parseInt(split[0]); + byte[] key = HexUtil.hexToBytes(split[1]); + + try { + decrypt.withSessionKey(new SessionKey(algorithm, key)); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--with-session-key'."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + return; + } + } + } + + private void setWithPasswords(List withPassword, Decrypt decrypt) { + for (String password : withPassword) { + try { + decrypt.withPassword(password); + } catch (SOPGPException.PasswordNotHumanReadable passwordNotHumanReadable) { + Print.errln("Password not human readable."); + Print.trace(passwordNotHumanReadable); + System.exit(passwordNotHumanReadable.getExitCode()); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--with-password'."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + } + + private void setNotAfter(String notAfter, Decrypt decrypt) { + Date notAfterDate = DateParser.parseNotAfter(notAfter); + try { + decrypt.verifyNotAfter(notAfterDate); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Option '--not-after' not supported."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + + private void setNotBefore(String notBefore, Decrypt decrypt) { + Date notBeforeDate = DateParser.parseNotBefore(notBefore); + try { + decrypt.verifyNotBefore(notBeforeDate); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Option '--not-before' not supported."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } +} 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 new file mode 100644 index 00000000..bdc6b1b4 --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/EncryptCmd.java @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import picocli.CommandLine; +import sop.Ready; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.enums.EncryptAs; +import sop.exception.SOPGPException; +import sop.operation.Encrypt; + +@CommandLine.Command(name = "encrypt", + description = "Encrypt a message from standard input", + exitCodeOnInvalidInput = 37) +public class EncryptCmd implements Runnable { + + @CommandLine.Option(names = "--no-armor", + description = "ASCII armor the output", + negatable = true) + boolean armor = true; + + @CommandLine.Option(names = {"--as"}, + description = "Type of the input data. Defaults to 'binary'", + paramLabel = "{binary|text|mime}") + EncryptAs type; + + @CommandLine.Option(names = "--with-password", + description = "Encrypt the message with a password", + paramLabel = "PASSWORD") + List withPassword = new ArrayList<>(); + + @CommandLine.Option(names = "--sign-with", + description = "Sign the output with a private key", + paramLabel = "KEY") + List signWith = new ArrayList<>(); + + @CommandLine.Parameters(description = "Certificates the message gets encrypted to", + index = "0..*", + paramLabel = "CERTS") + List certs = new ArrayList<>(); + + @Override + public void run() { + Encrypt encrypt = SopCLI.getSop().encrypt(); + if (type != null) { + try { + encrypt.mode(type); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--as'."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + + if (withPassword.isEmpty() && certs.isEmpty()) { + Print.errln("At least one password or cert file required for encryption."); + System.exit(19); + } + + for (String password : withPassword) { + try { + encrypt.withPassword(password); + } catch (SOPGPException.PasswordNotHumanReadable passwordNotHumanReadable) { + Print.errln("Password is not human-readable."); + Print.trace(passwordNotHumanReadable); + System.exit(passwordNotHumanReadable.getExitCode()); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--with-password'."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + + for (File keyFile : signWith) { + try (FileInputStream keyIn = new FileInputStream(keyFile)) { + encrypt.signWith(keyIn); + } catch (FileNotFoundException e) { + Print.errln("Key file " + keyFile.getAbsolutePath() + " not found."); + Print.trace(e); + System.exit(1); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.KeyIsProtected keyIsProtected) { + Print.errln("Key from " + keyFile.getAbsolutePath() + " is password protected."); + Print.trace(keyIsProtected); + System.exit(1); + } catch (SOPGPException.UnsupportedAsymmetricAlgo unsupportedAsymmetricAlgo) { + Print.errln("Key from " + keyFile.getAbsolutePath() + " has unsupported asymmetric algorithm."); + Print.trace(unsupportedAsymmetricAlgo); + System.exit(unsupportedAsymmetricAlgo.getExitCode()); + } catch (SOPGPException.CertCannotSign certCannotSign) { + Print.errln("Key from " + keyFile.getAbsolutePath() + " cannot sign."); + Print.trace(certCannotSign); + System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("Key file " + keyFile.getAbsolutePath() + " does not contain a valid OpenPGP private key."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + } + + for (File certFile : certs) { + try (FileInputStream certIn = new FileInputStream(certFile)) { + encrypt.withCert(certIn); + } catch (FileNotFoundException e) { + Print.errln("Certificate file " + certFile.getAbsolutePath() + " not found."); + Print.trace(e); + System.exit(1); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.UnsupportedAsymmetricAlgo unsupportedAsymmetricAlgo) { + Print.errln("Certificate from " + certFile.getAbsolutePath() + " has unsupported asymmetric algorithm."); + Print.trace(unsupportedAsymmetricAlgo); + System.exit(unsupportedAsymmetricAlgo.getExitCode()); + } catch (SOPGPException.CertCannotEncrypt certCannotEncrypt) { + Print.errln("Certificate from " + certFile.getAbsolutePath() + " is not capable of encryption."); + Print.trace(certCannotEncrypt); + System.exit(certCannotEncrypt.getExitCode()); + } catch (SOPGPException.BadData badData) { + Print.errln("Certificate file " + certFile.getAbsolutePath() + " does not contain a valid OpenPGP certificate."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + } + + if (!armor) { + encrypt.noArmor(); + } + + try { + Ready ready = encrypt.plaintext(System.in); + ready.writeTo(System.out); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/ExtractCert.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ExtractCertCmd.java similarity index 56% rename from pgpainless-sop/src/main/java/org/pgpainless/sop/commands/ExtractCert.java rename to sop-java-picocli/src/main/java/sop/cli/picocli/commands/ExtractCertCmd.java index 52480575..16d9ac24 100644 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/ExtractCert.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/ExtractCertCmd.java @@ -13,25 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.pgpainless.sop.commands; - -import static org.pgpainless.sop.Print.err_ln; -import static org.pgpainless.sop.Print.print_ln; +package sop.cli.picocli.commands; import java.io.IOException; -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyRing; -import org.bouncycastle.openpgp.PGPSecretKeyRing; -import org.pgpainless.PGPainless; -import org.pgpainless.key.util.KeyRingUtils; -import org.pgpainless.sop.Print; import picocli.CommandLine; +import sop.Ready; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.ExtractCert; @CommandLine.Command(name = "extract-cert", description = "Extract a public key certificate from a secret key from standard input", exitCodeOnInvalidInput = 37) -public class ExtractCert implements Runnable { +public class ExtractCertCmd implements Runnable { @CommandLine.Option(names = "--no-armor", description = "ASCII armor the output", @@ -40,15 +36,22 @@ public class ExtractCert implements Runnable { @Override public void run() { - try { - PGPSecretKeyRing secretKeys = PGPainless.readKeyRing().secretKeyRing(System.in); - PGPPublicKeyRing publicKeys = KeyRingUtils.publicKeyRingFrom(secretKeys); + ExtractCert extractCert = SopCLI.getSop().extractCert(); + if (!armor) { + extractCert.noArmor(); + } - print_ln(Print.toString(publicKeys, armor)); - } catch (IOException | PGPException e) { - err_ln("Error extracting certificate from keys;"); - err_ln(e.getMessage()); + try { + Ready ready = extractCert.key(System.in); + ready.writeTo(System.out); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("Standard Input does not contain valid OpenPGP private key material."); + Print.trace(badData); + System.exit(badData.getExitCode()); } } } diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/GenerateKeyCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/GenerateKeyCmd.java new file mode 100644 index 00000000..b9ca045f --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/GenerateKeyCmd.java @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import picocli.CommandLine; +import sop.Ready; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.GenerateKey; + +@CommandLine.Command(name = "generate-key", + description = "Generate a secret key", + exitCodeOnInvalidInput = 37) +public class GenerateKeyCmd implements Runnable { + + @CommandLine.Option(names = "--no-armor", + description = "ASCII armor the output", + negatable = true) + boolean armor = true; + + @CommandLine.Parameters(description = "User-ID, eg. \"Alice \"") + List userId = new ArrayList<>(); + + @Override + public void run() { + GenerateKey generateKey = SopCLI.getSop().generateKey(); + for (String userId : userId) { + generateKey.userId(userId); + } + + if (!armor) { + generateKey.noArmor(); + } + + try { + Ready ready = generateKey.generate(); + ready.writeTo(System.out); + } catch (SOPGPException.MissingArg missingArg) { + Print.errln("Missing argument."); + Print.trace(missingArg); + System.exit(missingArg.getExitCode()); + } catch (SOPGPException.UnsupportedAsymmetricAlgo unsupportedAsymmetricAlgo) { + Print.errln("Unsupported asymmetric algorithm."); + Print.trace(unsupportedAsymmetricAlgo); + System.exit(unsupportedAsymmetricAlgo.getExitCode()); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } + } +} diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/SignCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/SignCmd.java new file mode 100644 index 00000000..6140bfce --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/SignCmd.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import picocli.CommandLine; +import sop.Ready; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.enums.SignAs; +import sop.exception.SOPGPException; +import sop.operation.Sign; + +@CommandLine.Command(name = "sign", + description = "Create a detached signature on the data from standard input", + exitCodeOnInvalidInput = 37) +public class SignCmd implements Runnable { + + @CommandLine.Option(names = "--no-armor", + description = "ASCII armor the output", + negatable = true) + boolean armor = true; + + @CommandLine.Option(names = "--as", description = "Defaults to 'binary'. If '--as=text' and the input data is not valid UTF-8, sign fails with return code 53.", + paramLabel = "{binary|text}") + SignAs type; + + @CommandLine.Parameters(description = "Secret keys used for signing", + paramLabel = "KEY") + List secretKeyFile = new ArrayList<>(); + + @Override + public void run() { + Sign sign = SopCLI.getSop().sign(); + + if (type != null) { + try { + sign.mode(type); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--as'"); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + + if (secretKeyFile.isEmpty()) { + Print.errln("Missing required parameter 'KEY'."); + System.exit(19); + } + + for (File keyFile : secretKeyFile) { + try (FileInputStream keyIn = new FileInputStream(keyFile)) { + sign.key(keyIn); + } catch (FileNotFoundException e) { + Print.errln("File " + keyFile.getAbsolutePath() + " does not exist."); + Print.trace(e); + System.exit(1); + } catch (IOException e) { + Print.errln("Cannot access file " + keyFile.getAbsolutePath()); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.KeyIsProtected e) { + Print.errln("Key " + keyFile.getName() + " is password protected."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("Bad data in key file " + keyFile.getAbsolutePath() + ":"); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + } + + if (!armor) { + sign.noArmor(); + } + + try { + Ready ready = sign.data(System.in); + ready.writeTo(System.out); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.ExpectedText expectedText) { + Print.errln("Expected text input, but got binary data."); + Print.trace(expectedText); + System.exit(expectedText.getExitCode()); + } + } +} diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/VerifyCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/VerifyCmd.java new file mode 100644 index 00000000..18e1c03d --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/VerifyCmd.java @@ -0,0 +1,143 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import picocli.CommandLine; +import sop.Verification; +import sop.cli.picocli.DateParser; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.Verify; + +@CommandLine.Command(name = "verify", + description = "Verify a detached signature over the data from standard input", + exitCodeOnInvalidInput = 37) +public class VerifyCmd implements Runnable { + + @CommandLine.Parameters(index = "0", + description = "Detached signature", + paramLabel = "SIGNATURE") + File signature; + + @CommandLine.Parameters(index = "1..*", + arity = "1..*", + description = "Public key certificates", + paramLabel = "CERT") + List certificates = new ArrayList<>(); + + @CommandLine.Option(names = {"--not-before"}, + description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + + "Reject signatures with a creation date not in range.\n" + + "Defaults to beginning of time (\"-\").", + paramLabel = "DATE") + String notBefore = "-"; + + @CommandLine.Option(names = {"--not-after"}, + description = "ISO-8601 formatted UTC date (eg. '2020-11-23T16:35Z)\n" + + "Reject signatures with a creation date not in range.\n" + + "Defaults to current system time (\"now\").\n" + + "Accepts special value \"-\" for end of time.", + paramLabel = "DATE") + String notAfter = "now"; + + @Override + public void run() { + Verify verify = SopCLI.getSop().verify(); + if (notAfter != null) { + try { + verify.notAfter(DateParser.parseNotAfter(notAfter)); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--not-after'."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + if (notBefore != null) { + try { + verify.notBefore(DateParser.parseNotBefore(notBefore)); + } catch (SOPGPException.UnsupportedOption unsupportedOption) { + Print.errln("Unsupported option '--not-before'."); + Print.trace(unsupportedOption); + System.exit(unsupportedOption.getExitCode()); + } + } + + for (File certFile : certificates) { + try (FileInputStream certIn = new FileInputStream(certFile)) { + verify.cert(certIn); + } catch (FileNotFoundException fileNotFoundException) { + Print.errln("Certificate file " + certFile.getAbsolutePath() + " not found."); + + Print.trace(fileNotFoundException); + System.exit(1); + } catch (IOException ioException) { + Print.errln("IO Error."); + Print.trace(ioException); + System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("Certificate file " + certFile.getAbsolutePath() + " appears to not contain a valid OpenPGP certificate."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + } + + if (signature != null) { + try (FileInputStream sigIn = new FileInputStream(signature)) { + verify.signatures(sigIn); + } catch (FileNotFoundException e) { + Print.errln("Signature file " + signature.getAbsolutePath() + " does not exist."); + Print.trace(e); + System.exit(1); + } catch (IOException e) { + Print.errln("IO Error."); + Print.trace(e); + System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("File " + signature.getAbsolutePath() + " does not contain a valid OpenPGP signature."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + } + + List verifications = null; + try { + verifications = verify.data(System.in); + } catch (SOPGPException.NoSignature e) { + Print.errln("No verifiable signature found."); + Print.trace(e); + System.exit(e.getExitCode()); + } catch (IOException ioException) { + Print.errln("IO Error."); + Print.trace(ioException); + System.exit(1); + } catch (SOPGPException.BadData badData) { + Print.errln("Standard Input appears not to contain a valid OpenPGP message."); + Print.trace(badData); + System.exit(badData.getExitCode()); + } + for (Verification verification : verifications) { + Print.outln(verification.toString()); + } + } +} diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/commands/VersionCmd.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/VersionCmd.java new file mode 100644 index 00000000..a0a93e2a --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/VersionCmd.java @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import picocli.CommandLine; +import sop.cli.picocli.Print; +import sop.cli.picocli.SopCLI; +import sop.operation.Version; + +@CommandLine.Command(name = "version", description = "Display version information about the tool", + exitCodeOnInvalidInput = 37) +public class VersionCmd implements Runnable { + + @Override + public void run() { + Version version = SopCLI.getSop().version(); + + Print.outln(version.getName() + " " + version.getVersion()); + } +} diff --git a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/package-info.java b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/package-info.java similarity index 94% rename from pgpainless-sop/src/main/java/org/pgpainless/sop/commands/package-info.java rename to sop-java-picocli/src/main/java/sop/cli/picocli/commands/package-info.java index 633d7afb..28684d5a 100644 --- a/pgpainless-sop/src/main/java/org/pgpainless/sop/commands/package-info.java +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/commands/package-info.java @@ -16,4 +16,4 @@ /** * Subcommands of the PGPainless SOP. */ -package org.pgpainless.sop.commands; +package sop.cli.picocli.commands; diff --git a/sop-java-picocli/src/main/java/sop/cli/picocli/package-info.java b/sop-java-picocli/src/main/java/sop/cli/picocli/package-info.java new file mode 100644 index 00000000..af01095d --- /dev/null +++ b/sop-java-picocli/src/main/java/sop/cli/picocli/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Implementation of the Stateless OpenPGP Command Line Interface using Picocli. + */ +package sop.cli.picocli; diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/DateParserTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/DateParserTest.java new file mode 100644 index 00000000..0be6f7f2 --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/DateParserTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Date; + +import org.junit.jupiter.api.Test; +import sop.util.UTCUtil; + +public class DateParserTest { + + @Test + public void parseNotAfterDashReturnsEndOfTime() { + assertEquals(DateParser.END_OF_TIME, DateParser.parseNotAfter("-")); + } + + @Test + public void parseNotBeforeDashReturnsBeginningOfTime() { + assertEquals(DateParser.BEGINNING_OF_TIME, DateParser.parseNotBefore("-")); + } + + @Test + public void parseNotAfterNowReturnsNow() { + assertEquals(new Date().getTime(), DateParser.parseNotAfter("now").getTime(), 1000); + } + + @Test + public void parseNotBeforeNowReturnsNow() { + assertEquals(new Date().getTime(), DateParser.parseNotBefore("now").getTime(), 1000); + } + + @Test + public void parseNotAfterTimestamp() { + String timestamp = "2019-10-24T23:48:29Z"; + Date date = DateParser.parseNotAfter(timestamp); + assertEquals(timestamp, UTCUtil.formatUTCDate(date)); + } + + @Test + public void parseNotBeforeTimestamp() { + String timestamp = "2019-10-29T18:36:45Z"; + Date date = DateParser.parseNotBefore(timestamp); + assertEquals(timestamp, UTCUtil.formatUTCDate(date)); + } +} 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 new file mode 100644 index 00000000..3ee1461a --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/SOPTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli; + +import static org.mockito.Mockito.mock; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.Test; +import sop.SOP; + +public class SOPTest { + + @Test + @ExpectSystemExitWithStatus(69) + public void assertExitOnInvalidSubcommand() { + SOP sop = mock(SOP.class); + SopCLI.setSopInstance(sop); + + SopCLI.main(new String[] {"invalid"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertThrowsIfNoSOPBackendSet() { + SopCLI.SOP_INSTANCE = null; + // At this point, no SOP backend is set, so an InvalidStateException triggers exit(1) + SopCLI.main(new String[] {"armor"}); + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/ArmorCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/ArmorCmdTest.java new file mode 100644 index 00000000..e4a5d84a --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/ArmorCmdTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.OutputStream; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import com.ginsberg.junit.exit.FailOnSystemExit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sop.Ready; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.enums.ArmorLabel; +import sop.exception.SOPGPException; +import sop.operation.Armor; + +public class ArmorCmdTest { + + private Armor armor; + private SOP sop; + + @BeforeEach + public void mockComponents() throws SOPGPException.BadData { + armor = mock(Armor.class); + sop = mock(SOP.class); + when(sop.armor()).thenReturn(armor); + when(armor.data(any())).thenReturn(nopReady()); + + SopCLI.setSopInstance(sop); + } + + @Test + public void assertAllowNestedIsCalledWhenFlagged() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"armor", "--allow-nested"}); + verify(armor, times(1)).allowNested(); + } + + @Test + public void assertAllowNestedIsNotCalledByDefault() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"armor"}); + verify(armor, never()).allowNested(); + } + + @Test + public void assertLabelIsNotCalledByDefault() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"armor"}); + verify(armor, never()).label(any()); + } + + @Test + public void assertLabelIsCalledWhenFlaggedWithArgument() throws SOPGPException.UnsupportedOption { + for (ArmorLabel label : ArmorLabel.values()) { + SopCLI.main(new String[] {"armor", "--label", label.name()}); + verify(armor, times(1)).label(label); + } + } + + @Test + public void assertDataIsAlwaysCalled() throws SOPGPException.BadData { + SopCLI.main(new String[] {"armor"}); + verify(armor, times(1)).data(any()); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void assertThrowsForInvalidLabel() { + SopCLI.main(new String[] {"armor", "--label", "Invalid"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void ifLabelsUnsupportedExit37() throws SOPGPException.UnsupportedOption { + when(armor.label(any())).thenThrow(new SOPGPException.UnsupportedOption()); + + SopCLI.main(new String[] {"armor", "--label", "Sig"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void ifAllowNestedUnsupportedExit37() throws SOPGPException.UnsupportedOption { + when(armor.allowNested()).thenThrow(new SOPGPException.UnsupportedOption()); + + SopCLI.main(new String[] {"armor", "--allow-nested"}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void ifBadDataExit41() throws SOPGPException.BadData { + when(armor.data(any())).thenThrow(new SOPGPException.BadData(new IOException())); + + SopCLI.main(new String[] {"armor"}); + } + + @Test + @FailOnSystemExit + public void ifNoErrorsNoExit() { + when(sop.armor()).thenReturn(armor); + + SopCLI.main(new String[] {"armor"}); + } + + private static Ready nopReady() { + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) { + } + }; + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DearmorCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DearmorCmdTest.java new file mode 100644 index 00000000..d83f747e --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DearmorCmdTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.OutputStream; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sop.Ready; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.Dearmor; + +public class DearmorCmdTest { + + private SOP sop; + private Dearmor dearmor; + + @BeforeEach + public void mockComponents() throws IOException, SOPGPException.BadData { + sop = mock(SOP.class); + dearmor = mock(Dearmor.class); + when(dearmor.data(any())).thenReturn(nopReady()); + when(sop.dearmor()).thenReturn(dearmor); + + SopCLI.setSopInstance(sop); + } + + private static Ready nopReady() { + return new Ready() { + @Override + public void writeTo(OutputStream outputStream) { + } + }; + } + + @Test + public void assertDataIsCalled() throws IOException, SOPGPException.BadData { + SopCLI.main(new String[] {"dearmor"}); + verify(dearmor, times(1)).data(any()); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void assertBadDataCausesExit41() throws IOException, SOPGPException.BadData { + when(dearmor.data(any())).thenThrow(new SOPGPException.BadData(new IOException("invalid armor"))); + SopCLI.main(new String[] {"dearmor"}); + } +} 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 new file mode 100644 index 00000000..4e0b2497 --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/DecryptCmdTest.java @@ -0,0 +1,346 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Date; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; +import sop.DecryptionResult; +import sop.ReadyWithResult; +import sop.SOP; +import sop.SessionKey; +import sop.Verification; +import sop.cli.picocli.DateParser; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.Decrypt; +import sop.util.HexUtil; +import sop.util.UTCUtil; + +public class DecryptCmdTest { + + private Decrypt decrypt; + + @BeforeEach + public void mockComponents() throws SOPGPException.UnsupportedOption, SOPGPException.MissingArg, SOPGPException.BadData, SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.PasswordNotHumanReadable, SOPGPException.CannotDecrypt { + SOP sop = mock(SOP.class); + decrypt = mock(Decrypt.class); + + when(decrypt.verifyNotAfter(any())).thenReturn(decrypt); + when(decrypt.verifyNotBefore(any())).thenReturn(decrypt); + when(decrypt.withPassword(any())).thenReturn(decrypt); + when(decrypt.withSessionKey(any())).thenReturn(decrypt); + when(decrypt.withKey(any())).thenReturn(decrypt); + when(decrypt.ciphertext(any())).thenReturn(nopReadyWithResult()); + + when(sop.decrypt()).thenReturn(decrypt); + + SopCLI.setSopInstance(sop); + } + + private static ReadyWithResult nopReadyWithResult() { + return new ReadyWithResult() { + @Override + public DecryptionResult writeTo(OutputStream outputStream) { + return new DecryptionResult(null, Collections.emptyList()); + } + }; + } + + @Test + @ExpectSystemExitWithStatus(19) + public void missingArgumentsExceptionCausesExit19() throws SOPGPException.MissingArg, SOPGPException.BadData, SOPGPException.CannotDecrypt { + when(decrypt.ciphertext(any())).thenThrow(new SOPGPException.MissingArg("Missing arguments.")); + SopCLI.main(new String[] {"decrypt"}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void badDataExceptionCausesExit41() throws SOPGPException.MissingArg, SOPGPException.BadData, SOPGPException.CannotDecrypt { + when(decrypt.ciphertext(any())).thenThrow(new SOPGPException.BadData(new IOException())); + SopCLI.main(new String[] {"decrypt"}); + } + + @Test + @ExpectSystemExitWithStatus(31) + public void assertNotHumanReadablePasswordCausesExit31() throws SOPGPException.PasswordNotHumanReadable, + SOPGPException.UnsupportedOption { + when(decrypt.withPassword(any())).thenThrow(new SOPGPException.PasswordNotHumanReadable()); + SopCLI.main(new String[] {"decrypt", "--with-password", "pretendThisIsNotReadable"}); + } + + @Test + public void assertWithPasswordPassesPasswordDown() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"decrypt", "--with-password", "orange"}); + verify(decrypt, times(1)).withPassword("orange"); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void assertUnsupportedWithPasswordCausesExit37() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + when(decrypt.withPassword(any())).thenThrow(new SOPGPException.UnsupportedOption()); + SopCLI.main(new String[] {"decrypt", "--with-password", "swordfish"}); + } + + @Test + public void assertDefaultTimeRangesAreUsedIfNotOverwritten() throws SOPGPException.UnsupportedOption { + Date now = new Date(); + SopCLI.main(new String[] {"decrypt"}); + verify(decrypt, times(1)).verifyNotBefore(DateParser.BEGINNING_OF_TIME); + verify(decrypt, times(1)).verifyNotAfter( + ArgumentMatchers.argThat(argument -> { + // allow 1 second difference + return Math.abs(now.getTime() - argument.getTime()) <= 1000; + })); + } + + @Test + public void assertVerifyNotAfterAndBeforeDashResultsInMaxTimeRange() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"decrypt", "--not-before", "-", "--not-after", "-"}); + verify(decrypt, times(1)).verifyNotBefore(DateParser.BEGINNING_OF_TIME); + verify(decrypt, times(1)).verifyNotAfter(DateParser.END_OF_TIME); + } + + @Test + public void assertVerifyNotAfterAndBeforeNowResultsInMinTimeRange() throws SOPGPException.UnsupportedOption { + Date now = new Date(); + ArgumentMatcher isMaxOneSecOff = argument -> { + // Allow less than 1 second difference + return Math.abs(now.getTime() - argument.getTime()) <= 1000; + }; + + SopCLI.main(new String[] {"decrypt", "--not-before", "now", "--not-after", "now"}); + verify(decrypt, times(1)).verifyNotAfter(ArgumentMatchers.argThat(isMaxOneSecOff)); + verify(decrypt, times(1)).verifyNotBefore(ArgumentMatchers.argThat(isMaxOneSecOff)); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertMalformedDateInNotBeforeCausesExit1() { + // ParserException causes exit(1) + SopCLI.main(new String[] {"decrypt", "--not-before", "invalid"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertMalformedDateInNotAfterCausesExit1() { + // ParserException causes exit(1) + SopCLI.main(new String[] {"decrypt", "--not-after", "invalid"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void assertUnsupportedNotAfterCausesExit37() throws SOPGPException.UnsupportedOption { + when(decrypt.verifyNotAfter(any())).thenThrow(new SOPGPException.UnsupportedOption()); + SopCLI.main(new String[] {"decrypt", "--not-after", "now"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void assertUnsupportedNotBeforeCausesExit37() throws SOPGPException.UnsupportedOption { + when(decrypt.verifyNotBefore(any())).thenThrow(new SOPGPException.UnsupportedOption()); + SopCLI.main(new String[] {"decrypt", "--not-before", "now"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertExistingSessionKeyOutFileCausesExit1() throws IOException { + File tempFile = File.createTempFile("existing-session-key-", ".tmp"); + tempFile.deleteOnExit(); + SopCLI.main(new String[] {"decrypt", "--session-key-out", tempFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void assertWhenSessionKeyCannotBeExtractedExit37() throws IOException { + Path tempDir = Files.createTempDirectory("session-key-out-dir"); + File tempFile = new File(tempDir.toFile(), "session-key"); + tempFile.deleteOnExit(); + SopCLI.main(new String[] {"decrypt", "--session-key-out", tempFile.getAbsolutePath()}); + } + + @Test + public void assertSessionKeyIsProperlyWrittenToSessionKeyFile() throws SOPGPException.CannotDecrypt, SOPGPException.MissingArg, SOPGPException.BadData, IOException { + byte[] key = "C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137".getBytes(StandardCharsets.UTF_8); + when(decrypt.ciphertext(any())).thenReturn(new ReadyWithResult() { + @Override + public DecryptionResult writeTo(OutputStream outputStream) { + return new DecryptionResult( + new SessionKey((byte) 9, key), + Collections.emptyList() + ); + } + }); + Path tempDir = Files.createTempDirectory("session-key-out-dir"); + File tempFile = new File(tempDir.toFile(), "session-key"); + tempFile.deleteOnExit(); + SopCLI.main(new String[] {"decrypt", "--session-key-out", tempFile.getAbsolutePath()}); + + ByteArrayOutputStream bytesInFile = new ByteArrayOutputStream(); + try (FileInputStream fileIn = new FileInputStream(tempFile)) { + byte[] buf = new byte[32]; + int read = fileIn.read(buf); + while (read != -1) { + bytesInFile.write(buf, 0, read); + read = fileIn.read(buf); + } + } + + byte[] algAndKey = new byte[key.length + 1]; + algAndKey[0] = (byte) 9; + System.arraycopy(key, 0, algAndKey, 1, key.length); + assertArrayEquals(algAndKey, bytesInFile.toByteArray()); + } + + @Test + @ExpectSystemExitWithStatus(29) + public void assertUnableToDecryptExceptionResultsInExit29() throws SOPGPException.CannotDecrypt, SOPGPException.MissingArg, SOPGPException.BadData { + when(decrypt.ciphertext(any())).thenThrow(new SOPGPException.CannotDecrypt()); + SopCLI.main(new String[] {"decrypt"}); + } + + @Test + @ExpectSystemExitWithStatus(3) + public void assertNoSignatureExceptionCausesExit3() throws SOPGPException.CannotDecrypt, SOPGPException.MissingArg, SOPGPException.BadData { + when(decrypt.ciphertext(any())).thenReturn(new ReadyWithResult() { + @Override + public DecryptionResult writeTo(OutputStream outputStream) throws SOPGPException.NoSignature { + throw new SOPGPException.NoSignature(); + } + }); + SopCLI.main(new String[] {"decrypt"}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void badDataInVerifyWithCausesExit41() throws IOException, SOPGPException.BadData { + when(decrypt.verifyWithCert(any())).thenThrow(new SOPGPException.BadData(new IOException())); + File tempFile = File.createTempFile("verify-with-", ".tmp"); + SopCLI.main(new String[] {"decrypt", "--verify-with", tempFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void unexistentCertFileCausesExit1() { + SopCLI.main(new String[] {"decrypt", "--verify-with", "invalid"}); + } + + @Test + public void existingVerifyOutFileIsUnlinkedBeforeVerification() throws IOException, SOPGPException.CannotDecrypt, SOPGPException.MissingArg, SOPGPException.BadData { + File certFile = File.createTempFile("existing-verify-out-cert", ".asc"); + File existingVerifyOut = File.createTempFile("existing-verify-out", ".tmp"); + byte[] data = "some data".getBytes(StandardCharsets.UTF_8); + try (FileOutputStream out = new FileOutputStream(existingVerifyOut)) { + out.write(data); + } + Date date = UTCUtil.parseUTCDate("2021-07-11T20:58:23Z"); + when(decrypt.ciphertext(any())).thenReturn(new ReadyWithResult() { + @Override + public DecryptionResult writeTo(OutputStream outputStream) { + return new DecryptionResult(null, Collections.singletonList( + new Verification( + date, + "1B66A707819A920925BC6777C3E0AFC0B2DFF862", + "C8CD564EBF8D7BBA90611D8D071773658BF6BF86")) + ); + } + }); + + SopCLI.main(new String[] {"decrypt", "--verify-out", existingVerifyOut.getAbsolutePath(), "--verify-with", certFile.getAbsolutePath()}); + try (BufferedReader reader = new BufferedReader(new FileReader(existingVerifyOut))) { + String line = reader.readLine(); + assertEquals("2021-07-11T20:58:23Z 1B66A707819A920925BC6777C3E0AFC0B2DFF862 C8CD564EBF8D7BBA90611D8D071773658BF6BF86", line); + } + } + + @Test + public void assertWithSessionKeyIsPassedDown() throws SOPGPException.UnsupportedOption { + SessionKey key1 = new SessionKey((byte) 9, HexUtil.hexToBytes("C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137")); + SessionKey key2 = new SessionKey((byte) 9, HexUtil.hexToBytes("FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD")); + SopCLI.main(new String[] {"decrypt", + "--with-session-key", "9:C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137", + "--with-session-key", "9:FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD"}); + verify(decrypt).withSessionKey(key1); + verify(decrypt).withSessionKey(key2); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertMalformedSessionKeysResultInExit1() { + SopCLI.main(new String[] {"decrypt", + "--with-session-key", "C7CBDAF42537776F12509B5168793C26B93294E5ABDFA73224FB0177123E9137"}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void assertBadDataInKeysResultsInExit41() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.BadData, IOException { + when(decrypt.withKey(any())).thenThrow(new SOPGPException.BadData(new IOException())); + File tempKeyFile = File.createTempFile("key-", ".tmp"); + SopCLI.main(new String[] {"decrypt", tempKeyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertKeyFileNotFoundCausesExit1() { + SopCLI.main(new String[] {"decrypt", "nonexistent-key"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void assertProtectedKeyCausesExit1() throws IOException, SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.BadData { + when(decrypt.withKey(any())).thenThrow(new SOPGPException.KeyIsProtected()); + File tempKeyFile = File.createTempFile("key-", ".tmp"); + SopCLI.main(new String[] {"decrypt", tempKeyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(13) + public void assertUnsupportedAlgorithmExceptionCausesExit13() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.BadData, IOException { + when(decrypt.withKey(any())).thenThrow(new SOPGPException.UnsupportedAsymmetricAlgo(new IOException())); + File tempKeyFile = File.createTempFile("key-", ".tmp"); + SopCLI.main(new String[] {"decrypt", tempKeyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(23) + public void verifyOutWithoutVerifyWithCausesExit23() { + SopCLI.main(new String[] {"decrypt", "--verify-out", "out.file"}); + } +} 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 new file mode 100644 index 00000000..ce56bbcd --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/EncryptCmdTest.java @@ -0,0 +1,204 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sop.Ready; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.enums.EncryptAs; +import sop.exception.SOPGPException; +import sop.operation.Encrypt; + +public class EncryptCmdTest { + + Encrypt encrypt; + + @BeforeEach + public void mockComponents() throws IOException { + encrypt = mock(Encrypt.class); + when(encrypt.plaintext(any())).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) { + + } + }); + + SOP sop = mock(SOP.class); + when(sop.encrypt()).thenReturn(encrypt); + + SopCLI.setSopInstance(sop); + } + + @Test + @ExpectSystemExitWithStatus(19) + public void missingPasswordAndCertFileCauseExit19() { + SopCLI.main(new String[] {"encrypt", "--no-armor"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void as_unsupportedEncryptAsCausesExit37() throws SOPGPException.UnsupportedOption { + when(encrypt.mode(any())).thenThrow(new SOPGPException.UnsupportedOption()); + + SopCLI.main(new String[] {"encrypt", "--as", "Binary"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void as_invalidModeOptionCausesExit37() { + SopCLI.main(new String[] {"encrypt", "--as", "invalid"}); + } + + @Test + public void as_modeIsPassedDown() throws SOPGPException.UnsupportedOption { + for (EncryptAs mode : EncryptAs.values()) { + SopCLI.main(new String[] {"encrypt", "--as", mode.name(), "--with-password", "0rbit"}); + verify(encrypt, times(1)).mode(mode); + } + } + + @Test + @ExpectSystemExitWithStatus(31) + public void withPassword_notHumanReadablePasswordCausesExit31() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + when(encrypt.withPassword("pretendThisIsNotReadable")).thenThrow(new SOPGPException.PasswordNotHumanReadable()); + + SopCLI.main(new String[] {"encrypt", "--with-password", "pretendThisIsNotReadable"}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void withPassword_unsupportedWithPasswordCausesExit37() throws SOPGPException.PasswordNotHumanReadable, SOPGPException.UnsupportedOption { + when(encrypt.withPassword(any())).thenThrow(new SOPGPException.UnsupportedOption()); + + SopCLI.main(new String[] {"encrypt", "--with-password", "orange"}); + } + + @Test + public void signWith_multipleTimesGetPassedDown() throws IOException, SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotSign, 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()}); + verify(encrypt, times(2)).signWith(any()); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void signWith_nonExistentKeyFileCausesExit1() { + SopCLI.main(new String[] {"encrypt", "--with-password", "admin", "--sign-with", "nonExistent.asc"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void signWith_keyIsProtectedCausesExit1() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotSign, SOPGPException.BadData, IOException { + when(encrypt.signWith(any())).thenThrow(new SOPGPException.KeyIsProtected()); + File keyFile = File.createTempFile("sign-with", ".asc"); + SopCLI.main(new String[] {"encrypt", "--sign-with", keyFile.getAbsolutePath(), "--with-password", "starship"}); + } + + @Test + @ExpectSystemExitWithStatus(13) + public void signWith_unsupportedAsymmetricAlgoCausesExit13() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotSign, SOPGPException.BadData, IOException { + when(encrypt.signWith(any())).thenThrow(new SOPGPException.UnsupportedAsymmetricAlgo(new Exception())); + File keyFile = File.createTempFile("sign-with", ".asc"); + SopCLI.main(new String[] {"encrypt", "--with-password", "123456", "--sign-with", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void signWith_certCannotSignCausesExit1() throws IOException, SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotSign, SOPGPException.BadData { + when(encrypt.signWith(any())).thenThrow(new SOPGPException.CertCannotSign()); + File keyFile = File.createTempFile("sign-with", ".asc"); + SopCLI.main(new String[] {"encrypt", "--with-password", "dragon", "--sign-with", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void signWith_badDataCausesExit41() throws SOPGPException.KeyIsProtected, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotSign, SOPGPException.BadData, IOException { + when(encrypt.signWith(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()}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void cert_nonExistentCertFileCausesExit1() { + SopCLI.main(new String[] {"encrypt", "invalid.asc"}); + } + + @Test + @ExpectSystemExitWithStatus(13) + public void cert_unsupportedAsymmetricAlgorithmCausesExit13() throws IOException, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotEncrypt, SOPGPException.BadData { + when(encrypt.withCert(any())).thenThrow(new SOPGPException.UnsupportedAsymmetricAlgo(new Exception())); + File certFile = File.createTempFile("cert", ".asc"); + SopCLI.main(new String[] {"encrypt", certFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(17) + public void cert_certCannotEncryptCausesExit17() throws IOException, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotEncrypt, SOPGPException.BadData { + when(encrypt.withCert(any())).thenThrow(new SOPGPException.CertCannotEncrypt()); + File certFile = File.createTempFile("cert", ".asc"); + SopCLI.main(new String[] {"encrypt", certFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void cert_badDataCausesExit41() throws IOException, SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.CertCannotEncrypt, SOPGPException.BadData { + when(encrypt.withCert(any())).thenThrow(new SOPGPException.BadData(new IOException())); + File certFile = File.createTempFile("cert", ".asc"); + SopCLI.main(new String[] {"encrypt", certFile.getAbsolutePath()}); + } + + @Test + public void noArmor_notCalledByDefault() { + SopCLI.main(new String[] {"encrypt", "--with-password", "clownfish"}); + verify(encrypt, never()).noArmor(); + } + + @Test + public void noArmor_callGetsPassedDown() { + SopCLI.main(new String[] {"encrypt", "--with-password", "monkey", "--no-armor"}); + verify(encrypt, times(1)).noArmor(); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void writeTo_ioExceptionCausesExit1() throws IOException { + when(encrypt.plaintext(any())).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + throw new IOException(); + } + }); + + SopCLI.main(new String[] {"encrypt", "--with-password", "wildcat"}); + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/ExtractCertCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/ExtractCertCmdTest.java new file mode 100644 index 00000000..6e453894 --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/ExtractCertCmdTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.OutputStream; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sop.Ready; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.ExtractCert; + +public class ExtractCertCmdTest { + + ExtractCert extractCert; + + @BeforeEach + public void mockComponents() throws IOException, SOPGPException.BadData { + extractCert = mock(ExtractCert.class); + when(extractCert.key(any())).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) { + } + }); + + SOP sop = mock(SOP.class); + when(sop.extractCert()).thenReturn(extractCert); + + SopCLI.setSopInstance(sop); + } + + @Test + public void noArmor_notCalledByDefault() { + SopCLI.main(new String[] {"extract-cert"}); + verify(extractCert, never()).noArmor(); + } + + @Test + public void noArmor_passedDown() { + SopCLI.main(new String[] {"extract-cert", "--no-armor"}); + verify(extractCert, times(1)).noArmor(); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void key_ioExceptionCausesExit1() throws IOException, SOPGPException.BadData { + when(extractCert.key(any())).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + throw new IOException(); + } + }); + SopCLI.main(new String[] {"extract-cert"}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void key_badDataCausesExit41() throws IOException, SOPGPException.BadData { + when(extractCert.key(any())).thenThrow(new SOPGPException.BadData(new IOException())); + SopCLI.main(new String[] {"extract-cert"}); + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/GenerateKeyCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/GenerateKeyCmdTest.java new file mode 100644 index 00000000..4c061d43 --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/GenerateKeyCmdTest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.OutputStream; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.Mockito; +import sop.Ready; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.GenerateKey; + +public class GenerateKeyCmdTest { + + GenerateKey generateKey; + + @BeforeEach + public void mockComponents() throws SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.MissingArg, IOException { + generateKey = mock(GenerateKey.class); + when(generateKey.generate()).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) { + + } + }); + + SOP sop = mock(SOP.class); + when(sop.generateKey()).thenReturn(generateKey); + + SopCLI.setSopInstance(sop); + } + + @Test + public void noArmor_notCalledByDefault() { + SopCLI.main(new String[] {"generate-key", "Alice"}); + verify(generateKey, never()).noArmor(); + } + + @Test + public void noArmor_passedDown() { + SopCLI.main(new String[] {"generate-key", "--no-armor", "Alice"}); + verify(generateKey, times(1)).noArmor(); + } + + @Test + public void userId_multipleUserIdsPassedDownInProperOrder() { + SopCLI.main(new String[] {"generate-key", "Alice ", "Bob "}); + + InOrder inOrder = Mockito.inOrder(generateKey); + inOrder.verify(generateKey).userId("Alice "); + inOrder.verify(generateKey).userId("Bob "); + + verify(generateKey, times(2)).userId(any()); + } + + @Test + @ExpectSystemExitWithStatus(19) + public void missingArgumentCausesExit19() throws SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.MissingArg, IOException { + // TODO: RFC4880-bis and the current Stateless OpenPGP CLI spec allow keys to have no user-ids, + // so we might want to change this test in the future. + when(generateKey.generate()).thenThrow(new SOPGPException.MissingArg("Missing user-id.")); + SopCLI.main(new String[] {"generate-key"}); + } + + @Test + @ExpectSystemExitWithStatus(13) + public void unsupportedAsymmetricAlgorithmCausesExit13() throws SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.MissingArg, IOException { + when(generateKey.generate()).thenThrow(new SOPGPException.UnsupportedAsymmetricAlgo(new Exception())); + SopCLI.main(new String[] {"generate-key", "Alice"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void ioExceptionCausesExit1() throws SOPGPException.UnsupportedAsymmetricAlgo, SOPGPException.MissingArg, IOException { + when(generateKey.generate()).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + throw new IOException(); + } + }); + SopCLI.main(new String[] {"generate-key", "Alice"}); + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/SignCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/SignCmdTest.java new file mode 100644 index 00000000..98220c79 --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/SignCmdTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sop.Ready; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.Sign; + +public class SignCmdTest { + + Sign sign; + File keyFile; + + @BeforeEach + public void mockComponents() throws IOException, SOPGPException.ExpectedText { + sign = mock(Sign.class); + when(sign.data(any())).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) { + + } + }); + + SOP sop = mock(SOP.class); + when(sop.sign()).thenReturn(sign); + + SopCLI.setSopInstance(sop); + + keyFile = File.createTempFile("sign-", ".asc"); + } + + @Test + public void as_optionsAreCaseInsensitive() { + SopCLI.main(new String[] {"sign", "--as", "Binary", keyFile.getAbsolutePath()}); + SopCLI.main(new String[] {"sign", "--as", "binary", keyFile.getAbsolutePath()}); + SopCLI.main(new String[] {"sign", "--as", "BINARY", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void as_invalidOptionCausesExit37() { + SopCLI.main(new String[] {"sign", "--as", "Invalid", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void as_unsupportedOptionCausesExit37() throws SOPGPException.UnsupportedOption { + when(sign.mode(any())).thenThrow(new SOPGPException.UnsupportedOption()); + SopCLI.main(new String[] {"sign", "--as", "binary", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void key_nonExistentKeyFileCausesExit1() { + SopCLI.main(new String[] {"sign", "invalid.asc"}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void key_keyIsProtectedCausesExit1() throws SOPGPException.KeyIsProtected, IOException, SOPGPException.BadData { + when(sign.key(any())).thenThrow(new SOPGPException.KeyIsProtected()); + SopCLI.main(new String[] {"sign", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void key_badDataCausesExit41() throws SOPGPException.KeyIsProtected, IOException, SOPGPException.BadData { + when(sign.key(any())).thenThrow(new SOPGPException.BadData(new IOException())); + SopCLI.main(new String[] {"sign", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(19) + public void key_missingKeyFileCausesExit19() { + SopCLI.main(new String[] {"sign"}); + } + + @Test + public void noArmor_notCalledByDefault() { + SopCLI.main(new String[] {"sign", keyFile.getAbsolutePath()}); + verify(sign, never()).noArmor(); + } + + @Test + public void noArmor_passedDown() { + SopCLI.main(new String[] {"sign", "--no-armor", keyFile.getAbsolutePath()}); + verify(sign, times(1)).noArmor(); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void data_ioExceptionCausesExit1() throws IOException, SOPGPException.ExpectedText { + when(sign.data(any())).thenReturn(new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + throw new IOException(); + } + }); + SopCLI.main(new String[] {"sign", keyFile.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(53) + public void data_expectedTextExceptionCausesExit53() throws IOException, SOPGPException.ExpectedText { + when(sign.data(any())).thenThrow(new SOPGPException.ExpectedText()); + SopCLI.main(new String[] {"sign", keyFile.getAbsolutePath()}); + } +} diff --git a/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VerifyCmdTest.java b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VerifyCmdTest.java new file mode 100644 index 00000000..98d742e2 --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VerifyCmdTest.java @@ -0,0 +1,214 @@ +/* + * Copyright 2020 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import sop.SOP; +import sop.Verification; +import sop.cli.picocli.DateParser; +import sop.cli.picocli.SopCLI; +import sop.exception.SOPGPException; +import sop.operation.Verify; +import sop.util.UTCUtil; + +public class VerifyCmdTest { + + Verify verify; + File signature; + File cert; + + PrintStream originalSout; + + @BeforeEach + public void prepare() throws SOPGPException.UnsupportedOption, SOPGPException.BadData, SOPGPException.NoSignature, IOException { + originalSout = System.out; + + verify = mock(Verify.class); + when(verify.notBefore(any())).thenReturn(verify); + when(verify.notAfter(any())).thenReturn(verify); + when(verify.cert(any())).thenReturn(verify); + when(verify.signatures(any())).thenReturn(verify); + when(verify.data(any())).thenReturn( + Collections.singletonList( + new Verification( + UTCUtil.parseUTCDate("2019-10-29T18:36:45Z"), + "EB85BB5FA33A75E15E944E63F231550C4F47E38E", + "EB85BB5FA33A75E15E944E63F231550C4F47E38E") + ) + ); + + SOP sop = mock(SOP.class); + when(sop.verify()).thenReturn(verify); + + SopCLI.setSopInstance(sop); + + signature = File.createTempFile("signature-", ".asc"); + cert = File.createTempFile("cert-", ".asc"); + } + + @AfterEach + public void restoreSout() { + System.setOut(originalSout); + } + + @Test + public void notAfter_passedDown() throws SOPGPException.UnsupportedOption { + Date date = UTCUtil.parseUTCDate("2019-10-29T18:36:45Z"); + SopCLI.main(new String[] {"verify", "--not-after", "2019-10-29T18:36:45Z", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notAfter(date); + } + + @Test + public void notAfter_now() throws SOPGPException.UnsupportedOption { + Date now = new Date(); + SopCLI.main(new String[] {"verify", "--not-after", "now", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notAfter(dateMatcher(now)); + } + + @Test + public void notAfter_dashCountsAsEndOfTime() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"verify", "--not-after", "-", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notAfter(DateParser.END_OF_TIME); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void notAfter_unsupportedOptionCausesExit37() throws SOPGPException.UnsupportedOption { + when(verify.notAfter(any())).thenThrow(new SOPGPException.UnsupportedOption()); + SopCLI.main(new String[] {"verify", "--not-after", "2019-10-29T18:36:45Z", signature.getAbsolutePath(), cert.getAbsolutePath()}); + } + + @Test + public void notBefore_passedDown() throws SOPGPException.UnsupportedOption { + Date date = UTCUtil.parseUTCDate("2019-10-29T18:36:45Z"); + SopCLI.main(new String[] {"verify", "--not-before", "2019-10-29T18:36:45Z", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notBefore(date); + } + + @Test + public void notBefore_now() throws SOPGPException.UnsupportedOption { + Date now = new Date(); + SopCLI.main(new String[] {"verify", "--not-before", "now", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notBefore(dateMatcher(now)); + } + + @Test + public void notBefore_dashCountsAsBeginningOfTime() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"verify", "--not-before", "-", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notBefore(DateParser.BEGINNING_OF_TIME); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void notBefore_unsupportedOptionCausesExit37() throws SOPGPException.UnsupportedOption { + when(verify.notBefore(any())).thenThrow(new SOPGPException.UnsupportedOption()); + SopCLI.main(new String[] {"verify", "--not-before", "2019-10-29T18:36:45Z", signature.getAbsolutePath(), cert.getAbsolutePath()}); + } + + @Test + public void notBeforeAndNotAfterAreCalledWithDefaultValues() throws SOPGPException.UnsupportedOption { + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), cert.getAbsolutePath()}); + verify(verify, times(1)).notAfter(dateMatcher(new Date())); + verify(verify, times(1)).notBefore(DateParser.BEGINNING_OF_TIME); + } + + private static Date dateMatcher(Date date) { + return ArgumentMatchers.argThat(argument -> Math.abs(argument.getTime() - date.getTime()) < 1000); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void cert_fileNotFoundCausesExit1() { + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), "invalid.asc"}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void cert_badDataCausesExit41() throws SOPGPException.BadData { + when(verify.cert(any())).thenThrow(new SOPGPException.BadData(new IOException())); + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), cert.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(1) + public void signature_fileNotFoundCausesExit1() { + SopCLI.main(new String[] {"verify", "invalid.sig", cert.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void signature_badDataCausesExit41() throws SOPGPException.BadData { + when(verify.signatures(any())).thenThrow(new SOPGPException.BadData(new IOException())); + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), cert.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(3) + public void data_noSignaturesCausesExit3() throws SOPGPException.NoSignature, IOException, SOPGPException.BadData { + when(verify.data(any())).thenThrow(new SOPGPException.NoSignature()); + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), cert.getAbsolutePath()}); + } + + @Test + @ExpectSystemExitWithStatus(41) + public void data_badDataCausesExit41() throws SOPGPException.NoSignature, IOException, SOPGPException.BadData { + when(verify.data(any())).thenThrow(new SOPGPException.BadData(new IOException())); + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), cert.getAbsolutePath()}); + } + + @Test + public void resultIsPrintedProperly() throws SOPGPException.NoSignature, IOException, SOPGPException.BadData { + when(verify.data(any())).thenReturn(Arrays.asList( + new Verification(UTCUtil.parseUTCDate("2019-10-29T18:36:45Z"), + "EB85BB5FA33A75E15E944E63F231550C4F47E38E", + "EB85BB5FA33A75E15E944E63F231550C4F47E38E"), + new Verification(UTCUtil.parseUTCDate("2019-10-24T23:48:29Z"), + "C90E6D36200A1B922A1509E77618196529AE5FF8", + "C4BC2DDB38CCE96485EBE9C2F20691179038E5C6") + )); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + SopCLI.main(new String[] {"verify", signature.getAbsolutePath(), cert.getAbsolutePath()}); + + System.setOut(originalSout); + + String expected = "2019-10-29T18:36:45Z EB85BB5FA33A75E15E944E63F231550C4F47E38E EB85BB5FA33A75E15E944E63F231550C4F47E38E\n" + + "2019-10-24T23:48:29Z C90E6D36200A1B922A1509E77618196529AE5FF8 C4BC2DDB38CCE96485EBE9C2F20691179038E5C6\n"; + + assertEquals(expected, out.toString()); + } +} 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 new file mode 100644 index 00000000..1aa1440e --- /dev/null +++ b/sop-java-picocli/src/test/java/sop/cli/picocli/commands/VersionCmdTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.cli.picocli.commands; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.ginsberg.junit.exit.ExpectSystemExitWithStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sop.SOP; +import sop.cli.picocli.SopCLI; +import sop.operation.Version; + +public class VersionCmdTest { + + private SOP sop; + private Version version; + + @BeforeEach + public void mockComponents() { + sop = mock(SOP.class); + version = mock(Version.class); + when(version.getName()).thenReturn("MockSop"); + when(version.getVersion()).thenReturn("1.0"); + when(sop.version()).thenReturn(version); + + SopCLI.setSopInstance(sop); + } + + @Test + public void assertVersionCommandWorks() { + SopCLI.main(new String[] {"version"}); + verify(version, times(1)).getVersion(); + verify(version, times(1)).getName(); + } + + @Test + @ExpectSystemExitWithStatus(37) + public void assertInvalidOptionResultsInExit37() { + SopCLI.main(new String[] {"version", "--invalid"}); + } +} diff --git a/sop-java/build.gradle b/sop-java/build.gradle new file mode 100644 index 00000000..3da940c6 --- /dev/null +++ b/sop-java/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'java' +} + +group 'org.pgpainless' + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/sop-java/src/main/java/sop/ByteArrayAndResult.java b/sop-java/src/main/java/sop/ByteArrayAndResult.java new file mode 100644 index 00000000..b462926e --- /dev/null +++ b/sop-java/src/main/java/sop/ByteArrayAndResult.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +public class ByteArrayAndResult { + + private final byte[] bytes; + private final T result; + + public ByteArrayAndResult(byte[] bytes, T result) { + this.bytes = bytes; + this.result = result; + } + + public byte[] getBytes() { + return bytes; + } + + public T getResult() { + return result; + } +} diff --git a/sop-java/src/main/java/sop/DecryptionResult.java b/sop-java/src/main/java/sop/DecryptionResult.java new file mode 100644 index 00000000..3fa32d9c --- /dev/null +++ b/sop-java/src/main/java/sop/DecryptionResult.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import sop.util.Optional; + +public class DecryptionResult { + + private final Optional sessionKey; + private final List verifications; + + public DecryptionResult(SessionKey sessionKey, List verifications) { + this.sessionKey = Optional.ofNullable(sessionKey); + this.verifications = Collections.unmodifiableList(verifications); + } + + public Optional getSessionKey() { + return sessionKey; + } + + public List getVerifications() { + return new ArrayList<>(verifications); + } +} diff --git a/sop-java/src/main/java/sop/Ready.java b/sop-java/src/main/java/sop/Ready.java new file mode 100644 index 00000000..4eea4bb6 --- /dev/null +++ b/sop-java/src/main/java/sop/Ready.java @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +public abstract class Ready { + + public abstract void writeTo(OutputStream outputStream) throws IOException; + + public byte[] getBytes() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + writeTo(bytes); + return bytes.toByteArray(); + } +} diff --git a/sop-java/src/main/java/sop/ReadyWithResult.java b/sop-java/src/main/java/sop/ReadyWithResult.java new file mode 100644 index 00000000..12744b32 --- /dev/null +++ b/sop-java/src/main/java/sop/ReadyWithResult.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import sop.exception.SOPGPException; + +public abstract class ReadyWithResult { + + public abstract T writeTo(OutputStream outputStream) throws IOException, SOPGPException.NoSignature; + + public ByteArrayAndResult toBytes() throws IOException, SOPGPException.NoSignature { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + T result = writeTo(bytes); + return new ByteArrayAndResult<>(bytes.toByteArray(), result); + } +} diff --git a/sop-java/src/main/java/sop/SOP.java b/sop-java/src/main/java/sop/SOP.java new file mode 100644 index 00000000..011129f3 --- /dev/null +++ b/sop-java/src/main/java/sop/SOP.java @@ -0,0 +1,103 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +import sop.operation.Armor; +import sop.operation.Dearmor; +import sop.operation.Decrypt; +import sop.operation.Encrypt; +import sop.operation.ExtractCert; +import sop.operation.GenerateKey; +import sop.operation.Sign; +import sop.operation.Verify; +import sop.operation.Version; + +/** + * Stateless OpenPGP Interface. + */ +public interface SOP { + + /** + * Get information about the implementations name and version. + * + * @return version + */ + Version version(); + + /** + * Generate a secret key. + * Customize the operation using the builder {@link GenerateKey}. + * + * @return builder instance + */ + GenerateKey generateKey(); + + /** + * Extract a certificate (public key) from a secret key. + * Customize the operation using the builder {@link ExtractCert}. + * + * @return builder instance + */ + ExtractCert extractCert(); + + /** + * Create detached signatures. + * Customize the operation using the builder {@link Sign}. + * + * @return builder instance + */ + Sign sign(); + + /** + * Verify detached signatures. + * Customize the operation using the builder {@link Verify}. + * + * @return builder instance + */ + Verify verify(); + + /** + * Encrypt a message. + * Customize the operation using the builder {@link Encrypt}. + * + * @return builder instance + */ + Encrypt encrypt(); + + /** + * Decrypt a message. + * Customize the operation using the builder {@link Decrypt}. + * + * @return builder instance + */ + Decrypt decrypt(); + + /** + * Convert binary OpenPGP data to ASCII. + * Customize the operation using the builder {@link Armor}. + * + * @return builder instance + */ + Armor armor(); + + /** + * Converts ASCII armored OpenPGP data to binary. + * Customize the operation using the builder {@link Dearmor}. + * + * @return builder instance + */ + Dearmor dearmor(); +} diff --git a/sop-java/src/main/java/sop/SessionKey.java b/sop-java/src/main/java/sop/SessionKey.java new file mode 100644 index 00000000..86e0edf5 --- /dev/null +++ b/sop-java/src/main/java/sop/SessionKey.java @@ -0,0 +1,75 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +import java.util.Arrays; + +import sop.util.HexUtil; + +public class SessionKey { + + private final byte algorithm; + private final byte[] sessionKey; + + public SessionKey(byte algorithm, byte[] sessionKey) { + this.algorithm = algorithm; + this.sessionKey = sessionKey; + } + + /** + * Return the symmetric algorithm octet. + * + * @return algorithm id + */ + public byte getAlgorithm() { + return algorithm; + } + + /** + * Return the session key. + * + * @return session key + */ + public byte[] getKey() { + return sessionKey; + } + + @Override + public int hashCode() { + return getAlgorithm() * 17 + Arrays.hashCode(getKey()); + } + + @Override + public boolean equals(Object other) { + if (other == null) { + return false; + } + if (this == other) { + return true; + } + if (!(other instanceof SessionKey)) { + return false; + } + + SessionKey otherKey = (SessionKey) other; + return getAlgorithm() == otherKey.getAlgorithm() && Arrays.equals(getKey(), otherKey.getKey()); + } + + @Override + public String toString() { + return "" + (int) getAlgorithm() + ':' + HexUtil.bytesToHex(sessionKey); + } +} diff --git a/sop-java/src/main/java/sop/Verification.java b/sop-java/src/main/java/sop/Verification.java new file mode 100644 index 00000000..5dd60cd9 --- /dev/null +++ b/sop-java/src/main/java/sop/Verification.java @@ -0,0 +1,69 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop; + +import java.util.Date; + +import sop.util.UTCUtil; + +public class Verification { + + private final Date creationTime; + private final String signingKeyFingerprint; + private final String signingCertFingerprint; + + public Verification(Date creationTime, String signingKeyFingerprint, String signingCertFingerprint) { + this.creationTime = creationTime; + this.signingKeyFingerprint = signingKeyFingerprint; + this.signingCertFingerprint = signingCertFingerprint; + } + + /** + * Return the signatures creation time. + * + * @return signature creation time + */ + public Date getCreationTime() { + return creationTime; + } + + /** + * Return the fingerprint of the signing (sub)key. + * + * @return signing key fingerprint + */ + public String getSigningKeyFingerprint() { + return signingKeyFingerprint; + } + + /** + * Return the fingerprint fo the signing certificate. + * + * @return signing certificate fingerprint + */ + public String getSigningCertFingerprint() { + return signingCertFingerprint; + } + + @Override + public String toString() { + return UTCUtil.formatUTCDate(getCreationTime()) + + ' ' + + getSigningKeyFingerprint() + + ' ' + + getSigningCertFingerprint(); + } +} diff --git a/sop-java/src/main/java/sop/enums/ArmorLabel.java b/sop-java/src/main/java/sop/enums/ArmorLabel.java new file mode 100644 index 00000000..f5c1eea2 --- /dev/null +++ b/sop-java/src/main/java/sop/enums/ArmorLabel.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.enums; + +public enum ArmorLabel { + Auto, + Sig, + Key, + Cert, + Message +} diff --git a/sop-java/src/main/java/sop/enums/EncryptAs.java b/sop-java/src/main/java/sop/enums/EncryptAs.java new file mode 100644 index 00000000..590333da --- /dev/null +++ b/sop-java/src/main/java/sop/enums/EncryptAs.java @@ -0,0 +1,22 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.enums; + +public enum EncryptAs { + Binary, + Text, + MIME +} diff --git a/sop-java/src/main/java/sop/enums/SignAs.java b/sop-java/src/main/java/sop/enums/SignAs.java new file mode 100644 index 00000000..7c4e0249 --- /dev/null +++ b/sop-java/src/main/java/sop/enums/SignAs.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.enums; + +public enum SignAs { + Binary, + Text +} diff --git a/sop-java/src/main/java/sop/enums/package-info.java b/sop-java/src/main/java/sop/enums/package-info.java new file mode 100644 index 00000000..1406a899 --- /dev/null +++ b/sop-java/src/main/java/sop/enums/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Stateless OpenPGP Interface for Java. + * Enumerations. + */ +package sop.enums; diff --git a/sop-java/src/main/java/sop/exception/SOPGPException.java b/sop-java/src/main/java/sop/exception/SOPGPException.java new file mode 100644 index 00000000..c86d8d39 --- /dev/null +++ b/sop-java/src/main/java/sop/exception/SOPGPException.java @@ -0,0 +1,157 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.exception; + +public class SOPGPException extends Exception { + + public SOPGPException() { + super(); + } + + public SOPGPException(String message) { + super(message); + } + + public SOPGPException(Throwable e) { + super(e); + } + + public static class NoSignature extends SOPGPException { + + public static final int EXIT_CODE = 3; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class UnsupportedAsymmetricAlgo extends SOPGPException { + + public static final int EXIT_CODE = 13; + + public UnsupportedAsymmetricAlgo(Throwable e) { + super(e); + } + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class CertCannotEncrypt extends SOPGPException { + public static final int EXIT_CODE = 17; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class CertCannotSign extends SOPGPException { + + } + + public static class MissingArg extends SOPGPException { + + public static final int EXIT_CODE = 19; + + public MissingArg(String s) { + super(s); + } + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class IncompleteVerification extends SOPGPException { + + public static final int EXIT_CODE = 23; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class CannotDecrypt extends SOPGPException { + + public static final int EXIT_CODE = 29; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class PasswordNotHumanReadable extends SOPGPException { + + public static final int EXIT_CODE = 31; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class UnsupportedOption extends SOPGPException { + + public static final int EXIT_CODE = 37; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class BadData extends SOPGPException { + + public static final int EXIT_CODE = 41; + + public BadData(Throwable e) { + super(e); + } + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class ExpectedText extends SOPGPException { + + public static final int EXIT_CODE = 53; + + public int getExitCode() { + return EXIT_CODE; + } + } + + public static class OutputExists extends SOPGPException { + + } + + public static class KeyIsProtected extends SOPGPException { + + } + + public static class AmbiguousInput extends SOPGPException { + + } + + public static class NotImplemented extends SOPGPException { + + public static final int EXIT_CODE = 69; + + public int getExitCode() { + return EXIT_CODE; + } + } +} diff --git a/sop-java/src/main/java/sop/exception/package-info.java b/sop-java/src/main/java/sop/exception/package-info.java new file mode 100644 index 00000000..0c65aecd --- /dev/null +++ b/sop-java/src/main/java/sop/exception/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Stateless OpenPGP Interface for Java. + * Exception classes. + */ +package sop.exception; diff --git a/sop-java/src/main/java/sop/operation/Armor.java b/sop-java/src/main/java/sop/operation/Armor.java new file mode 100644 index 00000000..59db198a --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Armor.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.InputStream; + +import sop.Ready; +import sop.enums.ArmorLabel; +import sop.exception.SOPGPException; + +public interface Armor { + + /** + * Overrides automatic detection of label. + * + * @param label armor label + * @return builder instance + */ + Armor label(ArmorLabel label) throws SOPGPException.UnsupportedOption; + + /** + * Allow nested Armoring. + * + * @return builder instance + */ + Armor allowNested() throws SOPGPException.UnsupportedOption; + + /** + * Armor the provided data. + * + * @param data input stream of unarmored OpenPGP data + * @return armored data + */ + Ready data(InputStream data) throws SOPGPException.BadData; +} diff --git a/sop-java/src/main/java/sop/operation/Dearmor.java b/sop-java/src/main/java/sop/operation/Dearmor.java new file mode 100644 index 00000000..43a55bc4 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Dearmor.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; + +import sop.Ready; +import sop.exception.SOPGPException; + +public interface Dearmor { + + /** + * Dearmor armored OpenPGP data. + * + * @param data armored OpenPGP data + * @return input stream of unarmored data + */ + Ready data(InputStream data) throws SOPGPException.BadData, IOException; +} diff --git a/sop-java/src/main/java/sop/operation/Decrypt.java b/sop-java/src/main/java/sop/operation/Decrypt.java new file mode 100644 index 00000000..1ed1e700 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Decrypt.java @@ -0,0 +1,94 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; + +import sop.DecryptionResult; +import sop.ReadyWithResult; +import sop.SessionKey; +import sop.exception.SOPGPException; + +public interface Decrypt { + + /** + * Makes the SOP consider signatures before this date invalid. + * + * @param timestamp timestamp + * @return builder instance + */ + Decrypt verifyNotBefore(Date timestamp) + throws SOPGPException.UnsupportedOption; + + /** + * Makes the SOP consider signatures after this date invalid. + * + * @param timestamp timestamp + * @return builder instance + */ + Decrypt verifyNotAfter(Date timestamp) + throws SOPGPException.UnsupportedOption; + + /** + * Adds the verification cert. + * + * @param cert input stream containing the cert + * @return builder instance + */ + Decrypt verifyWithCert(InputStream cert) + throws SOPGPException.BadData, + IOException; + + /** + * Tries to decrypt with the given session key. + * + * @param sessionKey session key + * @return builder instance + */ + Decrypt withSessionKey(SessionKey sessionKey) + throws SOPGPException.UnsupportedOption; + + /** + * Tries to decrypt with the given password. + * + * @param password password + * @return builder instance + */ + Decrypt withPassword(String password) + throws SOPGPException.PasswordNotHumanReadable, + SOPGPException.UnsupportedOption; + + /** + * Adds the decryption key. + * + * @param key input stream containing the key + * @return builder instance + */ + Decrypt withKey(InputStream key) + throws SOPGPException.KeyIsProtected, + SOPGPException.BadData, + SOPGPException.UnsupportedAsymmetricAlgo; + + /** + * Decrypts the given ciphertext, returning verification results and plaintext. + * @param ciphertext ciphertext + * @return ready with result + */ + ReadyWithResult ciphertext(InputStream ciphertext) + throws SOPGPException.BadData, SOPGPException.MissingArg, SOPGPException.CannotDecrypt; +} diff --git a/sop-java/src/main/java/sop/operation/Encrypt.java b/sop-java/src/main/java/sop/operation/Encrypt.java new file mode 100644 index 00000000..2722ed71 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Encrypt.java @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; + +import sop.Ready; +import sop.enums.EncryptAs; +import sop.exception.SOPGPException; + +public interface Encrypt { + + /** + * Disable ASCII armor encoding. + * + * @return builder instance + */ + Encrypt noArmor(); + + /** + * Sets encryption mode. + * + * @param mode mode + * @return builder instance + */ + Encrypt mode(EncryptAs mode) + throws SOPGPException.UnsupportedOption; + + /** + * Adds the signer key. + * + * @param key input stream containing the encoded signer key + * @return builder instance + */ + Encrypt signWith(InputStream key) + throws SOPGPException.KeyIsProtected, + SOPGPException.CertCannotSign, + SOPGPException.UnsupportedAsymmetricAlgo, + SOPGPException.BadData; + + /** + * Encrypt with the given password. + * + * @param password password + * @return builder instance + */ + Encrypt withPassword(String password) + throws SOPGPException.PasswordNotHumanReadable, + SOPGPException.UnsupportedOption; + + /** + * Encrypt with the given cert. + * + * @param cert input stream containing the encoded cert. + * @return builder instance + */ + Encrypt withCert(InputStream cert) + throws SOPGPException.CertCannotEncrypt, + SOPGPException.UnsupportedAsymmetricAlgo, + SOPGPException.BadData; + + /** + * Encrypt the given data yielding the ciphertext. + * @param plaintext plaintext + * @return input stream containing the ciphertext + */ + Ready plaintext(InputStream plaintext) + throws IOException; +} diff --git a/sop-java/src/main/java/sop/operation/ExtractCert.java b/sop-java/src/main/java/sop/operation/ExtractCert.java new file mode 100644 index 00000000..4c427701 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/ExtractCert.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; + +import sop.Ready; +import sop.exception.SOPGPException; + +public interface ExtractCert { + + /** + * Disable ASCII armor encoding. + * + * @return builder instance + */ + ExtractCert noArmor(); + + /** + * Extract the cert from the provided key. + * + * @param keyInputStream input stream containing the encoding of an OpenPGP key + * @return input stream containing the encoding of the keys cert + */ + Ready key(InputStream keyInputStream) throws IOException, SOPGPException.BadData; +} diff --git a/sop-java/src/main/java/sop/operation/GenerateKey.java b/sop-java/src/main/java/sop/operation/GenerateKey.java new file mode 100644 index 00000000..7447d924 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/GenerateKey.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; + +import sop.Ready; +import sop.exception.SOPGPException; + +public interface GenerateKey { + + /** + * Disable ASCII armor encoding. + * + * @return builder instance + */ + GenerateKey noArmor(); + + /** + * Adds a user-id. + * + * @param userId user-id + * @return builder instance + */ + GenerateKey userId(String userId); + + /** + * Generate the OpenPGP key and return it encoded as an {@link InputStream}. + * + * @return key + */ + Ready generate() throws SOPGPException.MissingArg, SOPGPException.UnsupportedAsymmetricAlgo, IOException; +} diff --git a/sop-java/src/main/java/sop/operation/Sign.java b/sop-java/src/main/java/sop/operation/Sign.java new file mode 100644 index 00000000..1a3f7418 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Sign.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; + +import sop.Ready; +import sop.enums.SignAs; +import sop.exception.SOPGPException; + +public interface Sign { + + /** + * Disable ASCII armor encoding. + * + * @return builder instance + */ + Sign noArmor(); + + /** + * Sets the signature mode. + * + * @param mode signature mode + * @return builder instance + */ + Sign mode(SignAs mode) throws SOPGPException.UnsupportedOption; + + /** + * Adds the signer key. + * + * @param key input stream containing encoded key + * @return builder instance + */ + Sign key(InputStream key) throws SOPGPException.KeyIsProtected, SOPGPException.BadData, IOException; + + /** + * Signs data. + * + * @param data input stream containing data + * @return ready + */ + Ready data(InputStream data) throws IOException, SOPGPException.ExpectedText; +} diff --git a/sop-java/src/main/java/sop/operation/Verify.java b/sop-java/src/main/java/sop/operation/Verify.java new file mode 100644 index 00000000..ae515e1e --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Verify.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.InputStream; +import java.util.Date; + +import sop.exception.SOPGPException; + +public interface Verify extends VerifySignatures { + + /** + * Makes the SOP implementation consider signatures before this date invalid. + * + * @param timestamp timestamp + * @return builder instance + */ + Verify notBefore(Date timestamp) throws SOPGPException.UnsupportedOption; + + /** + * Makes the SOP implementation consider signatures after this date invalid. + * + * @param timestamp timestamp + * @return builder instance + */ + Verify notAfter(Date timestamp) throws SOPGPException.UnsupportedOption; + + /** + * Adds the verification cert. + * + * @param cert input stream containing the encoded cert + * @return builder instance + */ + Verify cert(InputStream cert) throws SOPGPException.BadData; + + /** + * Provides the signatures. + * @param signatures input stream containing encoded, detached signatures. + * + * @return builder instance + */ + VerifySignatures signatures(InputStream signatures) throws SOPGPException.BadData; + +} diff --git a/sop-java/src/main/java/sop/operation/VerifySignatures.java b/sop-java/src/main/java/sop/operation/VerifySignatures.java new file mode 100644 index 00000000..5fc01b31 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/VerifySignatures.java @@ -0,0 +1,28 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import sop.Verification; +import sop.exception.SOPGPException; + +public interface VerifySignatures { + + List data(InputStream data) throws IOException, SOPGPException.NoSignature, SOPGPException.BadData; +} diff --git a/sop-java/src/main/java/sop/operation/Version.java b/sop-java/src/main/java/sop/operation/Version.java new file mode 100644 index 00000000..db89e121 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/Version.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.operation; + +public interface Version { + + /** + * Return the implementations name. + * + * @return implementation name + */ + String getName(); + + /** + * Return the implementations version string. + * + * @return version string + */ + String getVersion(); +} diff --git a/sop-java/src/main/java/sop/operation/package-info.java b/sop-java/src/main/java/sop/operation/package-info.java new file mode 100644 index 00000000..6156f3d1 --- /dev/null +++ b/sop-java/src/main/java/sop/operation/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Stateless OpenPGP Interface for Java. + * Different cryptographic operations. + */ +package sop.operation; diff --git a/sop-java/src/main/java/sop/package-info.java b/sop-java/src/main/java/sop/package-info.java new file mode 100644 index 00000000..8caf9bb9 --- /dev/null +++ b/sop-java/src/main/java/sop/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Stateless OpenPGP Interface for Java. + */ +package sop; diff --git a/sop-java/src/main/java/sop/util/HexUtil.java b/sop-java/src/main/java/sop/util/HexUtil.java new file mode 100644 index 00000000..c1cb3580 --- /dev/null +++ b/sop-java/src/main/java/sop/util/HexUtil.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Paul Schaub, @maybeWeCouldStealAVan, @Dave L. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +public class HexUtil { + + private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + /** + * Encode a byte array to a hex string. + * + * @see + * How to convert a byte array to a hex string in Java? + * @param bytes + * @return + */ + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + /** + * Decode a hex string into a byte array. + * + * @see + * Convert a string representation of a hex dump to a byte array using Java? + * @param s hex string + * @return decoded byte array + */ + public static byte[] hexToBytes(String s) { + int len = s.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + + Character.digit(s.charAt(i + 1), 16)); + } + return data; + } +} diff --git a/sop-java/src/main/java/sop/util/Optional.java b/sop-java/src/main/java/sop/util/Optional.java new file mode 100644 index 00000000..7ee61df5 --- /dev/null +++ b/sop-java/src/main/java/sop/util/Optional.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +/** + * Backport of java.util.Optional for older Android versions. + * + * @param item type + */ +public class Optional { + + private final T item; + + public Optional() { + this(null); + } + + public Optional(T item) { + this.item = item; + } + + public static Optional of(T item) { + if (item == null) { + throw new NullPointerException("Item cannot be null."); + } + return new Optional<>(item); + } + + public static Optional ofNullable(T item) { + return new Optional<>(item); + } + + public static Optional ofEmpty() { + return new Optional<>(null); + } + + public T get() { + return item; + } + + public boolean isPresent() { + return item != null; + } + + public boolean isEmpty() { + return item == null; + } +} diff --git a/sop-java/src/main/java/sop/util/ProxyOutputStream.java b/sop-java/src/main/java/sop/util/ProxyOutputStream.java new file mode 100644 index 00000000..16fd04f6 --- /dev/null +++ b/sop-java/src/main/java/sop/util/ProxyOutputStream.java @@ -0,0 +1,91 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * {@link OutputStream} that buffers data being written into it, until its underlying output stream is being replaced. + * At that point, first all the buffered data is being written to the underlying stream, followed by any successive + * data that may get written to the {@link ProxyOutputStream}. + * + * This class is useful if we need to provide an {@link OutputStream} at one point in time where the final + * target output stream is not yet known. + */ +public class ProxyOutputStream extends OutputStream { + + private final ByteArrayOutputStream buffer; + private OutputStream swapped; + + public ProxyOutputStream() { + this.buffer = new ByteArrayOutputStream(); + } + + public synchronized void replaceOutputStream(OutputStream underlying) throws IOException { + if (underlying == null) { + throw new NullPointerException("Underlying OutputStream cannot be null."); + } + this.swapped = underlying; + + byte[] bufferBytes = buffer.toByteArray(); + swapped.write(bufferBytes); + } + + @Override + public synchronized void write(byte[] b) throws IOException { + if (swapped == null) { + buffer.write(b); + } else { + swapped.write(b); + } + } + + @Override + public synchronized void write(byte[] b, int off, int len) throws IOException { + if (swapped == null) { + buffer.write(b, off, len); + } else { + swapped.write(b, off, len); + } + } + + @Override + public synchronized void flush() throws IOException { + buffer.flush(); + if (swapped != null) { + swapped.flush(); + } + } + + @Override + public synchronized void close() throws IOException { + buffer.close(); + if (swapped != null) { + swapped.close(); + } + } + + @Override + public synchronized void write(int i) throws IOException { + if (swapped == null) { + buffer.write(i); + } else { + swapped.write(i); + } + } +} diff --git a/sop-java/src/main/java/sop/util/UTCUtil.java b/sop-java/src/main/java/sop/util/UTCUtil.java new file mode 100644 index 00000000..c847863e --- /dev/null +++ b/sop-java/src/main/java/sop/util/UTCUtil.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * Utility class to parse and format dates as ISO-8601 UTC timestamps. + */ +public class UTCUtil { + + public static SimpleDateFormat UTC_FORMATTER = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + public static SimpleDateFormat[] UTC_PARSERS = new SimpleDateFormat[] { + UTC_FORMATTER, + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX"), + new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'"), + new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'") + }; + + static { + for (SimpleDateFormat f : UTC_PARSERS) { + f.setTimeZone(TimeZone.getTimeZone("UTC")); + } + } + /** + * Parse an ISO-8601 UTC timestamp from a string. + * + * @param dateString string + * @return date + */ + public static Date parseUTCDate(String dateString) { + for (SimpleDateFormat parser : UTC_PARSERS) { + try { + return parser.parse(dateString); + } catch (ParseException e) { + } + } + return null; + } + + /** + * Format a date as ISO-8601 UTC timestamp. + * + * @param date date + * @return timestamp string + */ + public static String formatUTCDate(Date date) { + return UTC_FORMATTER.format(date); + } +} diff --git a/sop-java/src/main/java/sop/util/package-info.java b/sop-java/src/main/java/sop/util/package-info.java new file mode 100644 index 00000000..11a87c66 --- /dev/null +++ b/sop-java/src/main/java/sop/util/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Utility classes. + */ +package sop.util; diff --git a/sop-java/src/test/java/sop/util/ByteArrayAndResultTest.java b/sop-java/src/test/java/sop/util/ByteArrayAndResultTest.java new file mode 100644 index 00000000..8b7c2d78 --- /dev/null +++ b/sop-java/src/test/java/sop/util/ByteArrayAndResultTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import sop.ByteArrayAndResult; +import sop.Verification; + +public class ByteArrayAndResultTest { + + @Test + public void testCreationAndGetters() { + byte[] bytes = "Hello, World!\n".getBytes(StandardCharsets.UTF_8); + List result = Collections.singletonList( + new Verification(UTCUtil.parseUTCDate("2019-10-24T23:48:29Z"), + "C90E6D36200A1B922A1509E77618196529AE5FF8", + "C4BC2DDB38CCE96485EBE9C2F20691179038E5C6") + ); + ByteArrayAndResult> bytesAndResult = new ByteArrayAndResult<>(bytes, result); + + assertArrayEquals(bytes, bytesAndResult.getBytes()); + assertEquals(result, bytesAndResult.getResult()); + } +} diff --git a/sop-java/src/test/java/sop/util/HexUtilTest.java b/sop-java/src/test/java/sop/util/HexUtilTest.java new file mode 100644 index 00000000..6d229aad --- /dev/null +++ b/sop-java/src/test/java/sop/util/HexUtilTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.Charset; + +import org.junit.jupiter.api.Test; + +/** + * Test using some test vectors from RFC4648. + * + * @see RFC-4648 §10: Test Vectors + */ +public class HexUtilTest { + + private static final Charset ASCII = Charset.forName("US-ASCII"); + + @Test + public void emptyHexEncodeTest() { + assertHexEquals("", ""); + } + + @Test + public void encodeF() { + assertHexEquals("66", "f"); + } + + @Test + public void encodeFo() { + assertHexEquals("666F", "fo"); + } + + @Test + public void encodeFoo() { + assertHexEquals("666F6F", "foo"); + } + + @Test + public void encodeFoob() { + assertHexEquals("666F6F62", "foob"); + } + + @Test + public void encodeFooba() { + assertHexEquals("666F6F6261", "fooba"); + } + + @Test + public void encodeFoobar() { + assertHexEquals("666F6F626172", "foobar"); + } + + private void assertHexEquals(String hex, String ascii) { + assertEquals(hex, HexUtil.bytesToHex(ascii.getBytes(ASCII))); + assertArrayEquals(ascii.getBytes(ASCII), HexUtil.hexToBytes(hex)); + } +} diff --git a/sop-java/src/test/java/sop/util/OptionalTest.java b/sop-java/src/test/java/sop/util/OptionalTest.java new file mode 100644 index 00000000..edcb7aa5 --- /dev/null +++ b/sop-java/src/test/java/sop/util/OptionalTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +public class OptionalTest { + + @Test + public void testEmpty() { + Optional optional = new Optional<>(); + assertEmpty(optional); + } + + @Test + public void testArg() { + String string = "foo"; + Optional optional = new Optional<>(string); + assertFalse(optional.isEmpty()); + assertTrue(optional.isPresent()); + assertEquals(string, optional.get()); + } + + @Test + public void testOfEmpty() { + Optional optional = Optional.ofEmpty(); + assertEmpty(optional); + } + + @Test + public void testNullArg() { + Optional optional = new Optional<>(null); + assertEmpty(optional); + } + + @Test + public void testOfWithNullArgThrows() { + assertThrows(NullPointerException.class, () -> Optional.of(null)); + } + + @Test + public void testOf() { + String string = "Hello, World!"; + Optional optional = Optional.of(string); + assertFalse(optional.isEmpty()); + assertTrue(optional.isPresent()); + assertEquals(string, optional.get()); + } + + @Test + public void testOfNullableWithNull() { + Optional optional = Optional.ofNullable(null); + assertEmpty(optional); + } + + @Test + public void testOfNullableWithArg() { + Optional optional = Optional.ofNullable("bar"); + assertEquals("bar", optional.get()); + assertFalse(optional.isEmpty()); + assertTrue(optional.isPresent()); + } + + private void assertEmpty(Optional optional) { + assertTrue(optional.isEmpty()); + assertFalse(optional.isPresent()); + + assertNull(optional.get()); + } +} diff --git a/sop-java/src/test/java/sop/util/ProxyOutputStreamTest.java b/sop-java/src/test/java/sop/util/ProxyOutputStreamTest.java new file mode 100644 index 00000000..b9c203b3 --- /dev/null +++ b/sop-java/src/test/java/sop/util/ProxyOutputStreamTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +public class ProxyOutputStreamTest { + + @Test + public void replaceOutputStreamThrowsNPEForNull() { + ProxyOutputStream proxy = new ProxyOutputStream(); + assertThrows(NullPointerException.class, () -> proxy.replaceOutputStream(null)); + } + + @Test + public void testSwappingStreamPreservesWrittenBytes() throws IOException { + byte[] firstSection = "Foo\nBar\n".getBytes(StandardCharsets.UTF_8); + byte[] secondSection = "Baz\n".getBytes(StandardCharsets.UTF_8); + + ProxyOutputStream proxy = new ProxyOutputStream(); + proxy.write(firstSection); + + ByteArrayOutputStream swappedStream = new ByteArrayOutputStream(); + proxy.replaceOutputStream(swappedStream); + + proxy.write(secondSection); + proxy.close(); + + assertEquals("Foo\nBar\nBaz\n", swappedStream.toString()); + } +} diff --git a/sop-java/src/test/java/sop/util/ReadyTest.java b/sop-java/src/test/java/sop/util/ReadyTest.java new file mode 100644 index 00000000..64a08a2c --- /dev/null +++ b/sop-java/src/test/java/sop/util/ReadyTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import sop.Ready; + +public class ReadyTest { + + @Test + public void readyTest() throws IOException { + byte[] data = "Hello, World!\n".getBytes(StandardCharsets.UTF_8); + Ready ready = new Ready() { + @Override + public void writeTo(OutputStream outputStream) throws IOException { + outputStream.write(data); + } + }; + + assertArrayEquals(data, ready.getBytes()); + } +} diff --git a/sop-java/src/test/java/sop/util/ReadyWithResultTest.java b/sop-java/src/test/java/sop/util/ReadyWithResultTest.java new file mode 100644 index 00000000..51d1eeb7 --- /dev/null +++ b/sop-java/src/test/java/sop/util/ReadyWithResultTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; +import sop.ByteArrayAndResult; +import sop.ReadyWithResult; +import sop.Verification; +import sop.exception.SOPGPException; + +public class ReadyWithResultTest { + + @Test + public void testReadyWithResult() throws SOPGPException.NoSignature, IOException { + byte[] data = "Hello, World!\n".getBytes(StandardCharsets.UTF_8); + List result = Collections.singletonList( + new Verification(UTCUtil.parseUTCDate("2019-10-24T23:48:29Z"), + "C90E6D36200A1B922A1509E77618196529AE5FF8", + "C4BC2DDB38CCE96485EBE9C2F20691179038E5C6") + ); + ReadyWithResult> readyWithResult = new ReadyWithResult>() { + @Override + public List writeTo(OutputStream outputStream) throws IOException, SOPGPException.NoSignature { + outputStream.write(data); + return result; + } + }; + + ByteArrayAndResult> bytesAndResult = readyWithResult.toBytes(); + assertArrayEquals(data, bytesAndResult.getBytes()); + assertEquals(result, bytesAndResult.getResult()); + } +} diff --git a/sop-java/src/test/java/sop/util/SessionKeyTest.java b/sop-java/src/test/java/sop/util/SessionKeyTest.java new file mode 100644 index 00000000..712e6342 --- /dev/null +++ b/sop-java/src/test/java/sop/util/SessionKeyTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import org.junit.jupiter.api.Test; +import sop.SessionKey; + +public class SessionKeyTest { + + @Test + public void toStringTest() { + SessionKey sessionKey = new SessionKey((byte) 9, HexUtil.hexToBytes("FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD")); + assertEquals("9:FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD", sessionKey.toString()); + } + + @Test + public void equalsTest() { + SessionKey s1 = new SessionKey((byte) 9, HexUtil.hexToBytes("FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD")); + SessionKey s2 = new SessionKey((byte) 9, HexUtil.hexToBytes("FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD")); + SessionKey s3 = new SessionKey((byte) 4, HexUtil.hexToBytes("FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD")); + SessionKey s4 = new SessionKey((byte) 9, HexUtil.hexToBytes("19125CD57392BAB7037C7078359FCA4BEAF687F4025CBF9F7BCD8059CACC14FB")); + SessionKey s5 = new SessionKey((byte) 4, HexUtil.hexToBytes("19125CD57392BAB7037C7078359FCA4BEAF687F4025CBF9F7BCD8059CACC14FB")); + + assertEquals(s1, s1); + assertEquals(s1, s2); + assertEquals(s1.hashCode(), s2.hashCode()); + assertNotEquals(s1, s3); + assertNotEquals(s1.hashCode(), s3.hashCode()); + assertNotEquals(s1, s4); + assertNotEquals(s1.hashCode(), s4.hashCode()); + assertNotEquals(s4, s5); + assertNotEquals(s4.hashCode(), s5.hashCode()); + assertNotEquals(s1, null); + assertNotEquals(s1, "FCA4BEAF687F48059CACC14FB019125CD57392BAB7037C707835925CBF9F7BCD"); + } +} diff --git a/sop-java/src/test/java/sop/util/UTCUtilTest.java b/sop-java/src/test/java/sop/util/UTCUtilTest.java new file mode 100644 index 00000000..56c77f9f --- /dev/null +++ b/sop-java/src/test/java/sop/util/UTCUtilTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Paul Schaub. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sop.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Date; + +import org.junit.jupiter.api.Test; + +/** + * Test parsing some date examples from the stateless OpenPGP CLI spec. + * + * @see OpenPGP Stateless CLI §4.1. Date + */ +public class UTCUtilTest { + + @Test + public void parseExample1() { + String timestamp = "2019-10-29T12:11:04+00:00"; + Date date = UTCUtil.parseUTCDate(timestamp); + assertEquals("2019-10-29T12:11:04Z", UTCUtil.formatUTCDate(date)); + } + + @Test + public void parseExample2() { + String timestamp = "2019-10-24T23:48:29Z"; + Date date = UTCUtil.parseUTCDate(timestamp); + assertEquals("2019-10-24T23:48:29Z", UTCUtil.formatUTCDate(date)); + } + + @Test + public void parseExample3() { + String timestamp = "20191029T121104Z"; + Date date = UTCUtil.parseUTCDate(timestamp); + assertEquals("2019-10-29T12:11:04Z", UTCUtil.formatUTCDate(date)); + } + + @Test + public void invalidDateReturnsNull() { + String invalidTimestamp = "foobar"; + Date expectNull = UTCUtil.parseUTCDate(invalidTimestamp); + assertNull(expectNull); + } +}