From 8c6c7528f1e24c6b71f3e36db0cb8a697256ce25 Mon Sep 17 00:00:00 2001 From: "Marvin S. Addison" Date: Tue, 21 Jan 2020 16:59:39 -0500 Subject: [PATCH] Define new ciphertext header format. New format does not allocate any memory until HMAC check passes, which guards against untrusted input. All encryption components have been updated to use the new header, while preserving backward compatibility to decrypt messages encrypted with the old format. The decoding process for the old header has been hardened to impose reasonable limits on header fields: nonce sizes up to 255 bytes, key names up to 500 bytes. Fixes #52. --- .../org/cryptacular/CiphertextHeader.java | 65 +++- .../org/cryptacular/CiphertextHeaderV2.java | 307 ++++++++++++++++++ .../bean/AbstractBlockCipherBean.java | 10 +- .../cryptacular/bean/AbstractCipherBean.java | 31 +- .../java/org/cryptacular/util/ByteUtil.java | 45 ++- .../java/org/cryptacular/util/CipherUtil.java | 90 +++-- .../org/cryptacular/CiphertextHeaderTest.java | 55 ++++ .../cryptacular/CiphertextHeaderV2Test.java | 67 ++++ .../bean/AEADBlockCipherBeanTest.java | 65 ++-- .../org/cryptacular/util/CipherUtilTest.java | 47 +++ 10 files changed, 707 insertions(+), 75 deletions(-) create mode 100644 src/main/java/org/cryptacular/CiphertextHeaderV2.java create mode 100644 src/test/java/org/cryptacular/CiphertextHeaderTest.java create mode 100644 src/test/java/org/cryptacular/CiphertextHeaderV2Test.java diff --git a/src/main/java/org/cryptacular/CiphertextHeader.java b/src/main/java/org/cryptacular/CiphertextHeader.java index 93623c9..c17e735 100644 --- a/src/main/java/org/cryptacular/CiphertextHeader.java +++ b/src/main/java/org/cryptacular/CiphertextHeader.java @@ -34,18 +34,26 @@ * decrypt outstanding data which will be subsequently re-encrypted with a new key.

* * @author Middleware Services + * + * @deprecated Superseded by {@link CiphertextHeaderV2} */ +@Deprecated public class CiphertextHeader { + /** Maximum nonce length in bytes. */ + protected static final int MAX_NONCE_LEN = 255; + + /** Maximum key name length in bytes. */ + protected static final int MAX_KEYNAME_LEN = 500; /** Header nonce field value. */ - private final byte[] nonce; + protected final byte[] nonce; /** Header key name field value. */ - private String keyName; + protected final String keyName; /** Header length in bytes. */ - private int length; + protected final int length; /** @@ -67,12 +75,17 @@ public CiphertextHeader(final byte[] nonce) */ public CiphertextHeader(final byte[] nonce, final String keyName) { - this.nonce = nonce; - this.length = 8 + nonce.length; + if (nonce.length > 255) { + throw new IllegalArgumentException("Nonce exceeds size limit in bytes (255)"); + } if (keyName != null) { - this.length += 4 + keyName.getBytes().length; - this.keyName = keyName; + if (ByteUtil.toBytes(keyName).length > MAX_KEYNAME_LEN) { + throw new IllegalArgumentException("Key name exceeds size limit in bytes (500)"); + } } + this.nonce = nonce; + this.keyName = keyName; + length = computeLength(); } /** @@ -127,6 +140,19 @@ public String getKeyName() } + /** + * @return Length of this header encoded as bytes. + */ + protected int computeLength() + { + int len = 8 + nonce.length; + if (keyName != null) { + len += 4 + keyName.getBytes().length; + } + return len; + } + + /** * Creates a header from encrypted data containing a cleartext header prepended to the start. * @@ -143,17 +169,20 @@ public static CiphertextHeader decode(final byte[] data) throws EncodingExceptio final int length = bb.getInt(); if (length < 0) { - throw new EncodingException("Invalid ciphertext header length: " + length); + throw new EncodingException("Bad ciphertext header"); } final byte[] nonce; int nonceLen = 0; try { nonceLen = bb.getInt(); + if (nonceLen > MAX_NONCE_LEN) { + throw new EncodingException("Bad ciphertext header: maximum nonce length exceeded"); + } nonce = new byte[nonceLen]; bb.get(nonce); } catch (IndexOutOfBoundsException | BufferUnderflowException e) { - throw new EncodingException("Invalid nonce length: " + nonceLen); + throw new EncodingException("Bad ciphertext header"); } String keyName = null; @@ -162,11 +191,14 @@ public static CiphertextHeader decode(final byte[] data) throws EncodingExceptio int keyLen = 0; try { keyLen = bb.getInt(); + if (keyLen > MAX_KEYNAME_LEN) { + throw new EncodingException("Bad ciphertext header: maximum key length exceeded"); + } b = new byte[keyLen]; bb.get(b); keyName = new String(b); } catch (IndexOutOfBoundsException | BufferUnderflowException e) { - throw new EncodingException("Invalid key length: " + keyLen); + throw new EncodingException("Bad ciphertext header"); } } @@ -188,17 +220,20 @@ public static CiphertextHeader decode(final InputStream input) throws EncodingEx { final int length = ByteUtil.readInt(input); if (length < 0) { - throw new EncodingException("Invalid ciphertext header length: " + length); + throw new EncodingException("Bad ciphertext header"); } final byte[] nonce; int nonceLen = 0; try { nonceLen = ByteUtil.readInt(input); + if (nonceLen > MAX_NONCE_LEN) { + throw new EncodingException("Bad ciphertext header: maximum nonce size exceeded"); + } nonce = new byte[nonceLen]; input.read(nonce); } catch (ArrayIndexOutOfBoundsException e) { - throw new EncodingException("Invalid nonce length: " + nonceLen); + throw new EncodingException("Bad ciphertext header"); } catch (IOException e) { throw new StreamException(e); } @@ -209,10 +244,13 @@ public static CiphertextHeader decode(final InputStream input) throws EncodingEx int keyLen = 0; try { keyLen = ByteUtil.readInt(input); + if (keyLen > MAX_KEYNAME_LEN) { + throw new EncodingException("Bad ciphertext header: maximum key length exceeded"); + } b = new byte[keyLen]; input.read(b); } catch (ArrayIndexOutOfBoundsException e) { - throw new EncodingException("Invalid key length: " + keyLen); + throw new EncodingException("Bad ciphertext header"); } catch (IOException e) { throw new StreamException(e); } @@ -221,4 +259,5 @@ public static CiphertextHeader decode(final InputStream input) throws EncodingEx return new CiphertextHeader(nonce, keyName); } + } diff --git a/src/main/java/org/cryptacular/CiphertextHeaderV2.java b/src/main/java/org/cryptacular/CiphertextHeaderV2.java new file mode 100644 index 0000000..8119f4e --- /dev/null +++ b/src/main/java/org/cryptacular/CiphertextHeaderV2.java @@ -0,0 +1,307 @@ +/* See LICENSE for licensing and NOTICE for copyright. */ +package org.cryptacular; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.function.BiConsumer; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.macs.HMac; +import org.cryptacular.util.ByteUtil; + +/** + * Cleartext header prepended to ciphertext providing data required for decryption. + * + *

Data format:

+ * + *
+     +---------+---------+---+----------+-------+------+
+     | Version | KeyName | 0 | NonceLen | Nonce | HMAC |
+     +---------+---------+---+----------+-------+------+
+     |                                                 |
+     +--- 4 ---+--- x ---+ 1 +--- 1 ----+-- y --+- 32 -+
+ * 
+ * + *

Where fields are defined as follows:

+ * + * + * + *

The last two fields provide support for multiple keys at the encryption provider. A common case for multiple + * keys is key rotation; by tagging encrypted data with a key name, an old key may be retrieved by name to decrypt + * outstanding data which will be subsequently re-encrypted with a new key.

+ * + * @author Middleware Services + */ +public class CiphertextHeaderV2 extends CiphertextHeader +{ + /** Header version format. */ + private static final int VERSION = -2; + + /** Size of HMAC algorithm output in bytes. */ + private static final int HMAC_SIZE = 32; + + /** Function to resolve a key from a symbolic key name. */ + private Function keyLookup; + + + /** + * Creates a new instance with a nonce and named key. + * + * @param nonce Nonce bytes. + * @param keyName Key name. + */ + public CiphertextHeaderV2(final byte[] nonce, final String keyName) + { + super(nonce, keyName); + if (keyName == null || keyName.isEmpty()) { + throw new IllegalArgumentException("Key name is required"); + } + } + + + /** + * Sets the function to resolve keys from {@link #keyName}. + * + * @param keyLookup Key lookup function. + */ + public void setKeyLookup(final Function keyLookup) + { + this.keyLookup = keyLookup; + } + + + @Override + public byte[] encode() + { + final SecretKey key = keyLookup != null ? keyLookup.apply(keyName) : null; + if (key == null) { + throw new IllegalStateException("Could not resolve secret key to generate header HMAC"); + } + return encode(key); + } + + + /** + * Encodes the header into bytes. + * + * @param hmacKey Key used to generate header HMAC. + * + * @return Byte representation of header. + */ + public byte[] encode(final SecretKey hmacKey) + { + final ByteBuffer bb = ByteBuffer.allocate(length); + bb.order(ByteOrder.BIG_ENDIAN); + bb.putInt(VERSION); + bb.put(ByteUtil.toBytes(keyName)); + bb.put((byte) 0); + bb.put(ByteUtil.toUnsignedByte(nonce.length)); + bb.put(nonce); + if (hmacKey != null) { + final byte[] hmac = hmac(bb.array(), 0, bb.limit() - HMAC_SIZE); + bb.put(hmac); + } + return bb.array(); + } + + + /** + * @return Length of this header encoded as bytes. + */ + protected int computeLength() + { + return 4 + ByteUtil.toBytes(keyName).length + 2 + nonce.length + HMAC_SIZE; + } + + + /** + * Creates a header from encrypted data containing a cleartext header prepended to the start. + * + * @param data Encrypted data with prepended header data. + * @param keyLookup Function used to look up the secret key from the symbolic key name in the header. + * + * @return Decoded header. + * + * @throws EncodingException when ciphertext header cannot be decoded. + */ + public static CiphertextHeaderV2 decode(final byte[] data, final Function keyLookup) + throws EncodingException + { + final ByteBuffer bb = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN); + return decodeInternal( + ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN), + keyLookup, + ByteBuffer -> bb.getInt(), + ByteBuffer -> bb.get(), + (ByteBuffer, output) -> bb.get(output)); + } + + + /** + * Creates a header from encrypted data containing a cleartext header prepended to the start. + * + * @param input Input stream that is positioned at the start of ciphertext header data. + * @param keyLookup Function used to look up the secret key from the symbolic key name in the header. + * + * @return Decoded header. + * + * @throws EncodingException when ciphertext header cannot be decoded. + * @throws StreamException on stream IO errors. + */ + public static CiphertextHeaderV2 decode(final InputStream input, final Function keyLookup) + throws EncodingException, StreamException + { + return decodeInternal( + input, keyLookup, ByteUtil::readInt, CiphertextHeaderV2::readByte, CiphertextHeaderV2::readInto); + } + + + /** + * Internal header decoding routine. + * + * @param Type of input source. + * @param source Source of header data (input stream or byte buffer). + * @param keyLookup Function to look up key from symbolic key name in header. + * @param readIntFn Function that produces a 4-byte integer from the input source. + * @param readByteFn Function that produces a byte from the input source. + * @param readBytesConsumer Function that fills a byte array from the input source. + * + * @return Decoded header. + */ + private static CiphertextHeaderV2 decodeInternal( + final T source, + final Function keyLookup, + final Function readIntFn, + final Function readByteFn, + final BiConsumer readBytesConsumer) + { + final SecretKey key; + final String keyName; + final byte[] nonce; + final byte[] hmac; + try { + final int version = readIntFn.apply(source); + if (version != VERSION) { + throw new EncodingException("Unsupported ciphertext header version"); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(100); + byte b = 0; + int count = 0; + while ((b = readByteFn.apply(source)) != 0) { + out.write(b); + if (out.size() > MAX_KEYNAME_LEN) { + throw new EncodingException("Bad ciphertext header: maximum nonce length exceeded"); + } + count++; + } + keyName = ByteUtil.toString(out.toByteArray(), 0, count); + key = keyLookup.apply(keyName); + if (key == null) { + throw new IllegalStateException("Symbolic key name mentioned in header was not found"); + } + final int nonceLen = ByteUtil.toInt(readByteFn.apply(source)); + nonce = new byte[nonceLen]; + readBytesConsumer.accept(source, nonce); + hmac = new byte[HMAC_SIZE]; + readBytesConsumer.accept(source, hmac); + } catch (IndexOutOfBoundsException | BufferUnderflowException e) { + throw new EncodingException("Bad ciphertext header"); + } + final CiphertextHeaderV2 header = new CiphertextHeaderV2(nonce, keyName); + final byte[] encoded = header.encode(key); + if (!arraysEqual(hmac, 0, encoded, encoded.length - HMAC_SIZE, HMAC_SIZE)) { + throw new EncodingException("Ciphertext header HMAC verification failed"); + } + header.setKeyLookup(keyLookup); + return header; + } + + + /** + * Generates an HMAC-256 over the given input byte array. + * + * @param input Input bytes. + * @param offset Starting position in input byte array. + * @param length Number of bytes in input to consume. + * + * @return HMAC as byte array. + */ + private static byte[] hmac(final byte[] input, final int offset, final int length) + { + final HMac hmac = new HMac(new SHA256Digest()); + final byte[] output = new byte[HMAC_SIZE]; + hmac.update(input, offset, length); + hmac.doFinal(output, 0); + return output; + } + + + /** + * Read output.length bytes from the input stream into the output buffer. + * + * @param input Input stream. + * @param output Output buffer. + */ + private static void readInto(final InputStream input, final byte[] output) + { + try { + input.read(output); + } catch (IOException e) { + throw new StreamException(e); + } + } + + + /** + * Read a single byte from the input stream. + * + * @param input Input stream. + * + * @return Byte read from input stream. + */ + private static byte readByte(final InputStream input) + { + try { + return (byte) input.read(); + } catch (IOException e) { + throw new StreamException(e); + } + } + + + /** + * Determines if two byte array ranges are equal bytewise. + * + * @param a First array to compare. + * @param aOff Offset into first array. + * @param b Second array to compare. + * @param bOff Offset into second array. + * @param length Number of bytes to compare. + * + * @return True if every byte in the given range is equal, false otherwise. + */ + private static boolean arraysEqual(final byte[] a, final int aOff, final byte[] b, final int bOff, final int length) + { + if (length + aOff > a.length || length + bOff > b.length) { + return false; + } + for (int i = 0; i < length; i++) { + if (a[i + aOff] != b[i + bOff]) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java b/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java index 0cd6542..0d06b32 100644 --- a/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java +++ b/src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java @@ -45,12 +45,12 @@ public AbstractBlockCipherBean( protected byte[] process(final CiphertextHeader header, final boolean mode, final byte[] input) { final BlockCipherAdapter cipher = newCipher(header, mode); - final byte[] headerBytes = header.encode(); int outOff; final int inOff; final int length; final byte[] output; if (mode) { + final byte[] headerBytes = header.encode(); final int outSize = headerBytes.length + cipher.getOutputSize(input.length); output = new byte[outSize]; System.arraycopy(headerBytes, 0, output, 0, headerBytes.length); @@ -58,12 +58,12 @@ public AbstractBlockCipherBean( outOff = headerBytes.length; length = input.length; } else { - length = input.length - headerBytes.length; + outOff = 0; + inOff = header.getLength(); + length = input.length - inOff; final int outSize = cipher.getOutputSize(length); output = new byte[outSize]; - inOff = headerBytes.length; - outOff = 0; } outOff += cipher.processBytes(input, inOff, length, output, outOff); outOff += cipher.doFinal(output, outOff); @@ -85,7 +85,7 @@ protected void process( { final BlockCipherAdapter cipher = newCipher(header, mode); final int outSize = cipher.getOutputSize(StreamUtil.CHUNK_SIZE); - final byte[] outBuf = new byte[outSize > StreamUtil.CHUNK_SIZE ? outSize : StreamUtil.CHUNK_SIZE]; + final byte[] outBuf = new byte[Math.max(outSize, StreamUtil.CHUNK_SIZE)]; StreamUtil.pipeAll( input, output, diff --git a/src/main/java/org/cryptacular/bean/AbstractCipherBean.java b/src/main/java/org/cryptacular/bean/AbstractCipherBean.java index f82a259..fd73763 100644 --- a/src/main/java/org/cryptacular/bean/AbstractCipherBean.java +++ b/src/main/java/org/cryptacular/bean/AbstractCipherBean.java @@ -8,14 +8,16 @@ import java.security.KeyStore; import javax.crypto.SecretKey; import org.cryptacular.CiphertextHeader; +import org.cryptacular.CiphertextHeaderV2; import org.cryptacular.CryptoException; import org.cryptacular.EncodingException; import org.cryptacular.StreamException; import org.cryptacular.generator.Nonce; +import org.cryptacular.util.CipherUtil; /** * Base class for all cipher beans. The base class assumes all ciphertext output will contain a prepended {@link - * CiphertextHeader} containing metadata that facilitates decryption. + * CiphertextHeaderV2} containing metadata that facilitates decryption. * * @author Middleware Services */ @@ -128,14 +130,14 @@ public void setNonce(final Nonce nonce) @Override public byte[] encrypt(final byte[] input) throws CryptoException { - return process(new CiphertextHeader(nonce.generate(), keyAlias), true, input); + return process(header(), true, input); } @Override public void encrypt(final InputStream input, final OutputStream output) throws CryptoException, StreamException { - final CiphertextHeader header = new CiphertextHeader(nonce.generate(), keyAlias); + final CiphertextHeaderV2 header = header(); try { output.write(header.encode()); } catch (IOException e) { @@ -148,11 +150,7 @@ public void encrypt(final InputStream input, final OutputStream output) throws C @Override public byte[] decrypt(final byte[] input) throws CryptoException, EncodingException { - final CiphertextHeader header = CiphertextHeader.decode(input); - if (header.getKeyName() == null) { - throw new CryptoException("Ciphertext header does not contain required key"); - } - return process(header, false, input); + return process(CipherUtil.decodeHeader(input, this::lookupKey), false, input); } @@ -160,11 +158,7 @@ public void encrypt(final InputStream input, final OutputStream output) throws C public void decrypt(final InputStream input, final OutputStream output) throws CryptoException, EncodingException, StreamException { - final CiphertextHeader header = CiphertextHeader.decode(input); - if (header.getKeyName() == null) { - throw new CryptoException("Ciphertext header does not contain required key"); - } - process(header, false, input, output); + process(CipherUtil.decodeHeader(input, this::lookupKey), false, input, output); } @@ -211,4 +205,15 @@ protected SecretKey lookupKey(final String alias) * @param output Stream that receives output of cipher. */ protected abstract void process(CiphertextHeader header, boolean mode, InputStream input, OutputStream output); + + + /** + * @return New ciphertext header for a pending encryption or decryption operation performed by this instance. + */ + private CiphertextHeaderV2 header() + { + final CiphertextHeaderV2 header = new CiphertextHeaderV2(nonce.generate(), keyAlias); + header.setKeyLookup(this::lookupKey); + return header; + } } diff --git a/src/main/java/org/cryptacular/util/ByteUtil.java b/src/main/java/org/cryptacular/util/ByteUtil.java index 541fbf7..2163639 100644 --- a/src/main/java/org/cryptacular/util/ByteUtil.java +++ b/src/main/java/org/cryptacular/util/ByteUtil.java @@ -31,7 +31,7 @@ private ByteUtil() {} * * @param data 4-byte array in big-endian format. * - * @return Long integer value. + * @return Integer value. */ public static int toInt(final byte[] data) { @@ -39,6 +39,19 @@ public static int toInt(final byte[] data) } + /** + * Converts an unsigned byte into an integer. + * + * @param unsigned Unsigned byte. + * + * @return Integer value. + */ + public static int toInt(final byte unsigned) + { + return 0x000000FF & unsigned; + } + + /** * Reads 4-bytes from the input stream and converts to a 32-bit integer. * @@ -175,6 +188,21 @@ public static String toString(final byte[] bytes) } + /** + * Converts a portion of a byte array into a string in the UTF-8 character set. + * + * @param bytes Byte array to convert. + * @param offset Offset into byte array where string content begins. + * @param length Total number of bytes to convert. + * + * @return UTF-8 string representation of bytes. + */ + public static String toString(final byte[] bytes, final int offset, final int length) + { + return new String(bytes, offset, length, DEFAULT_CHARSET); + } + + /** * Converts a byte buffer into a string in the UTF-8 character set. * @@ -226,6 +254,19 @@ public static ByteBuffer toByteBuffer(final String s) } + /** + * Converts an integer into an unsigned byte. All bits above 1 byte are truncated. + * + * @param b Integer value. + * + * @return Unsigned byte as a byte. + */ + public static byte toUnsignedByte(final int b) + { + return (byte) (0x000000FF & b); + } + + /** * Converts a byte buffer into a byte array. * @@ -244,4 +285,6 @@ public static ByteBuffer toByteBuffer(final String s) buffer.get(array); return array; } + + } diff --git a/src/main/java/org/cryptacular/util/CipherUtil.java b/src/main/java/org/cryptacular/util/CipherUtil.java index d460039..cdbac0d 100644 --- a/src/main/java/org/cryptacular/util/CipherUtil.java +++ b/src/main/java/org/cryptacular/util/CipherUtil.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.function.Function; import javax.crypto.SecretKey; import org.bouncycastle.crypto.BlockCipher; import org.bouncycastle.crypto.modes.AEADBlockCipher; @@ -13,6 +14,7 @@ import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.crypto.params.ParametersWithIV; import org.cryptacular.CiphertextHeader; +import org.cryptacular.CiphertextHeaderV2; import org.cryptacular.CryptoException; import org.cryptacular.EncodingException; import org.cryptacular.StreamException; @@ -37,15 +39,15 @@ private CipherUtil() {} /** - * Encrypts data using an AEAD cipher. A {@link CiphertextHeader} is prepended to the resulting ciphertext and used as - * AAD (Additional Authenticated Data) passed to the AEAD cipher. + * Encrypts data using an AEAD cipher. A {@link CiphertextHeaderV2} is prepended to the resulting ciphertext and + * used as AAD (Additional Authenticated Data) passed to the AEAD cipher. * * @param cipher AEAD cipher. * @param key Encryption key. * @param nonce Nonce generator. * @param data Plaintext data to be encrypted. * - * @return Concatenation of encoded {@link CiphertextHeader} and encrypted data that completely fills the returned + * @return Concatenation of encoded {@link CiphertextHeaderV2} and encrypted data that completely fills the returned * byte array. * * @throws CryptoException on encryption errors. @@ -54,22 +56,22 @@ private CipherUtil() {} throws CryptoException { final byte[] iv = nonce.generate(); - final byte[] header = new CiphertextHeader(iv).encode(); + final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key); cipher.init(true, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, iv, header)); return encrypt(new AEADBlockCipherAdapter(cipher), header, data); } /** - * Encrypts data using an AEAD cipher. A {@link CiphertextHeader} is prepended to the resulting ciphertext and used as - * AAD (Additional Authenticated Data) passed to the AEAD cipher. + * Encrypts data using an AEAD cipher. A {@link CiphertextHeaderV2} is prepended to the resulting ciphertext and used + * as AAD (Additional Authenticated Data) passed to the AEAD cipher. * * @param cipher AEAD cipher. * @param key Encryption key. * @param nonce Nonce generator. * @param input Input stream containing plaintext data. - * @param output Output stream that receives a {@link CiphertextHeader} followed by ciphertext data produced by the - * AEAD cipher in encryption mode. + * @param output Output stream that receives a {@link CiphertextHeaderV2} followed by ciphertext data produced by + * the AEAD cipher in encryption mode. * * @throws CryptoException on encryption errors. * @throws StreamException on IO errors. @@ -83,7 +85,7 @@ public static void encrypt( throws CryptoException, StreamException { final byte[] iv = nonce.generate(); - final byte[] header = new CiphertextHeader(iv).encode(); + final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key); cipher.init(true, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, iv, header)); writeHeader(header, output); process(new AEADBlockCipherAdapter(cipher), input, output); @@ -95,7 +97,7 @@ public static void encrypt( * * @param cipher AEAD cipher. * @param key Encryption key. - * @param data Ciphertext data containing a prepended {@link CiphertextHeader}. The header is treated as AAD input + * @param data Ciphertext data containing a prepended {@link CiphertextHeaderV2}. The header is treated as AAD input * to the cipher that is verified during decryption. * * @return Decrypted data that completely fills the returned byte array. @@ -106,7 +108,7 @@ public static void encrypt( public static byte[] decrypt(final AEADBlockCipher cipher, final SecretKey key, final byte[] data) throws CryptoException, EncodingException { - final CiphertextHeader header = CiphertextHeader.decode(data); + final CiphertextHeader header = decodeHeader(data, String -> key); final byte[] nonce = header.getNonce(); final byte[] hbytes = header.encode(); cipher.init(false, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, nonce, hbytes)); @@ -119,7 +121,7 @@ public static void encrypt( * * @param cipher AEAD cipher. * @param key Encryption key. - * @param input Input stream containing a {@link CiphertextHeader} followed by ciphertext data. The header is + * @param input Input stream containing a {@link CiphertextHeaderV2} followed by ciphertext data. The header is * treated as AAD input to the cipher that is verified during decryption. * @param output Output stream that receives plaintext produced by block cipher in decryption mode. * @@ -134,7 +136,7 @@ public static void decrypt( final OutputStream output) throws CryptoException, EncodingException, StreamException { - final CiphertextHeader header = CiphertextHeader.decode(input); + final CiphertextHeader header = decodeHeader(input, String -> key); final byte[] nonce = header.getNonce(); final byte[] hbytes = header.encode(); cipher.init(false, new AEADParameters(new KeyParameter(key.getEncoded()), MAC_SIZE_BITS, nonce, hbytes)); @@ -143,7 +145,7 @@ public static void decrypt( /** - * Encrypts data using the given block cipher with PKCS5 padding. A {@link CiphertextHeader} is prepended to the + * Encrypts data using the given block cipher with PKCS5 padding. A {@link CiphertextHeaderV2} is prepended to the * resulting ciphertext. * * @param cipher Block cipher. @@ -152,7 +154,7 @@ public static void decrypt( * cipher block size. * @param data Plaintext data to be encrypted. * - * @return Concatenation of encoded {@link CiphertextHeader} and encrypted data that completely fills the returned + * @return Concatenation of encoded {@link CiphertextHeaderV2} and encrypted data that completely fills the returned * byte array. * * @throws CryptoException on encryption errors. @@ -161,7 +163,7 @@ public static void decrypt( throws CryptoException { final byte[] iv = nonce.generate(); - final byte[] header = new CiphertextHeader(iv).encode(); + final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key); final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding()); padded.init(true, new ParametersWithIV(new KeyParameter(key.getEncoded()), iv)); return encrypt(new BufferedBlockCipherAdapter(padded), header, data); @@ -191,7 +193,7 @@ public static void encrypt( throws CryptoException, StreamException { final byte[] iv = nonce.generate(); - final byte[] header = new CiphertextHeader(iv).encode(); + final byte[] header = new CiphertextHeaderV2(iv, "1").encode(key); final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding()); padded.init(true, new ParametersWithIV(new KeyParameter(key.getEncoded()), iv)); writeHeader(header, output); @@ -214,7 +216,7 @@ public static void encrypt( public static byte[] decrypt(final BlockCipher cipher, final SecretKey key, final byte[] data) throws CryptoException, EncodingException { - final CiphertextHeader header = CiphertextHeader.decode(data); + final CiphertextHeader header = decodeHeader(data, String -> key); final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding()); padded.init(false, new ParametersWithIV(new KeyParameter(key.getEncoded()), header.getNonce())); return decrypt(new BufferedBlockCipherAdapter(padded), data, header.getLength()); @@ -240,13 +242,62 @@ public static void decrypt( final OutputStream output) throws CryptoException, EncodingException, StreamException { - final CiphertextHeader header = CiphertextHeader.decode(input); + final CiphertextHeader header = decodeHeader(input, String -> key); final PaddedBufferedBlockCipher padded = new PaddedBufferedBlockCipher(cipher, new PKCS7Padding()); padded.init(false, new ParametersWithIV(new KeyParameter(key.getEncoded()), header.getNonce())); process(new BufferedBlockCipherAdapter(padded), input, output); } + /** + * Decodes the ciphertext header at the start of the given byte array. + * Supports both original (deprecated) and v2 formats. + * + * @param data Ciphertext data with prepended header. + * @param keyLookup Decryption key lookup function. + * + * @return Ciphertext header instance. + */ + public static CiphertextHeader decodeHeader(final byte[] data, final Function keyLookup) + { + try { + return CiphertextHeaderV2.decode(data, keyLookup); + } catch (EncodingException e) { + return CiphertextHeader.decode(data); + } + } + + + /** + * Decodes the ciphertext header at the start of the given input stream. + * Supports both original (deprecated) and v2 formats. + * + * @param in Ciphertext stream that is positioned at the start of the ciphertext header. + * @param keyLookup Decryption key lookup function. + * + * @return Ciphertext header instance. + */ + public static CiphertextHeader decodeHeader(final InputStream in, final Function keyLookup) + { + CiphertextHeader header; + try { + // Mark the stream start position so we can try again with old format header + if (in.markSupported()) { + in.mark(4); + } + header = CiphertextHeaderV2.decode(in, keyLookup); + } catch (EncodingException e) { + try { + in.reset(); + } catch (IOException ioe) { + throw new StreamException("Stream error trying to process old header format: " + ioe.getMessage()); + } + header = CiphertextHeader.decode(in); + } + return header; + } + + /** * Encrypts the given data. * @@ -325,6 +376,7 @@ private static void process(final BlockCipherAdapter cipher, final InputStream i } + /** * Writes a ciphertext header to the output stream. * diff --git a/src/test/java/org/cryptacular/CiphertextHeaderTest.java b/src/test/java/org/cryptacular/CiphertextHeaderTest.java new file mode 100644 index 0000000..51abfae --- /dev/null +++ b/src/test/java/org/cryptacular/CiphertextHeaderTest.java @@ -0,0 +1,55 @@ +/* See LICENSE for licensing and NOTICE for copyright. */ +package org.cryptacular; + +import java.util.Arrays; +import org.cryptacular.util.CodecUtil; +import org.testng.annotations.Test; +import static org.testng.Assert.assertEquals; + +/** + * Unit test for {@link CiphertextHeader}. + * + * @author Middleware Services + */ +public class CiphertextHeaderTest +{ + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Nonce exceeds size limit in bytes.*") + public void testNonceLimitConstructor() + { + new CiphertextHeader(new byte[256], "key2"); + } + + @Test + public void testEncodeDecodeSuccess() + { + final byte[] nonce = new byte[255]; + Arrays.fill(nonce, (byte) 7); + final CiphertextHeader expected = new CiphertextHeader(nonce, "aleph"); + final byte[] encoded = expected.encode(); + assertEquals(expected.getLength(), encoded.length); + final CiphertextHeader actual = CiphertextHeader.decode(encoded); + assertEquals(expected.getNonce(), actual.getNonce()); + assertEquals(expected.getKeyName(), actual.getKeyName()); + assertEquals(expected.getLength(), actual.getLength()); + } + + @Test( + expectedExceptions = EncodingException.class, + expectedExceptionsMessageRegExp = "Bad ciphertext header: maximum nonce length exceeded") + public void testDecodeFailNonceLengthExceeded() + { + // https://github.com/vt-middleware/cryptacular/issues/52 + CiphertextHeader.decode(CodecUtil.hex("000000347ffffffd")); + } + + @Test( + expectedExceptions = EncodingException.class, + expectedExceptionsMessageRegExp = "Bad ciphertext header: maximum key length exceeded") + public void testDecodeFailKeyLengthExceeded() + { + CiphertextHeader.decode(CodecUtil.hex("000000F300000004DEADBEEF00FFFFFF")); + } +} diff --git a/src/test/java/org/cryptacular/CiphertextHeaderV2Test.java b/src/test/java/org/cryptacular/CiphertextHeaderV2Test.java new file mode 100644 index 0000000..7313d35 --- /dev/null +++ b/src/test/java/org/cryptacular/CiphertextHeaderV2Test.java @@ -0,0 +1,67 @@ +/* See LICENSE for licensing and NOTICE for copyright. */ +package org.cryptacular; + +import java.util.Arrays; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.cryptacular.generator.sp80038a.RBGNonce; +import org.testng.annotations.Test; +import static org.testng.Assert.assertEquals; + +/** + * Unit test for {@link CiphertextHeaderV2}. + * + * @author Middleware Services + */ +public class CiphertextHeaderV2Test +{ + /** Test HMAC key. */ + private final SecretKey key = new SecretKeySpec(new RBGNonce().generate(), "AES"); + + @Test( + expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Nonce exceeds size limit in bytes.*") + public void testNonceLimitConstructor() + { + new CiphertextHeaderV2(new byte[256], "key2"); + } + + @Test + public void testEncodeDecodeSuccess() + { + final byte[] nonce = new byte[255]; + Arrays.fill(nonce, (byte) 7); + final CiphertextHeaderV2 expected = new CiphertextHeaderV2(nonce, "aleph"); + expected.setKeyLookup(this::getKey); + final byte[] encoded = expected.encode(); + assertEquals(expected.getLength(), encoded.length); + final CiphertextHeaderV2 actual = CiphertextHeaderV2.decode(encoded, this::getKey); + assertEquals(expected.getNonce(), actual.getNonce()); + assertEquals(expected.getKeyName(), actual.getKeyName()); + assertEquals(expected.getLength(), actual.getLength()); + } + + @Test( + expectedExceptions = EncodingException.class, + expectedExceptionsMessageRegExp = "Ciphertext header HMAC verification failed") + public void testEncodeDecodeFailBadHMAC() + { + final byte[] nonce = new byte[16]; + Arrays.fill(nonce, (byte) 3); + final CiphertextHeaderV2 expected = new CiphertextHeaderV2(nonce, "aleph"); + // Tamper with computed HMAC + final byte[] encoded = expected.encode(key); + final int index = encoded.length - 3; + final byte b = encoded[index]; + encoded[index] = (byte) (b + 1); + CiphertextHeaderV2.decode(encoded, this::getKey); + } + + private SecretKey getKey(final String alias) + { + if ("aleph".equals(alias)) { + return key; + } + return null; + } +} diff --git a/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java b/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java index fde2c97..a26f341 100644 --- a/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java +++ b/src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java @@ -5,13 +5,12 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.security.KeyStore; -import javax.crypto.SecretKey; - import org.cryptacular.FailListener; import org.cryptacular.generator.sp80038d.CounterNonce; import org.cryptacular.io.FileResource; import org.cryptacular.spec.AEADBlockCipherSpec; import org.cryptacular.util.ByteUtil; +import org.cryptacular.util.CodecUtil; import org.cryptacular.util.StreamUtil; import org.testng.annotations.DataProvider; import org.testng.annotations.Listeners; @@ -25,6 +25,7 @@ @Listeners(FailListener.class) public class AEADBlockCipherBeanTest { + @DataProvider(name = "test-arrays") public Object[][] getTestArrays() { @@ -78,14 +79,7 @@ public void testEncryptDecryptArray(final String input, final String cipherSpecString) throws Exception { - final AEADBlockCipherBean cipherBean = new AEADBlockCipherBean(); - final AEADBlockCipherSpec cipherSpec = AEADBlockCipherSpec.parse(cipherSpecString); - cipherBean.setNonce(new CounterNonce("vtmw", System.nanoTime())); - cipherBean.setKeyAlias("vtcrypt"); - cipherBean.setKeyPassword("vtcrypt"); - cipherBean.setKeyStore(getTestKeyStore()); - cipherBean.setBlockCipherSpec(cipherSpec); - + final AEADBlockCipherBean cipherBean = newCipherBean(AEADBlockCipherSpec.parse(cipherSpecString)); final byte[] ciphertext = cipherBean.encrypt(ByteUtil.toBytes(input)); assertEquals(ByteUtil.toString(cipherBean.decrypt(ciphertext)), input); } @@ -95,14 +89,7 @@ public void testEncryptDecryptArray(final String input, final String cipherSpecS public void testEncryptDecryptStream(final String path, final String cipherSpecString) throws Exception { - final AEADBlockCipherBean cipherBean = new AEADBlockCipherBean(); - final AEADBlockCipherSpec cipherSpec = AEADBlockCipherSpec.parse(cipherSpecString); - cipherBean.setNonce(new CounterNonce("vtmw", System.nanoTime())); - cipherBean.setKeyAlias("vtcrypt"); - cipherBean.setKeyPassword("vtcrypt"); - cipherBean.setKeyStore(getTestKeyStore()); - cipherBean.setBlockCipherSpec(cipherSpec); - + final AEADBlockCipherBean cipherBean = newCipherBean(AEADBlockCipherSpec.parse(cipherSpecString)); final ByteArrayOutputStream tempOut = new ByteArrayOutputStream(8192); cipherBean.encrypt(StreamUtil.makeStream(new File(path)), tempOut); @@ -113,6 +100,34 @@ public void testEncryptDecryptStream(final String path, final String cipherSpecS } + @Test + public void testDecryptArrayBackwardCompatibleHeader() + { + final AEADBlockCipherBean cipherBean = newCipherBean(new AEADBlockCipherSpec("Twofish", "OCB")); + final String expected = "Have you passed through this night?"; + final String v1CiphertextHex = + "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" + + "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6"; + final byte[] plaintext = cipherBean.decrypt(CodecUtil.hex(v1CiphertextHex)); + assertEquals(expected, ByteUtil.toString(plaintext)); + } + + + @Test + public void testDecryptStreamBackwardCompatibleHeader() + { + final AEADBlockCipherBean cipherBean = newCipherBean(new AEADBlockCipherSpec("Twofish", "OCB")); + final String expected = "Have you passed through this night?"; + final String v1CiphertextHex = + "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" + + "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6"; + final ByteArrayInputStream in = new ByteArrayInputStream(CodecUtil.hex(v1CiphertextHex)); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + cipherBean.decrypt(in, out); + assertEquals(expected, ByteUtil.toString(out.toByteArray())); + } + + private static KeyStore getTestKeyStore() { final KeyStoreFactoryBean bean = new KeyStoreFactoryBean(); @@ -122,12 +137,15 @@ private static KeyStore getTestKeyStore() return bean.newInstance(); } - private static SecretKey getTestKey() + + private static AEADBlockCipherBean newCipherBean(final AEADBlockCipherSpec cipherSpec) { - final KeyStoreBasedKeyFactoryBean secretKeyFactoryBean = new KeyStoreBasedKeyFactoryBean<>(); - secretKeyFactoryBean.setKeyStore(getTestKeyStore()); - secretKeyFactoryBean.setPassword("vtcrypt"); - secretKeyFactoryBean.setAlias("vtcrypt"); - return secretKeyFactoryBean.newInstance(); + final AEADBlockCipherBean cipherBean = new AEADBlockCipherBean(); + cipherBean.setNonce(new CounterNonce("vtmw", System.nanoTime())); + cipherBean.setKeyAlias("vtcrypt"); + cipherBean.setKeyPassword("vtcrypt"); + cipherBean.setKeyStore(getTestKeyStore()); + cipherBean.setBlockCipherSpec(cipherSpec); + return cipherBean; } } diff --git a/src/test/java/org/cryptacular/util/CipherUtilTest.java b/src/test/java/org/cryptacular/util/CipherUtilTest.java index f2cd7de..886ce54 100644 --- a/src/test/java/org/cryptacular/util/CipherUtilTest.java +++ b/src/test/java/org/cryptacular/util/CipherUtilTest.java @@ -17,11 +17,14 @@ import org.bouncycastle.crypto.modes.OCBBlockCipher; import org.bouncycastle.crypto.modes.OFBBlockCipher; import org.cryptacular.FailListener; +import org.cryptacular.bean.KeyStoreBasedKeyFactoryBean; +import org.cryptacular.bean.KeyStoreFactoryBean; import org.cryptacular.generator.Nonce; import org.cryptacular.generator.SecretKeyGenerator; import org.cryptacular.generator.sp80038a.LongCounterNonce; import org.cryptacular.generator.sp80038a.RBGNonce; import org.cryptacular.generator.sp80038d.CounterNonce; +import org.cryptacular.io.FileResource; import org.testng.annotations.DataProvider; import org.testng.annotations.Listeners; import org.testng.annotations.Test; @@ -35,6 +38,22 @@ @Listeners(FailListener.class) public class CipherUtilTest { + /** Static key derived from keystore on resource classpath. */ + private static final SecretKey STATIC_KEY; + + static + { + final KeyStoreFactoryBean keyStoreFactory = new KeyStoreFactoryBean(); + keyStoreFactory.setPassword("vtcrypt"); + keyStoreFactory.setResource(new FileResource(new File("src/test/resources/keystores/cipher-bean.jceks"))); + keyStoreFactory.setType("JCEKS"); + final KeyStoreBasedKeyFactoryBean keyFactory = new KeyStoreBasedKeyFactoryBean<>(); + keyFactory.setKeyStore(keyStoreFactory.newInstance()); + keyFactory.setAlias("vtcrypt"); + keyFactory.setPassword("vtcrypt"); + STATIC_KEY = keyFactory.newInstance(); + } + @DataProvider(name = "block-cipher") public Object[][] getBlockCipherData() { @@ -165,4 +184,32 @@ public void testAeadBlockCipherEncryptDecryptStream(final String path) CipherUtil.decrypt(cipher, key, tempIn, actual); assertEquals(new String(actual.toByteArray()), expected); } + + + @Test + public void testDecryptArrayBackwardCompatibleHeader() + { + final AEADBlockCipher cipher = new OCBBlockCipher(new TwofishEngine(), new TwofishEngine()); + final String expected = "Have you passed through this night?"; + final String v1CiphertextHex = + "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" + + "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6"; + final byte[] plaintext = CipherUtil.decrypt(cipher, STATIC_KEY, CodecUtil.hex(v1CiphertextHex)); + assertEquals(expected, ByteUtil.toString(plaintext)); + } + + + @Test + public void testDecryptStreamBackwardCompatibleHeader() + { + final AEADBlockCipher cipher = new OCBBlockCipher(new TwofishEngine(), new TwofishEngine()); + final String expected = "Have you passed through this night?"; + final String v1CiphertextHex = + "0000001f0000000c76746d770002ba17043672d900000007767463727970745a38dee735266e3f5f7aafec8d1c9ed8a0830a2ff9" + + "c3a46c25f89e69b6eb39dbb82fd13da50e32b2544a73f1a4476677b377e6"; + final ByteArrayInputStream in = new ByteArrayInputStream(CodecUtil.hex(v1CiphertextHex)); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + CipherUtil.decrypt(cipher, STATIC_KEY, in, out); + assertEquals(expected, ByteUtil.toString(out.toByteArray())); + } }