CVE-2020-7226
Description
CiphertextHeader.java in Cryptacular 1.2.3, as used in Apereo CAS and other products, allows attackers to trigger excessive memory allocation during a decode operation, because the nonce array length associated with "new byte" may depend on untrusted input within the header of encoded data.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Cryptacular 1.2.3 allows excessive memory allocation during decode due to untrusted nonce length in ciphertext headers.
Vulnerability
Description
CVE-2020-7226 is a denial-of-service vulnerability in the CiphertextHeader.java class of the Cryptacular library version 1.2.3, which is used by Apereo CAS and other applications. The root cause is that the decode method allocates an array for a nonce using new byte[nonceLength], where nonceLength is derived directly from untrusted input within the header of encoded data. The official description notes that the nonce array length "may depend on untrusted input within the header of encoded data," allowing an attacker to specify an arbitrarily large value [1].
Exploitation
Mechanism
The attack surface is the decoding of arbitrary ciphertext headers supplied by an attacker. No authentication is required, and exploitation can occur over the network if an application accepts and processes encrypted data. The commit history shows that the original code lacked bounds checks on the nonce length [2][3]. A fix introduced maximum size constants (MAX_NONCE_LEN = 255 bytes and MAX_KEYNAME_LEN = 500 bytes) and validates these lengths in the constructor before allocation [2][3]. The CiphertextHeader class was also deprecated in favor of a new CiphertextHeaderV2 class [2][3].
Impact
An attacker can trigger excessive memory allocation during a decode operation, potentially exhausting available heap memory and causing the JVM to crash or become unresponsive. This constitutes a denial-of-service (DoS) condition. The vulnerability is rated with a CVSS v3.1 base score of 5.9 (Medium), reflecting a high availability impact but requiring no privileges and low attack complexity [1].
Mitigation
The vulnerability was patched in Cryptacular by introducing input validation limits in commit 311baf1 and a backport commit ec2fb65 [2][3]. Apereo CAS also updated its dependencies to include the fix in commit 93b1c3e [4]. Users should upgrade to a patched version of Cryptacular (1.2.4 or later) or apply the workaround by ensuring only trusted sources provide encrypted data for decoding.
References
[1] NVD - CVE-2020-7226 [2] GitHub - vt-middleware/cryptacular@311baf1 [3] GitHub - vt-middleware/cryptacular@ec2fb65 [4] GitHub - apereo/cas@93b1c3e
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.cryptacular:cryptacularMaven | < 1.1.4 | 1.1.4 |
org.cryptacular:cryptacularMaven | >= 1.2.0, < 1.2.4 | 1.2.4 |
Affected products
2- Range: =1.2.3
Patches
5ec2fb65f2455Merge pull request #56 from aldaris/cve-backport
11 files changed · +782 −74
src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java+5 −5 modified@@ -46,25 +46,25 @@ 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); inOff = 0; 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); @@ -86,7 +86,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,
src/main/java/org/cryptacular/bean/AbstractCipherBean.java+32 −13 modified@@ -8,14 +8,17 @@ 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.KeyLookup; 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 +131,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,23 +151,15 @@ 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, new KeyStoreKeyLookup()), false, input); } @Override 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, new KeyStoreKeyLookup()), false, input, output); } @@ -211,4 +206,28 @@ 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(new KeyStoreKeyLookup()); + return header; + } + + /** + * Default {@link KeyLookup} implementation that looks up the keys by name in the key store. + */ + private class KeyStoreKeyLookup implements KeyLookup + { + + @Override + public SecretKey lookupKey(final String keyName) + { + return AbstractCipherBean.this.lookupKey(keyName); + } + } }
src/main/java/org/cryptacular/CiphertextHeader.java+52 −13 modified@@ -34,18 +34,26 @@ * decrypt outstanding data which will be subsequently re-encrypted with a new key.</p> * * @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 String keyName; /** Header length in bytes. */ - private int length; + protected 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 > MAX_NONCE_LEN) { + throw new IllegalArgumentException("Nonce exceeds size limit in bytes (" + MAX_NONCE_LEN + ")"); + } 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 (" + MAX_KEYNAME_LEN + ")"); + } } + this.nonce = nonce; + this.keyName = keyName; + length = computeLength(); } /** @@ -127,6 +140,19 @@ public byte[] encode() } + /** + * @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); } + }
src/main/java/org/cryptacular/CiphertextHeaderV2.java+315 −0 added@@ -0,0 +1,315 @@ +/* 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 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. + * + * <p>Data format:</p> + * + * <pre> + +---------+---------+---+----------+-------+------+ + | Version | KeyName | 0 | NonceLen | Nonce | HMAC | + +---------+---------+---+----------+-------+------+ + | | + +--- 4 ---+--- x ---+ 1 +--- 1 ----+-- y --+- 32 -+ + * </pre> + * + * <p>Where fields are defined as follows:</p> + * + * <ul> + * <li>Version - Header version format as a negative number (4-byte integer). Current version is -2.</li> + * <li>KeyName - Symbolic key name encoded as UTF-8 bytes (variable length)</li> + * <li>0 - Null byte signifying the end of the symbolic key name</li> + * <li>NonceLen - Nonce length in bytes (1-byte unsigned integer)</li> + * <li>Nonce - Nonce bytes (variable length)</li> + * <li>HMAC - HMAC-256 over preceding fields (32 bytes)</li> + * </ul> + * + * <p>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.</p> + * + * @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 KeyLookup 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 KeyLookup keyLookup) + { + this.keyLookup = keyLookup; + } + + + @Override + public byte[] encode() + { + final SecretKey key = keyLookup != null ? keyLookup.lookupKey(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) + { + if (hmacKey == null) { + throw new IllegalArgumentException("Secret key cannot be null"); + } + 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); + bb.put(hmac(bb.array(), 0, bb.limit() - HMAC_SIZE)); + 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 KeyLookup keyLookup) + throws EncodingException + { + final ByteBuffer source = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN); + final SecretKey key; + final String keyName; + final byte[] nonce; + final byte[] hmac; + try { + final int version = source.getInt(); + if (version != VERSION) { + throw new EncodingException("Unsupported ciphertext header version"); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(100); + byte b = 0; + int count = 0; + while ((b = source.get()) != 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.lookupKey(keyName); + if (key == null) { + throw new IllegalStateException("Symbolic key name mentioned in header was not found"); + } + final int nonceLen = ByteUtil.toInt(source.get()); + nonce = new byte[nonceLen]; + source.get(nonce); + hmac = new byte[HMAC_SIZE]; + source.get(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; + } + + + /** + * 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 KeyLookup keyLookup) + throws EncodingException, StreamException + { + final SecretKey key; + final String keyName; + final byte[] nonce; + final byte[] hmac; + try { + final int version = ByteUtil.readInt(input); + if (version != VERSION) { + throw new EncodingException("Unsupported ciphertext header version"); + } + final ByteArrayOutputStream out = new ByteArrayOutputStream(100); + byte b = 0; + int count = 0; + while ((b = readByte(input)) != 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.lookupKey(keyName); + if (key == null) { + throw new IllegalStateException("Symbolic key name mentioned in header was not found"); + } + final int nonceLen = ByteUtil.toInt(readByte(input)); + nonce = new byte[nonceLen]; + readInto(input, nonce); + hmac = new byte[HMAC_SIZE]; + readInto(input, 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 <code>output.length</code> bytes from the input stream into the output buffer. + * + * @param input Input stream. + * @param output Output buffer. + * + * @throws StreamException on stream IO errors. + */ + 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; + } +}
src/main/java/org/cryptacular/KeyLookup.java+24 −0 added@@ -0,0 +1,24 @@ +/* See LICENSE for licensing and NOTICE for copyright. */ +package org.cryptacular; + +import javax.crypto.SecretKey; + +/** + * Interface to allow custom implementations of secret key lookups. + * + * @deprecated In newer versions, KeyLookup is replaced by java.util.Function<String, SecretKey> instances. + * + * @author Middleware Services + */ +@Deprecated +public interface KeyLookup +{ + + /** + * Looks up the key with the provided key name. + * + * @param keyName Name of secret key entry. + * @return Secret key. + */ + SecretKey lookupKey(String keyName); +}
src/main/java/org/cryptacular/util/ByteUtil.java+44 −1 modified@@ -31,14 +31,27 @@ 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) { return (data[0] << 24) | ((data[1] & 0xff) << 16) | ((data[2] & 0xff) << 8) | (data[3] & 0xff); } + /** + * 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 byte[] toBytes(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 byte[] toArray(final ByteBuffer buffer) buffer.get(array); return array; } + + }
src/main/java/org/cryptacular/util/CipherUtil.java+95 −19 modified@@ -13,8 +13,10 @@ 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.KeyLookup; import org.cryptacular.StreamException; import org.cryptacular.adapter.AEADBlockCipherAdapter; import org.cryptacular.adapter.BlockCipherAdapter; @@ -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 @@ public static byte[] encrypt(final AEADBlockCipher cipher, final SecretKey key, 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, new StaticKeyLookup(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 byte[] decrypt(final AEADBlockCipher cipher, final SecretKey key, * * @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, new StaticKeyLookup(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 byte[] encrypt(final BlockCipher cipher, final SecretKey key, fina 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, new StaticKeyLookup(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, new StaticKeyLookup(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 KeyLookup 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 KeyLookup 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. * @@ -340,4 +391,29 @@ private static void writeHeader(final byte[] header, final OutputStream output) } } + /** + * This {@link KeyLookup} implementation returns the same key regardless of the key name requested during lookup. + */ + private static class StaticKeyLookup implements KeyLookup + { + + /** The secret key that should be always returned. */ + private final SecretKey key; + + /** + * Creates a new instance with the provided secret key. + * + * @param key The secret key to return for lookups. + */ + StaticKeyLookup(final SecretKey key) + { + this.key = key; + } + + @Override + public SecretKey lookupKey(final String keyName) + { + return key; + } + } }
src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java+41 −23 modified@@ -5,12 +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 Object[][] getTestStreams() 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<SecretKey> 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; } }
src/test/java/org/cryptacular/CiphertextHeaderTest.java+55 −0 added@@ -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")); + } +}
src/test/java/org/cryptacular/CiphertextHeaderV2Test.java+72 −0 added@@ -0,0 +1,72 @@ +/* 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(new TestKeyLookup()); + final byte[] encoded = expected.encode(); + assertEquals(expected.getLength(), encoded.length); + final CiphertextHeaderV2 actual = CiphertextHeaderV2.decode(encoded, new TestKeyLookup()); + 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, new TestKeyLookup()); + } + + private class TestKeyLookup implements KeyLookup + { + + @Override + public SecretKey lookupKey(final String keyName) + { + if ("aleph".equals(keyName)) { + return key; + } + return null; + } + } +}
src/test/java/org/cryptacular/util/CipherUtilTest.java+47 −0 modified@@ -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<SecretKey> 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())); + } }
1 file changed · +1 −1
gradle.properties+1 −1 modified@@ -217,7 +217,7 @@ bouncyCastleVersion=1.60 shibbolethUtilJavaSupport=7.4.0 jdomVersion=1.0 -cryptacularVersion=1.2.2 +cryptacularVersion=1.2.4 grouperVersion=2.3.0
1 file changed · +1 −1
gradle.properties+1 −1 modified@@ -267,7 +267,7 @@ bouncyCastleVersion=1.60 shibbolethUtilJavaSupportVersion=7.4.1 jdomVersion=1.1 -cryptacularVersion=1.2.2 +cryptacularVersion=1.2.4 grouperVersion=2.4.0
8810f2b6c71dUpgrade cryptacular to v1.2.4 (#4685)
1 file changed · +1 −1
gradle.properties+1 −1 modified@@ -275,7 +275,7 @@ bouncyCastleVersion=1.64 shibbolethUtilJavaSupportVersion=7.5.1 jdomVersion=1.1 -cryptacularVersion=1.2.3 +cryptacularVersion=1.2.4 grouperVersion=2.4.0
311baf12252aMerge pull request #53 from serac/52-safe-header
10 files changed · +708 −74
src/main/java/org/cryptacular/bean/AbstractBlockCipherBean.java+5 −5 modified@@ -45,25 +45,25 @@ 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); inOff = 0; 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,
src/main/java/org/cryptacular/bean/AbstractCipherBean.java+18 −13 modified@@ -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,23 +150,15 @@ 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); } @Override 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; + } }
src/main/java/org/cryptacular/CiphertextHeader.java+52 −13 modified@@ -34,18 +34,26 @@ * decrypt outstanding data which will be subsequently re-encrypted with a new key.</p> * * @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 > MAX_NONCE_LEN) { + throw new IllegalArgumentException("Nonce exceeds size limit in bytes (" + MAX_NONCE_LEN + ")"); + } 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 (" + MAX_KEYNAME_LEN + ")"); + } } + this.nonce = nonce; + this.keyName = keyName; + length = computeLength(); } /** @@ -127,6 +140,19 @@ public byte[] encode() } + /** + * @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); } + }
src/main/java/org/cryptacular/CiphertextHeaderV2.java+309 −0 added@@ -0,0 +1,309 @@ +/* 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. + * + * <p>Data format:</p> + * + * <pre> + +---------+---------+---+----------+-------+------+ + | Version | KeyName | 0 | NonceLen | Nonce | HMAC | + +---------+---------+---+----------+-------+------+ + | | + +--- 4 ---+--- x ---+ 1 +--- 1 ----+-- y --+- 32 -+ + * </pre> + * + * <p>Where fields are defined as follows:</p> + * + * <ul> + * <li>Version - Header version format as a negative number (4-byte integer). Current version is -2.</li> + * <li>KeyName - Symbolic key name encoded as UTF-8 bytes (variable length)</li> + * <li>0 - Null byte signifying the end of the symbolic key name</li> + * <li>NonceLen - Nonce length in bytes (1-byte unsigned integer)</li> + * <li>Nonce - Nonce bytes (variable length)</li> + * <li>HMAC - HMAC-256 over preceding fields (32 bytes)</li> + * </ul> + * + * <p>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.</p> + * + * @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<String, SecretKey> 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<String, SecretKey> 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) + { + if (hmacKey == null) { + throw new IllegalArgumentException("Secret key cannot be null"); + } + 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); + bb.put(hmac(bb.array(), 0, bb.limit() - HMAC_SIZE)); + 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<String, SecretKey> 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<String, SecretKey> keyLookup) + throws EncodingException, StreamException + { + return decodeInternal( + input, keyLookup, ByteUtil::readInt, CiphertextHeaderV2::readByte, CiphertextHeaderV2::readInto); + } + + + /** + * Internal header decoding routine. + * + * @param <T> 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 <T> CiphertextHeaderV2 decodeInternal( + final T source, + final Function<String, SecretKey> keyLookup, + final Function<T, Integer> readIntFn, + final Function<T, Byte> readByteFn, + final BiConsumer<T, byte[]> 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 <code>output.length</code> bytes from the input stream into the output buffer. + * + * @param input Input stream. + * @param output Output buffer. + * + * @throws StreamException on stream IO errors. + */ + 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; + } +}
src/main/java/org/cryptacular/util/ByteUtil.java+44 −1 modified@@ -31,14 +31,27 @@ 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) { return (data[0] << 24) | ((data[1] & 0xff) << 16) | ((data[2] & 0xff) << 8) | (data[3] & 0xff); } + /** + * 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 byte[] toBytes(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 byte[] toArray(final ByteBuffer buffer) buffer.get(array); return array; } + + }
src/main/java/org/cryptacular/util/CipherUtil.java+70 −19 modified@@ -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 @@ public static byte[] encrypt(final AEADBlockCipher cipher, final SecretKey key, 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 byte[] decrypt(final AEADBlockCipher cipher, final SecretKey key, * * @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 byte[] encrypt(final BlockCipher cipher, final SecretKey key, fina 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<String, SecretKey> 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<String, SecretKey> 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. *
src/test/java/org/cryptacular/bean/AEADBlockCipherBeanTest.java+41 −23 modified@@ -5,12 +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 Object[][] getTestStreams() 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<SecretKey> 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; } }
src/test/java/org/cryptacular/CiphertextHeaderTest.java+55 −0 added@@ -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")); + } +}
src/test/java/org/cryptacular/CiphertextHeaderV2Test.java+67 −0 added@@ -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; + } +}
src/test/java/org/cryptacular/util/CipherUtilTest.java+47 −0 modified@@ -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<SecretKey> 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())); + } }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
34- github.com/advisories/GHSA-x64g-4xx9-fh6xghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-7226ghsaADVISORY
- github.com/apereo/cas/commit/8810f2b6c71d73341d4dde6b09a18eb46cfd6d45ghsax_refsource_MISCWEB
- github.com/apereo/cas/commit/93b1c3e9d90e36a19d0fa0f6efb863c6f0235e75ghsax_refsource_MISCWEB
- github.com/apereo/cas/commit/a042808d6adbbf44753d52c55cac5f533e24101fghsax_refsource_MISCWEB
- github.com/apereo/cas/pull/4685ghsax_refsource_MISCWEB
- github.com/vt-middleware/cryptacular/blob/fafccd07ab1214e3588a35afe3c361519129605f/src/main/java/org/cryptacular/CiphertextHeader.javaghsax_refsource_MISCWEB
- github.com/vt-middleware/cryptacular/blob/master/src/main/java/org/cryptacular/CiphertextHeader.javaghsax_refsource_MISCWEB
- github.com/vt-middleware/cryptacular/commit/311baf12252abf21947afd07bf0a0291ec3ec796ghsaWEB
- github.com/vt-middleware/cryptacular/commit/ec2fb65f2455c479376695e3d75d30c7f6884b3fghsaWEB
- github.com/vt-middleware/cryptacular/issues/52ghsax_refsource_MISCWEB
- github.com/vt-middleware/cryptacular/pull/56ghsaWEB
- lists.apache.org/thread.html/r0847c7eb78c8f9e87d5b841fbd5da52b2ad4b4345e04b51c30621d88%40%3Ccommits.tomee.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/r0847c7eb78c8f9e87d5b841fbd5da52b2ad4b4345e04b51c30621d88@%3Ccommits.tomee.apache.org%3EghsaWEB
- lists.apache.org/thread.html/r209de85beae4d257d27fc577e3a3e97039bdb4c2dc6f4a8e5a5a5811%40%3Ccommits.tomee.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/r209de85beae4d257d27fc577e3a3e97039bdb4c2dc6f4a8e5a5a5811@%3Ccommits.tomee.apache.org%3EghsaWEB
- lists.apache.org/thread.html/r2237a27040b57adc2fcc5570bd530ad2038e67fcb2a3ce65283d3143%40%3Ccommits.tomee.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/r2237a27040b57adc2fcc5570bd530ad2038e67fcb2a3ce65283d3143@%3Ccommits.tomee.apache.org%3EghsaWEB
- lists.apache.org/thread.html/r380781f5b489cb3c818536cd3b3757e806bfe0bca188591e0051ac03%40%3Ccommits.ws.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/r380781f5b489cb3c818536cd3b3757e806bfe0bca188591e0051ac03@%3Ccommits.ws.apache.org%3EghsaWEB
- lists.apache.org/thread.html/r4a62133ad01d5f963755021027a4cce23f76b8674a13860d2978c7c8%40%3Ccommits.tomee.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/r4a62133ad01d5f963755021027a4cce23f76b8674a13860d2978c7c8@%3Ccommits.tomee.apache.org%3EghsaWEB
- lists.apache.org/thread.html/r77c48cd851f60833df9a9c9c31f12243508e15d1b2a0961066d44fc6%40%3Ccommits.tomee.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/r77c48cd851f60833df9a9c9c31f12243508e15d1b2a0961066d44fc6@%3Ccommits.tomee.apache.org%3EghsaWEB
- lists.apache.org/thread.html/rc36b75cabb4d700b48035d15ad8b8c2712bb32123572a1bdaec2510a%40%3Cdev.ws.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/rc36b75cabb4d700b48035d15ad8b8c2712bb32123572a1bdaec2510a@%3Cdev.ws.apache.org%3EghsaWEB
- lists.apache.org/thread.html/re04e4f8f0d095387fb6b0ff9016a0af8c93f42e1de93b09298bfa547%40%3Ccommits.ws.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/re04e4f8f0d095387fb6b0ff9016a0af8c93f42e1de93b09298bfa547@%3Ccommits.ws.apache.org%3EghsaWEB
- lists.apache.org/thread.html/re7f46c4cc29a4616e0aa669c84a0eb34832e83a8eef05189e2e59b44%40%3Cdev.ws.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/re7f46c4cc29a4616e0aa669c84a0eb34832e83a8eef05189e2e59b44@%3Cdev.ws.apache.org%3EghsaWEB
- lists.apache.org/thread.html/rfa4647c58e375996e62a9094bffff6dc350ec311ba955b430e738945%40%3Cdev.ws.apache.org%3Emitremailing-listx_refsource_MLIST
- lists.apache.org/thread.html/rfa4647c58e375996e62a9094bffff6dc350ec311ba955b430e738945@%3Cdev.ws.apache.org%3EghsaWEB
- www.oracle.com/security-alerts/cpuapr2022.htmlghsax_refsource_MISCWEB
- www.oracle.com/security-alerts/cpuoct2021.htmlghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.