From c19e3f0fa12f5f4bcefc4dbe9c439aaaefcbbeba Mon Sep 17 00:00:00 2001 From: Chris Conlon Date: Thu, 30 Jul 2026 15:07:17 -0600 Subject: [PATCH] F-5033: grow update() buffer by doubling in WolfCryptCipher --- .../wolfssl/provider/jce/WolfCryptCipher.java | 135 ++++++++++--- .../jce/test/WolfCryptCipherTest.java | 188 ++++++++++++++++++ 2 files changed, 295 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/wolfssl/provider/jce/WolfCryptCipher.java b/src/main/java/com/wolfssl/provider/jce/WolfCryptCipher.java index 0817e11d..ad5d6e49 100644 --- a/src/main/java/com/wolfssl/provider/jce/WolfCryptCipher.java +++ b/src/main/java/com/wolfssl/provider/jce/WolfCryptCipher.java @@ -174,8 +174,93 @@ public class WolfCryptCipher extends CipherSpi { /* Has this Cipher been inintialized? */ private boolean cipherInitialized = false; - /* buffered data from update calls */ + /* Buffered data from update calls, only the first bufferedLen bytes + * are valid. Capacity grows to max(2 * capacity, needed). */ private byte[] buffered = new byte[0]; + private int bufferedLen = 0; + + /* Max buffered size. Below Integer.MAX_VALUE, which is not allocatable + * in full because VMs reserve array header words. */ + private static final int MAX_BUFFERED_SIZE = Integer.MAX_VALUE - 8; + + /* Capacity above this is released on reset instead of kept, so one + * large message does not pin memory for the life of the Cipher. */ + private static final int MAX_RETAINED_SIZE = 64 * 1024; + + /** + * Append len bytes of src to the buffered data. + * + * Any buffer replaced during growth is zeroized before being dropped. + * + * @param src array to append from + * @param offset offset into src to start at + * @param len number of bytes to append + * + * @throws IllegalArgumentException if the total buffered size would + * exceed MAX_BUFFERED_SIZE + */ + private void bufferedAppend(byte[] src, int offset, int len) { + + int newCap, needed; + + if (len > (MAX_BUFFERED_SIZE - this.bufferedLen)) { + throw new IllegalArgumentException( + "Buffered input would exceed maximum size of " + + MAX_BUFFERED_SIZE + " bytes"); + } + needed = this.bufferedLen + len; + + if (needed > this.buffered.length) { + newCap = this.buffered.length * 2; + if (newCap < 0 || newCap > MAX_BUFFERED_SIZE) { + /* Doubling overflowed or passed the cap, pin to max */ + newCap = MAX_BUFFERED_SIZE; + } + if (newCap < needed) { + /* Single append larger than double, cap at needed */ + newCap = needed; + } + + byte[] tmp = new byte[newCap]; + System.arraycopy(this.buffered, 0, tmp, 0, this.bufferedLen); + Arrays.fill(this.buffered, 0, this.bufferedLen, (byte)0); + this.buffered = tmp; + } + + System.arraycopy(src, offset, this.buffered, this.bufferedLen, len); + this.bufferedLen = needed; + } + + /** + * Drop and zeroize all buffered data. + * + * Bytes at or past bufferedLen are always zero already, so only the + * used prefix is cleared. Capacity is kept for reuse unless it is + * above MAX_RETAINED_SIZE, which is released instead. + */ + private void bufferedReset() { + + Arrays.fill(this.buffered, 0, this.bufferedLen, (byte)0); + this.bufferedLen = 0; + + if (this.buffered.length > MAX_RETAINED_SIZE) { + this.buffered = new byte[0]; + } + } + + /** + * Drop the first count bytes, shifting the remainder to the front. + * + * @param count bytes to drop, must be between 0 and bufferedLen + */ + private void bufferedConsume(int count) { + + int remaining = this.bufferedLen - count; + + System.arraycopy(this.buffered, count, this.buffered, 0, remaining); + Arrays.fill(this.buffered, remaining, this.bufferedLen, (byte)0); + this.bufferedLen = remaining; + } private WolfCryptCipher(CipherType type, CipherMode mode, PaddingType pad) { @@ -609,8 +694,8 @@ public class WolfCryptCipher extends CipherSpi { /* Add buffered data size to input length, calculate total blocks */ if (isBlockCipher()) { - if (buffered != null && buffered.length > 0) { - totalSz = inputLen + buffered.length; + if (bufferedLen > 0) { + totalSz = inputLen + bufferedLen; } else { totalSz = inputLen; } @@ -654,8 +739,8 @@ public class WolfCryptCipher extends CipherSpi { } else if (paddingType == PaddingType.WC_PKCS5) { outSize = inputLen; - if (buffered != null && buffered.length > 0) { - outSize += buffered.length; + if (bufferedLen > 0) { + outSize += bufferedLen; } /* Only add padding size when encrypting. When decrypting, * the output size should not include padding bytes since @@ -679,8 +764,8 @@ public class WolfCryptCipher extends CipherSpi { } else if (paddingType == PaddingType.WC_PKCS5) { outSize = inputLen; - if (buffered != null && buffered.length > 0) { - outSize += buffered.length; + if (bufferedLen > 0) { + outSize += bufferedLen; } /* Only add padding size when encrypting. When decrypting, * the output size should not include padding bytes since @@ -1203,7 +1288,7 @@ public class WolfCryptCipher extends CipherSpi { throws InvalidKeyException, InvalidAlgorithmParameterException { /* Reset buffered data from any previous operation */ - buffered = new byte[0]; + bufferedReset(); InitializeNativeStructs(); wolfCryptSetDirection(opmode); @@ -1340,7 +1425,6 @@ public class WolfCryptCipher extends CipherSpi { int bytesToProcess = 0; byte[] output = null; byte[] tmpIn = null; - byte[] tmpBuf = null; if (input == null || len < 0 || inputOffset < 0) { throw new IllegalArgumentException( @@ -1363,34 +1447,31 @@ public class WolfCryptCipher extends CipherSpi { this.operationStarted = true; - if ((buffered.length + len) == 0) { + if ((bufferedLen + len) == 0) { /* no data to process */ return null; } if (len > 0) { /* add input bytes to buffered */ - tmpIn = new byte[buffered.length + len]; - System.arraycopy(buffered, 0, tmpIn, 0, buffered.length); - System.arraycopy(input, inputOffset, tmpIn, buffered.length, len); - buffered = tmpIn; + bufferedAppend(input, inputOffset, len); } /* Some algos/modes keep data buffered until the doFinal() call, like * RSA or AES-GCM/CCM without stream mode compiled natively. Just * return an empty byte array in those cases here. */ - if (isNoOpUpdate(len + buffered.length)) { + if (isNoOpUpdate(bufferedLen)) { return new byte[0]; } /* Calculate blocks and partial non-block size remaining */ - blocks = buffered.length / blockSize; + blocks = bufferedLen / blockSize; bytesToProcess = blocks * blockSize; /* CTR and OFB are stream ciphers, process all available data */ if (cipherMode == CipherMode.WC_CTR || cipherMode == CipherMode.WC_OFB) { - bytesToProcess = buffered.length; + bytesToProcess = bufferedLen; } /* If PKCS#5/7 padding, and decrypting, hold on to last block for @@ -1409,10 +1490,8 @@ public class WolfCryptCipher extends CipherSpi { tmpIn = new byte[bytesToProcess]; System.arraycopy(buffered, 0, tmpIn, 0, bytesToProcess); - /* buffer remaining non-block size input, or reset */ - tmpBuf = new byte[buffered.length - bytesToProcess]; - System.arraycopy(buffered, bytesToProcess, tmpBuf, 0, tmpBuf.length); - buffered = tmpBuf; + /* keep remaining non-block size input buffered */ + bufferedConsume(bytesToProcess); /* process tmpIn[] */ switch (this.cipherType) { @@ -1499,7 +1578,7 @@ public class WolfCryptCipher extends CipherSpi { byte tmpOut[] = null; this.operationStarted = true; - totalSz = buffered.length + len; + totalSz = bufferedLen + len; /* AES-CTS requires input length >= 16 bytes (RFC 3962/8009). * For exactly 16 bytes, CTS reduces to plain CBC, handled in JNI. */ @@ -1523,16 +1602,16 @@ public class WolfCryptCipher extends CipherSpi { (totalSz % blockSize != 0)) { throw new IllegalBlockSizeException( "Input length (" + totalSz + ") not multiple of " + - blockSize + " bytes. (" + buffered.length +" buffered)"); + blockSize + " bytes. (" + bufferedLen +" buffered)"); } /* do final encrypt over totalSz */ tmpIn = new byte[totalSz]; if (totalSz > 0) { - System.arraycopy(buffered, 0, tmpIn, 0, buffered.length); + System.arraycopy(buffered, 0, tmpIn, 0, bufferedLen); if (input != null && len > 0) { System.arraycopy(input, inputOffset, tmpIn, - buffered.length, len); + bufferedLen, len); } } @@ -1779,7 +1858,7 @@ public class WolfCryptCipher extends CipherSpi { /* reset state, user doesn't need to call init again before use */ try { - buffered = new byte[0]; + bufferedReset(); wolfCryptSetDirection(this.storedOpMode); @@ -1943,7 +2022,7 @@ public class WolfCryptCipher extends CipherSpi { } log("final (offset: " + inputOffset + ", len: " + inputLen + - ", buffered: " + buffered.length + ")"); + ", buffered: " + bufferedLen + ")"); return wolfCryptFinal(input, inputOffset, inputLen); } @@ -1963,7 +2042,7 @@ public class WolfCryptCipher extends CipherSpi { log("final (inputOffset: " + inputOffset + ", inputLen: " + inputLen + ", outputOffset: " + outputOffset + ", buffered: " + - buffered.length + ")"); + bufferedLen + ")"); if (output == null || (outputOffset > output.length)) { throw new IllegalArgumentException( diff --git a/src/test/java/com/wolfssl/provider/jce/test/WolfCryptCipherTest.java b/src/test/java/com/wolfssl/provider/jce/test/WolfCryptCipherTest.java index 62550a74..bce86cc0 100644 --- a/src/test/java/com/wolfssl/provider/jce/test/WolfCryptCipherTest.java +++ b/src/test/java/com/wolfssl/provider/jce/test/WolfCryptCipherTest.java @@ -29,6 +29,7 @@ import org.junit.runner.Description; import org.junit.Test; import org.junit.BeforeClass; +import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.Random; @@ -2304,6 +2305,193 @@ public class WolfCryptCipherTest { } } + /* + * Test that many small update() calls match a single call. GCM buffers + * everything until doFinal(), so this drives buffer growth only. + */ + @Test + public void testAesGcmChunkedUpdateMatchesSingle() throws Exception { + + if (!enabledJCEAlgos.contains("AES/GCM/NoPadding") || + !FeatureDetect.Aes256Enabled()) { + /* skip if AES-256-GCM is not enabled */ + return; + } + + byte[] keyBytes = new byte[32]; + byte[] iv = new byte[12]; + byte[] plaintext = new byte[4096]; + + for (int i = 0; i < keyBytes.length; i++) { + keyBytes[i] = (byte)i; + } + for (int i = 0; i < iv.length; i++) { + iv[i] = (byte)(0xB0 + i); + } + for (int i = 0; i < plaintext.length; i++) { + plaintext[i] = (byte)(i & 0xFF); + } + + SecretKeySpec key = new SecretKeySpec(keyBytes, "AES"); + GCMParameterSpec spec = new GCMParameterSpec(128, iv); + + Cipher single = Cipher.getInstance("AES/GCM/NoPadding", jceProvider); + single.init(Cipher.ENCRYPT_MODE, key, spec); + byte[] expected = single.doFinal(plaintext); + + /* Same plaintext, sent one byte per update() call */ + Cipher chunked = Cipher.getInstance("AES/GCM/NoPadding", jceProvider); + chunked.init(Cipher.ENCRYPT_MODE, key, spec); + for (int i = 0; i < plaintext.length; i++) { + chunked.update(plaintext, i, 1); + } + + assertArrayEquals("Chunked update should match single doFinal", + expected, chunked.doFinal()); + } + + /* + * Test reusing a Cipher after a message large enough that the internal + * buffer capacity is released rather than kept. Drives the release and + * regrow path, where a stale buffered length would corrupt the next + * message. + */ + @Test + public void testAesGcmLargeThenSmallReusesCipher() throws Exception { + + if (!enabledJCEAlgos.contains("AES/GCM/NoPadding") || + !FeatureDetect.Aes256Enabled()) { + /* skip if AES-256-GCM is not enabled */ + return; + } + + /* Larger than the buffer capacity kept across operations */ + byte[] large = new byte[256 * 1024]; + byte[] small = new byte[64]; + new Random(1234).nextBytes(large); + new Random(5678).nextBytes(small); + + SecretKeySpec key = new SecretKeySpec(new byte[32], "AES"); + byte[] iv = new byte[12]; + + Cipher c = Cipher.getInstance("AES/GCM/NoPadding", jceProvider); + + /* Large message fed in chunks, so capacity grows well past the + * retention limit */ + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + for (int i = 0; i < large.length; i += 4096) { + c.update(large, i, Math.min(4096, large.length - i)); + } + byte[] largeCipher = c.doFinal(); + + /* Same instance, smaller message with a different IV */ + iv[0] = (byte)0x01; + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + byte[] smallCipher = c.doFinal(small); + + /* Both must match a fresh Cipher doing the same work */ + Cipher refLarge = Cipher.getInstance("AES/GCM/NoPadding", jceProvider); + byte[] iv0 = new byte[12]; + refLarge.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv0)); + assertArrayEquals("Large message should match fresh Cipher", + refLarge.doFinal(large), largeCipher); + + Cipher refSmall = Cipher.getInstance("AES/GCM/NoPadding", jceProvider); + refSmall.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + assertArrayEquals("Reused Cipher should match fresh Cipher", + refSmall.doFinal(small), smallCipher); + + /* Round trip the reused instance output */ + Cipher dec = Cipher.getInstance("AES/GCM/NoPadding", jceProvider); + dec.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv)); + assertArrayEquals("Reused Cipher output should decrypt", + small, dec.doFinal(smallCipher)); + } + + /* + * Test CBC round trips with non block aligned update() sizes. Unlike + * GCM, CBC drains the buffer each call, so this drives the grow then + * partial consume path that shifts leftover bytes to the front. + */ + @Test + public void testAesCbcUnalignedChunkedUpdateRoundTrip() + throws Exception { + + if (!enabledJCEAlgos.contains("AES/CBC/NoPadding") || + !enabledJCEAlgos.contains("AES/CBC/PKCS5Padding")) { + /* skip if AES-CBC is not enabled */ + return; + } + + /* Sizes that leave a partial block buffered across most calls */ + int[] chunkSizes = { 1, 3, 7, 13, 31, 127, 5, 999, 17 }; + int[] inputSizes = { 4096, 64 * 1024, 300 * 1024 }; + String[] transforms = + { "AES/CBC/NoPadding", "AES/CBC/PKCS5Padding" }; + + SecretKeySpec key = new SecretKeySpec(new byte[16], "AES"); + IvParameterSpec iv = new IvParameterSpec(new byte[16]); + + for (String transform : transforms) { + for (int size : inputSizes) { + + byte[] plaintext = new byte[size]; + new Random(size).nextBytes(plaintext); + + Cipher enc = Cipher.getInstance(transform, jceProvider); + enc.init(Cipher.ENCRYPT_MODE, key, iv); + byte[] expected = enc.doFinal(plaintext); + + /* Encrypt again, feeding rotating unaligned chunk sizes */ + enc.init(Cipher.ENCRYPT_MODE, key, iv); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + int offset = 0; + int idx = 0; + while (offset < plaintext.length) { + int n = chunkSizes[idx % chunkSizes.length]; + idx++; + if (n > (plaintext.length - offset)) { + n = plaintext.length - offset; + } + byte[] part = enc.update(plaintext, offset, n); + if (part != null) { + out.write(part); + } + offset += n; + } + out.write(enc.doFinal()); + byte[] chunkedCipher = out.toByteArray(); + + assertArrayEquals(transform + " chunked encrypt at " + size + + " should match single doFinal", expected, chunkedCipher); + + /* Decrypt the same way and confirm the round trip */ + Cipher dec = Cipher.getInstance(transform, jceProvider); + dec.init(Cipher.DECRYPT_MODE, key, iv); + ByteArrayOutputStream plain = new ByteArrayOutputStream(); + offset = 0; + idx = 0; + while (offset < chunkedCipher.length) { + int n = chunkSizes[idx % chunkSizes.length]; + idx++; + if (n > (chunkedCipher.length - offset)) { + n = chunkedCipher.length - offset; + } + byte[] part = dec.update(chunkedCipher, offset, n); + if (part != null) { + plain.write(part); + } + offset += n; + } + plain.write(dec.doFinal()); + + assertArrayEquals(transform + " chunked decrypt at " + size + + " should recover plaintext", plaintext, + plain.toByteArray()); + } + } + } + /* * Test that AAD supplied over many updateAAD() calls produces the same * result as a single call. Guards the incremental AAD accumulator.