From d0bca9260fe111b3930f3ee3f5259a7f8270db15 Mon Sep 17 00:00:00 2001 From: Winfried Gerlach Date: Thu, 1 Oct 2026 18:53:43 +0200 Subject: [PATCH] #2481 ChaCha20Poly1305 now hands runs of whole blocks to ChaCha and Poly1305 in one call each instead of alternating one 64-byte block at a time --- .../crypto/modes/ChaCha20Poly1305.java | 83 ++++++++++++++++++- .../crypto/test/ChaCha20Poly1305Test.java | 75 +++++++++++++++++ docs/releasenotes.md | 2 + 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/bouncycastle/crypto/modes/ChaCha20Poly1305.java b/core/src/main/java/org/bouncycastle/crypto/modes/ChaCha20Poly1305.java index 78956b013f..0ac8f37d69 100644 --- a/core/src/main/java/org/bouncycastle/crypto/modes/ChaCha20Poly1305.java +++ b/core/src/main/java/org/bouncycastle/crypto/modes/ChaCha20Poly1305.java @@ -346,10 +346,57 @@ public int processBytes(byte[] in, int inOff, int len, byte[] out, int outOff) t { case State.DEC_DATA: { - for (int i = 0; i < len; ++i) + // A block is decrypted once MAC_SIZE more bytes follow it, since until then it may hold the tag. The + // blocks that start in the buffer are completed from 'in' one at a time; after that the input is + // block-aligned and every block the rule allows goes to the cipher as one run. + int pos = inOff, end = inOff + len; + + while (bufPos > 0) + { + int need = BUF_SIZE - bufPos; + if (need > 0) + { + if (end - pos < need + MAC_SIZE) + { + break; + } + System.arraycopy(in, pos, buf, bufPos, need); + pos += need; + bufPos = BUF_SIZE; + } + else if (bufPos - BUF_SIZE + (end - pos) < MAC_SIZE) + { + break; + } + + poly1305.update(buf, 0, BUF_SIZE); + processData(buf, 0, BUF_SIZE, out, outOff + resultLen); + resultLen += BUF_SIZE; + bufPos -= BUF_SIZE; + System.arraycopy(buf, BUF_SIZE, buf, 0, bufPos); + } + + if (bufPos == 0 && end - pos - MAC_SIZE >= BUF_SIZE) { - buf[bufPos] = in[inOff + i]; - if (++bufPos == buf.length) + int run = wholeBlockRun(Math.min(end - pos - MAC_SIZE, out.length - (outOff + resultLen))); + if (run > 0) + { + poly1305.update(in, pos, run); + processData(in, pos, run, out, outOff + resultLen); + pos += run; + resultLen += run; + } + } + + // what remains - the lookahead, a partial block, and any blocks wholeBlockRun held back - goes through + // the buffer, as much at a time as it takes + while (pos < end) + { + int n = Math.min(end - pos, buf.length - bufPos); + System.arraycopy(in, pos, buf, bufPos, n); + pos += n; + bufPos += n; + if (bufPos == buf.length) { poly1305.update(buf, 0, BUF_SIZE); processData(buf, 0, BUF_SIZE, out, outOff + resultLen); @@ -379,6 +426,20 @@ public int processBytes(byte[] in, int inOff, int len, byte[] out, int outOff) t } } + // From two blocks: a single block is cheaper through the loop below + if (len >= 2 * BUF_SIZE) + { + int run = wholeBlockRun(Math.min(len, out.length - (outOff + resultLen))); + if (run > 0) + { + processData(in, inOff, run, out, outOff + resultLen); + poly1305.update(out, outOff + resultLen, run); + inOff += run; + len -= run; + resultLen += run; + } + } + while (len >= BUF_SIZE) { processData(in, inOff, BUF_SIZE, out, outOff + resultLen); @@ -552,6 +613,22 @@ private void finishData(int nextState) this.state = nextState; } + /* + * The length, a multiple of BUF_SIZE and at most maxLen, of a run of whole blocks that may go to the cipher and + * to Poly1305 in one call each - long runs are what let both process whole blocks straight from the arrays + * rather than one 64-byte call at a time. The last block before DATA_LIMIT is left to the one-block path, and + * maxLen is capped by the caller at the room left in the output, so what happens at either limit is unchanged. + */ + private int wholeBlockRun(int maxLen) + { + long headroom = DATA_LIMIT - BUF_SIZE - dataCount; + if (maxLen < BUF_SIZE || headroom < BUF_SIZE) + { + return 0; + } + return (int)Math.min(maxLen, headroom) & -BUF_SIZE; + } + private long incrementCount(long count, int increment, long limit) { if (Longs.compareUnsigned(count, limit - increment) > 0) diff --git a/core/src/test/java/org/bouncycastle/crypto/test/ChaCha20Poly1305Test.java b/core/src/test/java/org/bouncycastle/crypto/test/ChaCha20Poly1305Test.java index b8dc6a5c29..d456681182 100644 --- a/core/src/test/java/org/bouncycastle/crypto/test/ChaCha20Poly1305Test.java +++ b/core/src/test/java/org/bouncycastle/crypto/test/ChaCha20Poly1305Test.java @@ -59,9 +59,84 @@ public void performTest() throws Exception outputSizeTests(); randomTests(); + testPiecewiseDecryption(); testExceptions(); } + /* + * Decryption holds back the last MAC_SIZE bytes it has seen, since they may be the tag, so how the ciphertext is + * split across calls decides what sits in the buffer when the next call arrives. Every split must decrypt as + * one call does. + */ + private void testPiecewiseDecryption() + throws InvalidCipherTextException + { + SecureRandom random = new SecureRandom(); + byte[] K = new byte[32]; + random.nextBytes(K); + byte[] nonce = new byte[12]; + random.nextBytes(nonce); + AEADParameters parameters = new AEADParameters(new KeyParameter(K), 16 * 8, nonce); + + int[] lengths = { 0, 1, 15, 16, 17, 48, 63, 64, 65, 79, 80, 81, 127, 128, 129, 143, 144, 145, 300, 1000 }; + for (int i = 0; i < lengths.length; ++i) + { + byte[] P = new byte[lengths[i]]; + random.nextBytes(P); + + ChaCha20Poly1305 cipher = initCipher(true, parameters); + byte[] C = new byte[cipher.getOutputSize(P.length)]; + int len = cipher.processBytes(P, 0, P.length, C, 0); + cipher.doFinal(C, len); + + // pieces of one size, then pieces of random sizes with single bytes through processByte + for (int piece = 1; piece <= 2 * (64 + 16) + 1; ++piece) + { + checkPiecewiseDecryption(parameters, P, C, random, piece); + } + for (int j = 0; j < 50; ++j) + { + checkPiecewiseDecryption(parameters, P, C, random, 0); + } + } + } + + private void checkPiecewiseDecryption(AEADParameters parameters, byte[] P, byte[] C, SecureRandom random, + int piece) + throws InvalidCipherTextException + { + ChaCha20Poly1305 cipher = initCipher(false, parameters); + byte[] decP = new byte[cipher.getOutputSize(C.length)]; + + int len = 0; + for (int pos = 0; pos < C.length; ) + { + int n = Math.min(C.length - pos, piece > 0 ? piece : random.nextInt(3 * 64)); + int predicted = cipher.getUpdateOutputSize(n); + int written; + if (n == 1 && random.nextBoolean()) + { + written = cipher.processByte(C[pos], decP, len); + } + else + { + written = cipher.processBytes(C, pos, n, decP, len); + } + if (written != predicted) + { + fail("piecewise decryption reported incorrect update length"); + } + pos += n; + len += written; + } + len += cipher.doFinal(decP, len); + + if (len != P.length || !areEqual(P, decP)) + { + fail("incorrect piecewise decrypt"); + } + } + private void checkTestCase( ChaCha20Poly1305 encCipher, ChaCha20Poly1305 decCipher, diff --git a/docs/releasenotes.md b/docs/releasenotes.md index 3983bd59b6..47a01f9895 100644 --- a/docs/releasenotes.md +++ b/docs/releasenotes.md @@ -81,6 +81,8 @@ Date: 2026, TBD - The BCJSSE provider adds an org.bouncycastle.jsse.BCSSLContext interface exposing extended functionality of its SSLContext, obtained with org.bouncycastle.jsse.util.ContextUtil.getBCSSLContext() by way of the new BCSSLSessionContext interface the context's session contexts implement. Its getDefaultParameters(boolean) and getSupportedParameters(boolean) return the context's default and supported parameters as a BCSSLParameters for either client or server mode, including the BC-specific properties, where SSLContext.getDefaultSSLParameters() and getSupportedSSLParameters() report client mode only and cannot carry those properties. A BCSSLContext describes the initialization of the SSLContext it was obtained from, and is not updated if the SSLContext is re-initialized. +- ChaCha20Poly1305 - and with it XChaCha20Poly1305, the provider's ChaCha20-Poly1305 and XChaCha20-Poly1305 ciphers, HPKE and MLS - now hands runs of whole 64-byte blocks to ChaCha and to Poly1305 in one call each, where it used to alternate between them one block at a time and, when decrypting, copy every byte through its buffer. In a JMH comparison on an x86-64 machine decryption of 1 KB to 16 KB ran about 1.2 times as fast; together with the faster Salsa20Engine and Poly1305 block loops (github #2476, #2477), whose gains in this mode depend on it, encryption ran 1.5 times and decryption 1.8 to 2.0 times as fast. The output is unchanged. + ### 2.1.4 Additional Notes - The sources and javadoc jars of the Ant-built distributions (jdk14, jdk15to18 and jdk13) no longer carry test material. Each module's javadoc target copies the package documentation it needs - org/bouncycastle//**/*.html - back into the module source directory that has already been compiled from, and zip-src zips that directory afterwards, so every test package's package.html arrived in the sources jar by that route; javadoc-util additionally copied org/bouncycastle/asn1/isismtt/**/*.java, which put test classes into the bcutil javadoc as generated pages, and javadoc-pg deliberately copied the gpg and bcpg test sources in order to document them. Separately the source copies excluded test material only one directory deep and only for *.java, because Ant reads ** as an any-depth wildcard just where it is a whole path segment, so anything nested further or with another extension - the PEM certificate fixtures under org/bouncycastle/est/test/san corrected in 1.86, and an ICAO master list under org/bouncycastle/asn1/icao/test - went through. The source and javadoc copies of every module now exclude test directories at any depth, and javadoc-pg no longer documents the test packages. org.bouncycastle.util.test is unaffected and still ships in the bcprov binary, sources and javadoc jars, as it does from the Gradle build: it is the SimpleTest framework the light-weight API's own test classes are written against, not test material of the distribution. No binary changes - the classes and resources of every Ant-built jar are identical to those of the 1.86 release - and the Gradle-built jdk18on artifacts never carried any of this.