diff --git a/src/main/java/org/c02e/jpgpj/DecryptionResult.java b/src/main/java/org/c02e/jpgpj/DecryptionResult.java new file mode 100644 index 0000000..b765273 --- /dev/null +++ b/src/main/java/org/c02e/jpgpj/DecryptionResult.java @@ -0,0 +1,56 @@ +package org.c02e.jpgpj; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Holds the most detailed information about a decrypted file + */ +public class DecryptionResult { + private final FileMetadata fileMetadata; + private final boolean armored; + private final List armorHeaders; + + public DecryptionResult( + FileMetadata fileMetadata, boolean armored, Collection armorHeaders) { + this.fileMetadata = fileMetadata; + this.armored = armored; + this.armorHeaders = ((armorHeaders == null) || armorHeaders.isEmpty()) + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(armorHeaders)); + } + + /** + * @return The decrypted {@link FileMetadata} - may be {@code null} if none was provided + */ + public FileMetadata getFileMetadata() { + return fileMetadata; + } + + /** + * @return {@code true} if the encrypted data was armored + */ + public boolean isAsciiArmored() { + return armored; + } + + /** + * @return An unmodifiable {@link List} of extracted armored + * headers - is valid only if {@link #isAsciiArmored() armored}. Note: + * might be empty if the encrypted data was armored but contained no headers. + */ + public List getArmorHeaders() { + return armorHeaders; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "[metadata=" + getFileMetadata() + + ", armored=" + isAsciiArmored() + + ", numArmorHeaders=" + getArmorHeaders().size() + + "]"; + } +} diff --git a/src/main/java/org/c02e/jpgpj/Decryptor.java b/src/main/java/org/c02e/jpgpj/Decryptor.java index 2a34bd7..6e99784 100644 --- a/src/main/java/org/c02e/jpgpj/Decryptor.java +++ b/src/main/java/org/c02e/jpgpj/Decryptor.java @@ -10,6 +10,7 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -78,7 +79,7 @@ public class Decryptor { protected String symmetricPassphrase; protected int maxFileBufferSize = 0x100000; //1MB protected Ring ring; - protected Logger log = LoggerFactory.getLogger(Decryptor.class.getName()); + protected final Logger log = LoggerFactory.getLogger(Decryptor.class.getName()); /** Constructs a decryptor with an empty key ring. */ @@ -226,7 +227,7 @@ public void clearSecrets() { * the message was not signed by any of the keys supplied for verification. */ public FileMetadata decrypt(File ciphertext, File plaintext) - throws IOException, PGPException { + throws IOException, PGPException { if (ciphertext.equals(plaintext)) throw new IOException("cannot decrypt " + ciphertext + " over itself"); @@ -284,23 +285,63 @@ public FileMetadata decrypt(File ciphertext, File plaintext) * of the keys supplied for decryption. * @throws VerificationException if {@link #isVerificationRequired} and * the message was not signed by any of the keys supplied for verification. + * @see #decryptWithFullDetails(InputStream, OutputStream) decryptWithFullDetails */ public FileMetadata decrypt(InputStream ciphertext, OutputStream plaintext) - throws IOException, PGPException { - List meta = unpack(parse(unarmor(ciphertext)), plaintext); + throws IOException, PGPException { + DecryptionResult result = decryptWithFullDetails(ciphertext, plaintext); + return result.getFileMetadata(); + } + + /** + * Decrypts the specified PGP message into the specified output stream, + * including the armored headers (if stream was armored and contained + * any such headers). If {@link #isVerificationRequired} also verifies + * the message signatures. Note: does not close or flush the streams. + * + * @param ciphertext PGP message, in binary or ASCII Armor format. + * @param plaintext Decrypted content target {@link OutputStream} + * @return The {@link DecryptionResult} containing all relevant + * information that could be extracted from the encrypted data - including + * metadata, armored headers (if any), etc... + * @throws IOException if an IO error occurs reading from or writing to + * the underlying input or output streams. + * @throws PGPException if the PGP message is not formatted correctly. + * @throws PassphraseException if an incorrect passphrase was supplied + * for one of the decryption keys, or as the + * {@link #getSymmetricPassphrase()}. + * @throws DecryptionException if the message was not encrypted for any + * of the keys supplied for decryption. + * @throws VerificationException if {@link #isVerificationRequired} and + * the message was not signed by any of the keys supplied for verification. + */ + public DecryptionResult decryptWithFullDetails( + InputStream ciphertext, OutputStream plaintext) + throws IOException, PGPException { + InputStream unarmoredStream = unarmor(ciphertext); + List meta = unpack(parse(unarmoredStream), plaintext); if (meta.size() > 1) throw new PGPException("content contained more than one file"); - if (meta.size() < 1) - return new FileMetadata(); - return meta.get(0); - } + + FileMetadata metadata = (meta.size() < 1) ? new FileMetadata() : meta.get(0); + if (unarmoredStream instanceof ArmoredInputStream) { + ArmoredInputStream ais = (ArmoredInputStream) unarmoredStream; + String[] headers = ais.getArmorHeaders(); + return new DecryptionResult(metadata, true, + ((headers == null) || (headers.length == 0)) + ? Collections.emptyList() + : Arrays.asList(headers)); + } else { + return new DecryptionResult(metadata, false, Collections.emptyList()); + } + } /** * Recursively unpacks the pgp message packets, * writing the decrypted message content into the output stream. */ - protected List unpack(Iterator packets, - OutputStream plaintext) throws IOException, PGPException { + protected List unpack(Iterator packets, OutputStream plaintext) + throws IOException, PGPException { List meta = new ArrayList(); List verifiers = new ArrayList(); @@ -326,17 +367,14 @@ protected List unpack(Iterator packets, // in already initialized verifiers else matchSignatures(list.iterator(), verifiers); - } else if (packet instanceof PGPEncryptedDataList) { PGPEncryptedDataList list = (PGPEncryptedDataList) packet; // decrypt and unpack encrypted content meta.addAll(unpack(parse(decrypt(list.iterator())), plaintext)); - } else if (packet instanceof PGPCompressedData) { InputStream i = ((PGPCompressedData) packet).getDataStream(); // unpack compressed content meta.addAll(unpack(parse(i), plaintext)); - } else if (packet instanceof PGPLiteralData) { PGPLiteralData data = (PGPLiteralData) packet; FileMetadata file = new FileMetadata(data); @@ -345,7 +383,6 @@ protected List unpack(Iterator packets, // while also passing input bytes into verifiers file.setLength(copy(i, plaintext, verifiers)); meta.add(file); - } else { throw new PGPException("unexpected packet: " + packet.getClass()); } @@ -362,7 +399,7 @@ protected List unpack(Iterator packets, * for which a verification key is available. */ protected List buildVerifiers(Iterator signatures) - throws PGPException { + throws PGPException { List verifiers = new ArrayList(); while (signatures.hasNext()) { Verifier verifier = null; @@ -517,10 +554,10 @@ protected void verify(List verifiers, List meta) /** * Wraps stream with ArmoredInputStream if necessary - * (to convert ascii-armored content back into binary data). + * (to convert ASCII-armored content back into binary data). */ protected InputStream unarmor(InputStream stream) - throws IOException, PGPException { + throws IOException, PGPException { DetectionResult result = FileDetection.detectContainer(stream, getMaxFileBufferSize()); switch (result.type) { diff --git a/src/main/java/org/c02e/jpgpj/EncryptedAsciiArmorHeadersCallback.java b/src/main/java/org/c02e/jpgpj/EncryptedAsciiArmorHeadersCallback.java new file mode 100644 index 0000000..c238230 --- /dev/null +++ b/src/main/java/org/c02e/jpgpj/EncryptedAsciiArmorHeadersCallback.java @@ -0,0 +1,21 @@ +package org.c02e.jpgpj; + +/** + * Used by the encryptor to allow users to configure per-file + * armored headers instead/in addition to the global ones that + * are set by the encryptor + */ +@FunctionalInterface +public interface EncryptedAsciiArmorHeadersCallback { + /** + * Invoked by the encryptor after updating the + * settings with the configured global headers. + * + * @param encryptor The {@link Encryptor} that is handling the encryption request + * @param meta The input plaintext {@link FileMetadata} - might be empty + * (but not {@code null}). + * @param manipulator The manipulator that can be used to update the headers + */ + void prepareAsciiArmoredHeaders( + Encryptor encryptor, FileMetadata meta, EncryptedAsciiArmorHeadersManipulator manipulator); +} diff --git a/src/main/java/org/c02e/jpgpj/EncryptedAsciiArmorHeadersManipulator.java b/src/main/java/org/c02e/jpgpj/EncryptedAsciiArmorHeadersManipulator.java new file mode 100644 index 0000000..9f038d1 --- /dev/null +++ b/src/main/java/org/c02e/jpgpj/EncryptedAsciiArmorHeadersManipulator.java @@ -0,0 +1,54 @@ +package org.c02e.jpgpj; + +import java.util.Map; + +import org.bouncycastle.bcpg.ArmoredOutputStream; + +@FunctionalInterface +public interface EncryptedAsciiArmorHeadersManipulator { + /** + * A manipulator that ignores all headers manipulations + */ + EncryptedAsciiArmorHeadersManipulator EMPTY = (name, value) -> { /* do nothing */ }; + + /** + * Set the specified header value - replace any previous value + * + * @param name Case sensitive name of header to set. Note: this + * method can be used to override the default version header value. + * @param value Value to set - if {@code null} then equivalent to header removal + */ + void setHeader(String name, String value); + + /** + * Removes specified header - no-op if header not set anyway + * + * @param name Case sensitive name of header to set. Note: this + * method can be used to remove the default version header value. + */ + default void removeHeader(String name) { + setHeader(name, null); + } + + /** + * Replaces existing headers and adds missing ones + * + * @param headers The headers to update - ignored if {@code null}. + * Note: header name is case sensitive + */ + default void updateHeaders(Map headers) { + if (headers != null) { + headers.forEach((name, value) -> setHeader(name, value)); + } + } + + /** + * Wraps an {@link ArmoredOutputStream} + * + * @param aos The stream to wrap - ignored if {@code null} + * @return The manipulator wrapping + */ + static EncryptedAsciiArmorHeadersManipulator wrap(ArmoredOutputStream aos) { + return (aos == null) ? EMPTY : (name, value) -> aos.setHeader(name, value); + } +} diff --git a/src/main/java/org/c02e/jpgpj/Encryptor.java b/src/main/java/org/c02e/jpgpj/Encryptor.java index e520afa..3779c01 100644 --- a/src/main/java/org/c02e/jpgpj/Encryptor.java +++ b/src/main/java/org/c02e/jpgpj/Encryptor.java @@ -11,8 +11,12 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import org.bouncycastle.bcpg.ArmoredOutputStream; @@ -73,8 +77,11 @@ public class Encryptor { public static final int MAX_ENCRYPT_COPY_BUFFER_SIZE = 0x10000; protected boolean asciiArmored; - protected int compressionLevel; + protected boolean removeDefaultArmoredVersionHeader; + protected final Map armoredHeaders = new HashMap<>(); + protected EncryptedAsciiArmorHeadersCallback armorHeadersCallback; + protected int compressionLevel; protected CompressionAlgorithm compressionAlgorithm; protected EncryptionAlgorithm encryptionAlgorithm; protected HashingAlgorithm signingAlgorithm; @@ -124,6 +131,149 @@ public void setAsciiArmored(boolean x) { asciiArmored = x; } + /** + * @return The last set {@link EncryptedAsciiArmorHeadersCallback} + * @see #setArmorHeadersCallback(EncryptedAsciiArmorHeadersCallback) + */ + public EncryptedAsciiArmorHeadersCallback getArmorHeadersCallback() { + return armorHeadersCallback; + } + + /** + * Allows users to provide a callback that will be invoked for each + * encrypted armored output in order to allow them to set specified + * headers besides the global ones set by the encryptor. Note: + * affects the output only if {@link #isAsciiArmored() armored} setting is used. + * + * @param armorHeadersCallback The callback to invoke - {@code null} if none + * @see #isAsciiArmored() + * @see #isRemoveDefaultArmoredVersionHeader() + * @see #setArmoredHeaders(Map) setArmoredHeaders + * @see #addArmoredHeaders(Map) addArmoredHeaders + * @see #updateArmoredHeader(String, String) updateArmoredHeader + */ + public void setArmorHeadersCallback(EncryptedAsciiArmorHeadersCallback armorHeadersCallback) { + this.armorHeadersCallback = armorHeadersCallback; + } + + /** + * By default the {@link ArmoredOutputStream} adds a "Version" + * header - this setting allows users to remove this header (and perhaps + * replace it and/or add others - see headers manipulation methods). + * + * @return {@code true} if "Version" should be removed - default={@code false} + */ + public boolean isRemoveDefaultArmoredVersionHeader() { + return removeDefaultArmoredVersionHeader; + } + + /** + * By default the {@link ArmoredOutputStream} adds a "Version" + * header - this setting allows users to remove this header (and perhaps + * replace it and/or add others - see headers manipulation methods). Note: + * affects the output only if {@link #isAsciiArmored() armored} setting is used. + * + * @param removeDefaultarmoredVersionHeader {@code true} if "Version" + * should be removed - default={@code false}. Note: relevant only if + * {@link #setAsciiArmored(boolean) armored} setting was also set. + */ + public void setRemoveDefaultArmoredVersionHeader(boolean removeDefaultArmoredVersionHeader) { + this.removeDefaultArmoredVersionHeader = removeDefaultArmoredVersionHeader; + } + + /** + * Retrieves the value for the specified armored header. + * + * @param name Case sensitive name of header to get + * @return The header value - {@code null} if header not set + * @throws NullPointerException If no header name provided + */ + public String getArmoredHeader(String name) { + Objects.requireNonNull(name, "No header name provided"); + return armoredHeaders.get(name); + } + + /** + * @return An unmodifiable {@link Map} of + * the current armored headers - Note: header name + * access is case sensitive + */ + public Map getArmoredHeaders() { + if (armoredHeaders.isEmpty()) { + return Collections.emptyMap(); + } + + return Collections.unmodifiableMap(armoredHeaders); + } + + /** + * Replaces the current armored headers with the provided ones. Note: + * affects the output only if {@link #isAsciiArmored() armored} setting is used. + * + * @param headers The new headers to set - may be {@code null}/empty. Note: + *
    + *
  • Header names are case sensitive
  • + * + *
  • + * In order to clear all headers need to also use + * {@link #setRemoveDefaultArmoredVersionHeader(boolean)}. + *
  • + *
+ */ + public void setArmoredHeaders(Map headers) { + armoredHeaders.clear(); + addArmoredHeaders(headers); + } + + /** + * Adds the specified headers - replaces existing ones and adds the new ones. + * Note: affects the output only if {@link #isAsciiArmored() armored} + * setting is used. + * + * @param headers The headers to add - may be {@code null}/empty. Note: + * header names are case sensitive. + */ + public void addArmoredHeaders(Map headers) { + if (headers != null) { + armoredHeaders.putAll(headers); + } + } + + /** + * Sets the specified header value - replaces it if already set. Note: + * affects the output only if {@link #isAsciiArmored() armored} setting is used. + * + * @param name Case sensitive name of header to set. Note: this + * method can be used to override the default version header value. + * @param value Value to set - if {@code null} then equivalent to + * {@link #removeArmoredHeader(String) header removal} + * @return The replaced value - {@code null} if no previous value set + * @throws NullPointerException If no header name provided + * @see #setRemoveDefaultArmoredVersionHeader(boolean) + */ + public String updateArmoredHeader(String name, String value) { + if (value == null) { + return removeArmoredHeader(name); + } + + Objects.requireNonNull(name, "No header name provided"); + return armoredHeaders.put(name, value); + } + + /** + * Removes the specified armored header Note: affects the output only + * if {@link #isAsciiArmored() armored} setting is used. + * + * @param name Case sensitive name of header to remove - Note: + * in order to remove the version header must use {@link #setRemoveDefaultArmoredVersionHeader(boolean)}. + * @return The removed value - {@code null} if header was not set + * @throws NullPointerException If no header name provided + */ + public String removeArmoredHeader(String name) { + Objects.requireNonNull(name, "No header name provided"); + return armoredHeaders.remove(name); + } + /** * Compression level, from 1 (fastest and biggest) * to 9 (slowest and smallest). Defaults to 6. @@ -240,7 +390,7 @@ public void setSymmetricPassphrase(String x) { } /** - * Key-deriviation (aka s2k digest) algorithm to use + * Key-derivation (aka s2k digest) algorithm to use * (used to convert the symmetric passphrase into an encryption key). * Defaults to {@link HashingAlgorithm#SHA512}. */ @@ -249,7 +399,7 @@ public HashingAlgorithm getKeyDeriviationAlgorithm() { } /** - * Key-deriviation (aka s2k digest) algorithm to use + * Key-derivation (aka s2k digest) algorithm to use * (used to convert the symmetric passphrase into an encryption key). * Defaults to {@link HashingAlgorithm#SHA512}. */ @@ -258,7 +408,7 @@ public void setKeyDeriviationAlgorithm(HashingAlgorithm x) { } /** - * Key-deriviation work factor (aka s2k count) to use, from 0 to 255 + * Key-derivation work factor (aka s2k count) to use, from 0 to 255 * (where 1 = 1088 iterations, and 255 = 65,011,712 iterations). * Defaults to 255. */ @@ -267,7 +417,7 @@ public int getKeyDeriviationWorkFactor() { } /** - * Key-deriviation work factor (aka s2k count) to use, from 0 to 255 + * Key-derivation work factor (aka s2k count) to use, from 0 to 255 * (where 1 = 1088 iterations, and 255 = 65,011,712 iterations). * Defaults to 255. */ @@ -497,7 +647,7 @@ public OutputStream prepareCiphertextOutputStream( stack.add(ciphertext); // setup encryption pipeline - ciphertext = pipeline(armor(ciphertext), stack); + ciphertext = pipeline(armor(ciphertext, meta), stack); ciphertext = pipeline(encrypt(ciphertext, meta), stack); ciphertext = pipeline(compress(ciphertext, meta), stack); SigningOutputStream signingstream = sign(ciphertext, meta); @@ -589,12 +739,42 @@ protected OutputStream pipeline(OutputStream out, List stack) { } /** - * Wraps with stream that outputs ascii-armored text. + * Wraps with stream that outputs ASCII-armored text - including configuring + * its armor headers. + * + * @param meta The input plaintext {@link FileMetadata} - might be empty + * (but not {@code null}). + * @param out The {@link OutputStream} to wrap + * @return The wrapped output stream - {@code null} if no wrapping. + * @see #isAsciiArmored() + * @see #isRemoveDefaultArmoredVersionHeader() + * @see #setArmoredHeaders(Map) setArmoredHeaders + * @see #addArmoredHeaders(Map) addArmoredHeaders + * @see #updateArmoredHeader(String, String) updateArmoredHeader + * @see #setArmorHeadersCallback(EncryptedAsciiArmorHeadersCallback) */ - protected OutputStream armor(OutputStream out) { - if (asciiArmored) - return new ArmoredOutputStream(out); - return null; + protected OutputStream armor(OutputStream out, FileMetadata meta) { + if (!isAsciiArmored()) { + return null; + } + + ArmoredOutputStream aos = new ArmoredOutputStream(out); + if (isRemoveDefaultArmoredVersionHeader()) { + aos.setHeader(ArmoredOutputStream.VERSION_HDR, null); + } + + // add the global headers - if any + armoredHeaders.forEach((name, value) -> aos.setHeader(name, value)); + + // see if user wants to manipulate the headers + EncryptedAsciiArmorHeadersCallback callback = getArmorHeadersCallback(); + if (callback != null) { + EncryptedAsciiArmorHeadersManipulator manipulator = + EncryptedAsciiArmorHeadersManipulator.wrap(aos); + callback.prepareAsciiArmoredHeaders(this, meta, manipulator); + } + + return aos; } /** diff --git a/src/main/java/org/c02e/jpgpj/Ring.java b/src/main/java/org/c02e/jpgpj/Ring.java index 512c4dd..93d2db9 100644 --- a/src/main/java/org/c02e/jpgpj/Ring.java +++ b/src/main/java/org/c02e/jpgpj/Ring.java @@ -36,7 +36,7 @@ * {@link #load(File)} method, or by loading them from an input stream via the * {@link #load(InputStream)} method. A ring can also be constructed from * an existing array of keys ({@link #Ring(Key...)}), or from an existing list - * of keys ({@link #Ring(List)}), or from an ascii-armor text string containing + * of keys ({@link #Ring(List)}), or from an ASCII-armor text string containing * the keys ({@link #Ring(String)}), or from a file containing the keys * ({@link #Ring(File)}), or from an input stream containing the keys * ({@link #Ring(InputStream)}). diff --git a/src/test/groovy/org/c02e/jpgpj/EncryptorSpec.groovy b/src/test/groovy/org/c02e/jpgpj/EncryptorSpec.groovy index 2d0d67c..7bb9384 100644 --- a/src/test/groovy/org/c02e/jpgpj/EncryptorSpec.groovy +++ b/src/test/groovy/org/c02e/jpgpj/EncryptorSpec.groovy @@ -246,9 +246,14 @@ class EncryptorSpec extends Specification { def decryptor = new Decryptor(new Ring(stream('test-key-1.asc'))) decryptor.ring.keys*.passphrase = 'c02e' - decryptor.decrypt cipherIn, plainOut + def result = decryptor.decryptWithFullDetails cipherIn, plainOut + def armorHeaders = result.armorHeaders then: + result.asciiArmored == true + armorHeaders.size() == 1 + armorHeaders[0].startsWith("Version") + plainOut.toString() == plainText cipherOut.toString(). replaceFirst(/(?m)^(hQEMAyne546XDHBhAQ)[\w\+\/\n]+[\w\+\/]={0,2}/, '$1...'). @@ -262,6 +267,58 @@ hQEMAyne546XDHBhAQ... '''.trim() + '\n' } + def "encrypt armored without version header"() { + when: + def encryptor = new Encryptor(new Ring(stream('test-key-1.asc'))) + encryptor.asciiArmored = true + encryptor.removeDefaultArmoredVersionHeader = true + encryptor.ring.keys*.passphrase = 'c02e' + encryptor.encrypt plainIn, cipherOut + def decryptor = new Decryptor(new Ring(stream('test-key-1.asc'))) + decryptor.ring.keys*.passphrase = 'c02e' + def result = decryptor.decryptWithFullDetails cipherIn, plainOut + def armorHeaders = result.armorHeaders + + then: + result.asciiArmored == true + armorHeaders.size() == 0 + plainOut.toString() == plainText + } + + def "encrypt and use user defined ascii armor headers"() { + when: + def encryptor = new Encryptor(new Ring(stream('test-key-1.asc'))) + encryptor.asciiArmored = true + encryptor.ring.keys*.passphrase = 'c02e' + encryptor.updateArmoredHeader("Version", "3.14") + encryptor.updateArmoredHeader("Encryptor", "c02e") + encryptor.armorHeadersCallback = new EncryptedAsciiArmorHeadersCallback() { + @Override + public void prepareAsciiArmoredHeaders( + Encryptor enc, FileMetadata meta, EncryptedAsciiArmorHeadersManipulator manipulator) { + manipulator.setHeader("Version", "2.71") // override + manipulator.setHeader("Callback", "true") // add new + } + } + encryptor.encrypt plainIn, cipherOut + + def decryptor = new Decryptor(new Ring(stream('test-key-1.asc'))) + decryptor.ring.keys*.passphrase = 'c02e' + def result = decryptor.decryptWithFullDetails cipherIn, plainOut + // result headers list is unmodifiable and we want to sort it + def armorHeaders = new ArrayList<>(result.armorHeaders) + armorHeaders.sort(Comparator.naturalOrder()) + + then: + result.asciiArmored == true + armorHeaders.size() == 3 + armorHeaders[0].equals("Callback: true") // added by callback + armorHeaders[1].equals("Encryptor: c02e") // global setting in encryptor + armorHeaders[2].equals("Version: 2.71") // overwritten by the callback + + plainOut.toString() == plainText + } + def "encrypt and sign file"() { when: def encryptor = new Encryptor(new Key(file('test-key-1.asc'), 'c02e'))