diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java index f4bc7134..f5be25bd 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java @@ -32,6 +32,7 @@ import org.pgpainless.util.Passphrase; */ public class ConsumerOptions { + private boolean ignoreMDCErrors = false; private Date verifyNotBefore = null; @@ -47,6 +48,7 @@ public class ConsumerOptions { private final Map decryptionKeys = new HashMap<>(); private final Set decryptionPassphrases = new HashSet<>(); + private MissingKeyPassphraseStrategy missingKeyPassphraseStrategy = MissingKeyPassphraseStrategy.INTERACTIVE; /** @@ -289,4 +291,13 @@ public class ConsumerOptions { boolean isIgnoreMDCErrors() { return ignoreMDCErrors; } + + public ConsumerOptions setMissingKeyPassphraseStrategy(MissingKeyPassphraseStrategy strategy) { + this.missingKeyPassphraseStrategy = strategy; + return this; + } + + MissingKeyPassphraseStrategy getMissingKeyPassphraseStrategy() { + return missingKeyPassphraseStrategy; + } } 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 6e9627de..07ddb01a 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 @@ -10,6 +10,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -47,6 +48,7 @@ import org.pgpainless.algorithm.SymmetricKeyAlgorithm; import org.pgpainless.exception.MessageNotIntegrityProtectedException; import org.pgpainless.exception.MissingDecryptionMethodException; import org.pgpainless.exception.MissingLiteralDataException; +import org.pgpainless.exception.MissingPassphraseException; import org.pgpainless.exception.SignatureValidationException; import org.pgpainless.exception.UnacceptableAlgorithmException; import org.pgpainless.exception.WrongConsumingMethodException; @@ -67,6 +69,7 @@ import org.slf4j.LoggerFactory; public final class DecryptionStreamFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(DecryptionStreamFactory.class); private static final int MAX_RECURSION_DEPTH = 16; @@ -382,21 +385,36 @@ public final class DecryptionStreamFactory { // Try postponed keys with missing passphrases (will cause missing passphrase callbacks to fire) if (encryptedSessionKey == null) { - for (Tuple missingPassphrases : postponedDueToMissingPassphrase) { - SubkeyIdentifier keyId = missingPassphrases.getA(); - PGPPublicKeyEncryptedData publicKeyEncryptedData = missingPassphrases.getB(); - PGPSecretKeyRing secretKeys = findDecryptionKeyRing(keyId.getKeyId()); - PGPSecretKey secretKey = secretKeys.getSecretKey(keyId.getSubkeyId()); - PGPPrivateKey privateKey = tryPublicKeyDecryption(secretKeys, secretKey, publicKeyEncryptedData, postponedDueToMissingPassphrase, false); - if (privateKey == null) { - continue; + if (options.getMissingKeyPassphraseStrategy() == MissingKeyPassphraseStrategy.THROW_EXCEPTION) { + // Non-interactive mode: Throw an exception with all locked decryption keys + Set keyIds = new HashSet<>(); + for (Tuple k : postponedDueToMissingPassphrase) { + keyIds.add(k.getA()); } - - decryptionKey = privateKey; - encryptedSessionKey = publicKeyEncryptedData; - break; + throw new MissingPassphraseException(keyIds); } + else if (options.getMissingKeyPassphraseStrategy() == MissingKeyPassphraseStrategy.INTERACTIVE) { + // Interactive mode: Fire protector callbacks to get passphrases interactively + for (Tuple missingPassphrases : postponedDueToMissingPassphrase) { + SubkeyIdentifier keyId = missingPassphrases.getA(); + PGPPublicKeyEncryptedData publicKeyEncryptedData = missingPassphrases.getB(); + PGPSecretKeyRing secretKeys = findDecryptionKeyRing(keyId.getKeyId()); + PGPSecretKey secretKey = secretKeys.getSecretKey(keyId.getSubkeyId()); + + PGPPrivateKey privateKey = tryPublicKeyDecryption(secretKeys, secretKey, publicKeyEncryptedData, postponedDueToMissingPassphrase, false); + if (privateKey == null) { + continue; + } + + decryptionKey = privateKey; + encryptedSessionKey = publicKeyEncryptedData; + break; + } + } else { + throw new IllegalStateException("Invalid PostponedKeysStrategy set in consumer options."); + } + } return decryptWith(encryptedSessionKey, decryptionKey); diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/MissingKeyPassphraseStrategy.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/MissingKeyPassphraseStrategy.java new file mode 100644 index 00000000..166f954b --- /dev/null +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/MissingKeyPassphraseStrategy.java @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.decryption_verification; + +public enum MissingKeyPassphraseStrategy { + INTERACTIVE, // ask for missing key passphrases one by one + THROW_EXCEPTION // throw an exception with all keys with missing passphrases +} diff --git a/pgpainless-core/src/main/java/org/pgpainless/exception/MissingPassphraseException.java b/pgpainless-core/src/main/java/org/pgpainless/exception/MissingPassphraseException.java new file mode 100644 index 00000000..3f8e0799 --- /dev/null +++ b/pgpainless-core/src/main/java/org/pgpainless/exception/MissingPassphraseException.java @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.exception; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; + +import org.bouncycastle.openpgp.PGPException; +import org.pgpainless.key.SubkeyIdentifier; + +public class MissingPassphraseException extends PGPException { + + private final Set keyIds; + + public MissingPassphraseException(Set keyIds) { + super("Missing passphrase encountered for keys " + Arrays.toString(keyIds.toArray())); + this.keyIds = Collections.unmodifiableSet(keyIds); + } + + public Set getKeyIds() { + return keyIds; + } +} diff --git a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MissingPassphraseForDecryptionTest.java b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MissingPassphraseForDecryptionTest.java new file mode 100644 index 00000000..e7b36875 --- /dev/null +++ b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/MissingPassphraseForDecryptionTest.java @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2021 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.decryption_verification; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +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.List; +import javax.annotation.Nullable; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.util.io.Streams; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.EncryptionPurpose; +import org.pgpainless.encryption_signing.EncryptionOptions; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; +import org.pgpainless.exception.MissingPassphraseException; +import org.pgpainless.key.SubkeyIdentifier; +import org.pgpainless.key.info.KeyRingInfo; +import org.pgpainless.key.protection.SecretKeyRingProtector; +import org.pgpainless.key.protection.passphrase_provider.SecretKeyPassphraseProvider; +import org.pgpainless.util.Passphrase; + +public class MissingPassphraseForDecryptionTest { + + private String passphrase = "dragon123"; + private PGPSecretKeyRing secretKeys; + private byte[] message; + + @BeforeEach + public void setup() throws PGPException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IOException { + secretKeys = PGPainless.generateKeyRing().modernKeyRing("Test", passphrase); + PGPPublicKeyRing certificate = PGPainless.extractCertificate(secretKeys); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(out) + .withOptions(ProducerOptions.encrypt(EncryptionOptions.encryptCommunications() + .addRecipient(certificate))); + + Streams.pipeAll(new ByteArrayInputStream("Hey, what's up?".getBytes(StandardCharsets.UTF_8)), encryptionStream); + encryptionStream.close(); + message = out.toByteArray(); + } + + @Test + public void invalidPostponedKeysStrategyTest() { + SecretKeyPassphraseProvider callback = new SecretKeyPassphraseProvider() { + @Nullable + @Override + public Passphrase getPassphraseFor(Long keyId) { + fail("MUST NOT get called in if postponed key strategy is invalid."); + return null; + } + + @Override + public boolean hasPassphrase(Long keyId) { + return true; + } + }; + ConsumerOptions options = new ConsumerOptions() + .setMissingKeyPassphraseStrategy(null) // illegal + .addDecryptionKey(secretKeys, SecretKeyRingProtector.defaultSecretKeyRingProtector(callback)); + + assertThrows(IllegalStateException.class, () -> PGPainless.decryptAndOrVerify() + .onInputStream(new ByteArrayInputStream(message)) + .withOptions(options)); + } + + @Test + public void interactiveStrategy() throws PGPException, IOException { + // interactive callback + SecretKeyPassphraseProvider callback = new SecretKeyPassphraseProvider() { + @Nullable + @Override + public Passphrase getPassphraseFor(Long keyId) { + // is called in interactive mode + return Passphrase.fromPassword(passphrase); + } + + @Override + public boolean hasPassphrase(Long keyId) { + return true; + } + }; + ConsumerOptions options = new ConsumerOptions() + .setMissingKeyPassphraseStrategy(MissingKeyPassphraseStrategy.INTERACTIVE) + .addDecryptionKey(secretKeys, SecretKeyRingProtector.defaultSecretKeyRingProtector(callback)); + + DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(new ByteArrayInputStream(message)) + .withOptions(options); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Streams.pipeAll(decryptionStream, out); + + decryptionStream.close(); + assertArrayEquals("Hey, what's up?".getBytes(StandardCharsets.UTF_8), out.toByteArray()); + } + + @Test + public void throwExceptionStrategy() throws PGPException, IOException { + KeyRingInfo info = PGPainless.inspectKeyRing(secretKeys); + List encryptionKeys = info.getEncryptionSubkeys(EncryptionPurpose.STORAGE_AND_COMMUNICATIONS); + + SecretKeyPassphraseProvider callback = new SecretKeyPassphraseProvider() { + @Nullable + @Override + public Passphrase getPassphraseFor(Long keyId) { + fail("MUST NOT get called in non-interactive mode."); + return null; + } + + @Override + public boolean hasPassphrase(Long keyId) { + return true; + } + }; + + ConsumerOptions options = new ConsumerOptions() + .setMissingKeyPassphraseStrategy(MissingKeyPassphraseStrategy.THROW_EXCEPTION) + .addDecryptionKey(secretKeys, SecretKeyRingProtector.defaultSecretKeyRingProtector(callback)); + + try { + PGPainless.decryptAndOrVerify() + .onInputStream(new ByteArrayInputStream(message)) + .withOptions(options); + fail("Expected exception!"); + } catch (MissingPassphraseException e) { + assertFalse(e.getKeyIds().isEmpty()); + assertEquals(encryptionKeys.size(), e.getKeyIds().size()); + for (PGPPublicKey encryptionKey : encryptionKeys) { + assertTrue(e.getKeyIds().contains(new SubkeyIdentifier(secretKeys, encryptionKey.getKeyID()))); + } + } + } +}