This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch branch-3.5
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-3.5 by this push:
new b75a07db85e6 [SPARK-56227][3.5][CORE] Fix GcmTransportCipher to
correctly handle multiple messages per channel
b75a07db85e6 is described below
commit b75a07db85e6630725e9bbaa070e08c29906aa80
Author: Akira Ajisaka <[email protected]>
AuthorDate: Fri Jul 10 22:04:32 2026 -0700
[SPARK-56227][3.5][CORE] Fix GcmTransportCipher to correctly handle
multiple messages per channel
### What changes were proposed in this pull request?
Backport #55028 to branch-3.5.
### Why are the changes needed?
To successfully run Spark jobs on YARN with
`spark.network.crypto.cipher="AES/GCM/NoPadding"`
### Does this PR introduce _any_ user-facing change?
No
### How was this patch tested?
Added new regression tests.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.6)
Closes #55621 from aajisaka/fix-rpc-encryption-branch-3.5.
Authored-by: Akira Ajisaka <[email protected]>
Signed-off-by: Holden Karau <[email protected]>
---
.../spark/network/crypto/GcmTransportCipher.java | 276 ++++++++---
.../spark/network/crypto/GcmAuthEngineSuite.java | 522 ++++++++++++++++++++-
2 files changed, 710 insertions(+), 88 deletions(-)
diff --git
a/common/network-common/src/main/java/org/apache/spark/network/crypto/GcmTransportCipher.java
b/common/network-common/src/main/java/org/apache/spark/network/crypto/GcmTransportCipher.java
index d3f1bf490d3a..5f6d6ef50207 100644
---
a/common/network-common/src/main/java/org/apache/spark/network/crypto/GcmTransportCipher.java
+++
b/common/network-common/src/main/java/org/apache/spark/network/crypto/GcmTransportCipher.java
@@ -22,9 +22,11 @@ import com.google.common.base.Preconditions;
import com.google.common.primitives.Longs;
import com.google.crypto.tink.subtle.*;
import io.netty.buffer.ByteBuf;
+import io.netty.buffer.CompositeByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.util.ReferenceCounted;
+
import org.apache.spark.network.util.AbstractFileRegion;
import org.apache.spark.network.util.ByteBufferWriteableChannel;
@@ -41,6 +43,12 @@ public class GcmTransportCipher implements TransportCipher {
private static final int LENGTH_HEADER_BYTES = 8;
@VisibleForTesting
static final int CIPHERTEXT_BUFFER_SIZE = 32 * 1024; // 32KB
+ // Maximum plaintext bytes to accumulate before flushing to downstream
handlers, even
+ // if the GCM message is not yet complete. Bounds on-heap retention for
large shuffle
+ // blocks that route to disk via spark.maxRemoteBlockSizeFetchToMem, while
still
+ // reducing EventLoop callbacks to 1 for the common small-message case (<
1 MB).
+ @VisibleForTesting
+ static final int MAX_PLAINTEXT_BATCH_BYTES = 1024 * 1024; // 1 MB
private final SecretKeySpec aesKey;
public GcmTransportCipher(SecretKeySpec aesKey) {
@@ -82,25 +90,16 @@ public class GcmTransportCipher implements TransportCipher {
@VisibleForTesting
class EncryptionHandler extends ChannelOutboundHandlerAdapter {
- private final ByteBuffer plaintextBuffer;
- private final ByteBuffer ciphertextBuffer;
private final AesGcmHkdfStreaming aesGcmHkdfStreaming;
EncryptionHandler() throws InvalidAlgorithmParameterException {
aesGcmHkdfStreaming = getAesGcmHkdfStreaming();
- plaintextBuffer =
ByteBuffer.allocate(aesGcmHkdfStreaming.getPlaintextSegmentSize());
- ciphertextBuffer =
ByteBuffer.allocate(aesGcmHkdfStreaming.getCiphertextSegmentSize());
}
@Override
public void write(ChannelHandlerContext ctx, Object msg,
ChannelPromise promise)
throws Exception {
- GcmEncryptedMessage encryptedMessage = new GcmEncryptedMessage(
- aesGcmHkdfStreaming,
- msg,
- plaintextBuffer,
- ciphertextBuffer);
- ctx.write(encryptedMessage, promise);
+ ctx.write(new GcmEncryptedMessage(aesGcmHkdfStreaming, msg),
promise);
}
}
@@ -116,22 +115,39 @@ public class GcmTransportCipher implements
TransportCipher {
private final long encryptedCount;
GcmEncryptedMessage(AesGcmHkdfStreaming aesGcmHkdfStreaming,
- Object plaintextMessage,
- ByteBuffer plaintextBuffer,
- ByteBuffer ciphertextBuffer) throws
GeneralSecurityException {
+ Object plaintextMessage) throws
GeneralSecurityException {
Preconditions.checkArgument(
plaintextMessage instanceof ByteBuf || plaintextMessage
instanceof FileRegion,
"Unrecognized message type: %s",
plaintextMessage.getClass().getName());
this.plaintextMessage = plaintextMessage;
- this.plaintextBuffer = plaintextBuffer;
- this.ciphertextBuffer = ciphertextBuffer;
+ this.plaintextBuffer =
+
ByteBuffer.allocate(aesGcmHkdfStreaming.getPlaintextSegmentSize());
+ this.ciphertextBuffer =
+
ByteBuffer.allocate(aesGcmHkdfStreaming.getCiphertextSegmentSize());
// If the ciphertext buffer cannot be fully written the target,
transferTo may
// return with it containing some unwritten data. The initial call
we'll explicitly
// set its limit to 0 to indicate the first call to transferTo.
((Buffer) this.ciphertextBuffer).limit(0);
+
this.bytesToRead = getReadableBytes();
- this.encryptedCount =
- LENGTH_HEADER_BYTES +
aesGcmHkdfStreaming.expectedCiphertextSize(bytesToRead);
+ int plaintextSegmentSize =
aesGcmHkdfStreaming.getPlaintextSegmentSize();
+ int ciphertextSegmentSize =
aesGcmHkdfStreaming.getCiphertextSegmentSize();
+ int tagSize = ciphertextSegmentSize - plaintextSegmentSize;
+ long fullSegments = bytesToRead / plaintextSegmentSize;
+ long partialBytes = bytesToRead % plaintextSegmentSize;
+ // encryptedCount covers all bytes written to the wire:
+ // LENGTH_HEADER_BYTES - Spark's 8-byte big-endian framing
prefix, read by the
+ // receiver to know where this message
ends in the TCP stream.
+ // getHeaderLength() - Tink's streaming header (salt + nonce
prefix), written by
+ // the encrypter before any ciphertext
segments.
+ // segment bytes - full segments each padded with a
16-byte GCM auth tag,
+ // plus any partial final segment
similarly padded.
+ // NOTE: This formula is coupled to Tink's internal layout.
testEncryptedCountBoundary
+ // guards current behavior.
+ this.encryptedCount = LENGTH_HEADER_BYTES
+ + aesGcmHkdfStreaming.getHeaderLength()
+ + fullSegments * ciphertextSegmentSize
+ + (partialBytes > 0 ? partialBytes + tagSize : 0);
byte[] lengthAad = Longs.toByteArray(encryptedCount);
this.encrypter =
aesGcmHkdfStreaming.newStreamSegmentEncrypter(lengthAad);
this.headerByteBuffer = createHeaderByteBuffer();
@@ -297,27 +313,66 @@ public class GcmTransportCipher implements
TransportCipher {
private final ByteBuffer headerBuffer;
private final ByteBuffer ciphertextBuffer;
private final AesGcmHkdfStreaming aesGcmHkdfStreaming;
- private final StreamSegmentDecrypter decrypter;
+ private StreamSegmentDecrypter decrypter;
+ private final int headerLength;
private final int plaintextSegmentSize;
private boolean decrypterInit = false;
private boolean completed = false;
private int segmentNumber = 0;
private long expectedLength = -1;
private long ciphertextRead = 0;
+ // Accumulates decrypted segments and flushes them to downstream in
bounded batches.
+ // For small messages (total plaintext < MAX_PLAINTEXT_BATCH_BYTES) a
single
+ // ctx.fireChannelRead() fires at message completion, reducing N
per-segment
+ // EventLoop callbacks to 1 and preventing EventLoop starvation under
shuffle load.
+ // For large messages the accumulator flushes every
MAX_PLAINTEXT_BATCH_BYTES,
+ // bounding on-heap retention for blocks that route to disk. Null
between flushes;
+ // ownership transfers to downstream on fireChannelRead().
+ private CompositeByteBuf plaintextAccumulator = null;
DecryptionHandler() throws GeneralSecurityException {
aesGcmHkdfStreaming = getAesGcmHkdfStreaming();
+ headerLength = aesGcmHkdfStreaming.getHeaderLength();
expectedLengthBuffer = ByteBuffer.allocate(LENGTH_HEADER_BYTES);
- headerBuffer =
ByteBuffer.allocate(aesGcmHkdfStreaming.getHeaderLength());
+ headerBuffer = ByteBuffer.allocate(headerLength);
ciphertextBuffer =
ByteBuffer.allocate(aesGcmHkdfStreaming.getCiphertextSegmentSize());
decrypter = aesGcmHkdfStreaming.newStreamSegmentDecrypter();
plaintextSegmentSize =
aesGcmHkdfStreaming.getPlaintextSegmentSize();
}
- private boolean initalizeExpectedLength(ByteBuf ciphertextNettyBuf) {
+ /**
+ * Resets all per-message state so that the next incoming GCM message
can be
+ * decoded through the same channel handler instance. This must be
called after
+ * every successfully completed message because AesGcmHkdfStreaming is
a one-shot
+ * streaming primitive: each encrypted message carries its own random
IV and must
+ * be decrypted with a fresh StreamSegmentDecrypter.
+ */
+ private void resetForNextMessage() throws GeneralSecurityException {
+ expectedLength = -1;
+ ((Buffer) expectedLengthBuffer).clear();
+ ((Buffer) headerBuffer).clear();
+ ((Buffer) ciphertextBuffer).clear();
+ decrypterInit = false;
+ completed = false;
+ segmentNumber = 0;
+ ciphertextRead = 0;
+ decrypter = aesGcmHkdfStreaming.newStreamSegmentDecrypter();
+ plaintextAccumulator = null; // defensive; should already be null
after fireChannelRead
+ }
+
+ private boolean initializeExpectedLength(ByteBuf ciphertextNettyBuf) {
if (expectedLength < 0) {
- ciphertextNettyBuf.readBytes(expectedLengthBuffer);
+ // ByteBuf.readBytes(ByteBuffer) throws if fewer than
dst.remaining() bytes
+ // are available, so temporarily narrow the limit to what is
actually present.
+ int toRead = Math.min(ciphertextNettyBuf.readableBytes(),
+ expectedLengthBuffer.remaining());
+ if (toRead > 0) {
+ int savedLimit = expectedLengthBuffer.limit();
+ ((Buffer)
expectedLengthBuffer).limit(expectedLengthBuffer.position() + toRead);
+ ciphertextNettyBuf.readBytes(expectedLengthBuffer);
+ ((Buffer) expectedLengthBuffer).limit(savedLimit);
+ }
if (expectedLengthBuffer.hasRemaining()) {
// We did not read enough bytes to initialize the expected
length.
return false;
@@ -332,12 +387,22 @@ public class GcmTransportCipher implements
TransportCipher {
return true;
}
- private boolean initalizeDecrypter(ByteBuf ciphertextNettyBuf)
+ private boolean initializeDecrypter(ByteBuf ciphertextNettyBuf)
throws GeneralSecurityException {
// Check if the ciphertext header has been read. This contains
// the IV and other internal metadata.
if (!decrypterInit) {
- ciphertextNettyBuf.readBytes(headerBuffer);
+ // ByteBuf.readBytes(ByteBuffer) throws if fewer than
dst.remaining() bytes
+ // are available. Under TCP fragmentation the header can
arrive in multiple
+ // chunks, so temporarily narrow the limit to what is actually
present.
+ int toRead = Math.min(ciphertextNettyBuf.readableBytes(),
+ headerBuffer.remaining());
+ if (toRead > 0) {
+ int savedLimit = headerBuffer.limit();
+ ((Buffer) headerBuffer).limit(headerBuffer.position() +
toRead);
+ ciphertextNettyBuf.readBytes(headerBuffer);
+ ((Buffer) headerBuffer).limit(savedLimit);
+ }
if (headerBuffer.hasRemaining()) {
// We did not read enough bytes to initialize the header.
return false;
@@ -346,7 +411,7 @@ public class GcmTransportCipher implements TransportCipher {
byte[] lengthAad = Longs.toByteArray(expectedLength);
decrypter.init(headerBuffer, lengthAad);
decrypterInit = true;
- ciphertextRead += aesGcmHkdfStreaming.getHeaderLength();
+ ciphertextRead += headerLength;
if (expectedLength == ciphertextRead) {
// If the expected length is just the header, the
ciphertext is 0 length.
completed = true;
@@ -362,61 +427,130 @@ public class GcmTransportCipher implements
TransportCipher {
"Unrecognized message type: %s",
ciphertextMessage.getClass().getName());
ByteBuf ciphertextNettyBuf = (ByteBuf) ciphertextMessage;
- // The format of the output is:
+ // The format of each message is:
// [8 byte length][Internal IV and header][Ciphertext][Auth Tag]
+ //
+ // A single channelRead() call may deliver bytes from multiple
back-to-back
+ // GCM messages (common under shuffle load when TCP coalesces
writes). The
+ // outer loop processes as many complete messages as possible from
the buffer
+ // before releasing it, so that bytes belonging to the next
message are never
+ // discarded mid-stream.
try {
- if (!initalizeExpectedLength(ciphertextNettyBuf)) {
- // We have not read enough bytes to initialize the
expected length.
- return;
- }
- if (!initalizeDecrypter(ciphertextNettyBuf)) {
- // We have not read enough bytes to initialize a header,
needed to
- // initialize a decrypter.
- return;
- }
- int nettyBufReadableBytes = ciphertextNettyBuf.readableBytes();
- while (nettyBufReadableBytes > 0 && !completed) {
- // Read the ciphertext into the local buffer
- int readableBytes = Integer.min(
- nettyBufReadableBytes,
- ciphertextBuffer.remaining());
- int expectedRemaining = (int) (expectedLength -
ciphertextRead);
- int bytesToRead = Integer.min(readableBytes,
expectedRemaining);
- // The smallest ciphertext size is 16 bytes for the auth
tag
- ((Buffer) ciphertextBuffer).limit(
- ((Buffer) ciphertextBuffer).position() +
bytesToRead);
- ciphertextNettyBuf.readBytes(ciphertextBuffer);
- ciphertextRead += bytesToRead;
- // Check if this is the last segment
- if (ciphertextRead == expectedLength) {
- completed = true;
- } else if (ciphertextRead > expectedLength) {
- throw new IllegalStateException("Read more ciphertext
than expected.");
+ while (true) {
+ if (!initializeExpectedLength(ciphertextNettyBuf)) {
+ // We have not read enough bytes to initialize the
expected length.
+ break;
+ }
+ if (!initializeDecrypter(ciphertextNettyBuf)) {
+ // We have not read enough bytes to initialize a
header, needed to
+ // initialize a decrypter.
+ break;
+ }
+ int nettyBufReadableBytes =
ciphertextNettyBuf.readableBytes();
+ while (nettyBufReadableBytes > 0 && !completed) {
+ // Read the ciphertext into the local buffer
+ int readableBytes = Math.min(
+ nettyBufReadableBytes,
+ ciphertextBuffer.remaining());
+ int expectedRemaining = (int) (expectedLength -
ciphertextRead);
+ int bytesToRead = Math.min(readableBytes,
expectedRemaining);
+ // The smallest ciphertext size is 16 bytes for the
auth tag
+ ((Buffer) ciphertextBuffer).limit(
+ ciphertextBuffer.position() + bytesToRead);
+ ciphertextNettyBuf.readBytes(ciphertextBuffer);
+ ciphertextRead += bytesToRead;
+ // Check if this is the last segment
+ if (ciphertextRead == expectedLength) {
+ completed = true;
+ } else if (ciphertextRead > expectedLength) {
+ throw new IllegalStateException("Read more
ciphertext than expected.");
+ }
+ // If the ciphertext buffer is full, or this is the
last segment,
+ // then decrypt it and fire a read.
+ if (ciphertextBuffer.limit() ==
ciphertextBuffer.capacity() || completed) {
+ ByteBuffer plaintextBuffer =
ByteBuffer.allocate(plaintextSegmentSize);
+ ((Buffer) ciphertextBuffer).flip();
+ decrypter.decryptSegment(
+ ciphertextBuffer,
+ segmentNumber,
+ completed,
+ plaintextBuffer);
+ segmentNumber++;
+ // Clear the ciphertext buffer because it's been
read
+ ((Buffer) ciphertextBuffer).clear();
+ ((Buffer) plaintextBuffer).flip();
+ if (plaintextAccumulator == null) {
+ // Integer.MAX_VALUE disables Netty's internal
consolidation,
+ // which would copy all components into a
single buffer on
+ // access. The initial component array is
small regardless of
+ // this cap (min(16, maxNumComponents)
elements).
+ plaintextAccumulator =
+
Unpooled.compositeBuffer(Integer.MAX_VALUE);
+ }
+ // Zero-copy append: addComponent(true, ...)
increases writerIndex
+ // so the component is immediately readable from
the composite.
+ plaintextAccumulator.addComponent(
+ true,
Unpooled.wrappedBuffer(plaintextBuffer));
+ // Flush when the batch threshold is reached or
the message ends.
+ // Small messages flush once at completion; large
blocks flush
+ // every MAX_PLAINTEXT_BATCH_BYTES to cap on-heap
retention.
+ if (completed ||
+ plaintextAccumulator.readableBytes() >=
+ MAX_PLAINTEXT_BATCH_BYTES) {
+ flushAccumulator(ctx);
+ }
+ } else {
+ // Set the ciphertext buffer up to read the next
chunk
+ ((Buffer)
ciphertextBuffer).limit(ciphertextBuffer.capacity());
+ }
+ nettyBufReadableBytes =
ciphertextNettyBuf.readableBytes();
}
- // If the ciphertext buffer is full, or this is the last
segment,
- // then decrypt it and fire a read.
- if (ciphertextBuffer.limit() ==
ciphertextBuffer.capacity() || completed) {
- ByteBuffer plaintextBuffer =
ByteBuffer.allocate(plaintextSegmentSize);
- ((Buffer) ciphertextBuffer).flip();
- decrypter.decryptSegment(
- ciphertextBuffer,
- segmentNumber,
- completed,
- plaintextBuffer);
- segmentNumber++;
- // Clear the ciphertext buffer because it's been read
- ((Buffer) ciphertextBuffer).clear();
- ((Buffer) plaintextBuffer).flip();
-
ctx.fireChannelRead(Unpooled.wrappedBuffer(plaintextBuffer));
- } else {
- // Set the ciphertext buffer up to read the next chunk
- ((Buffer)
ciphertextBuffer).limit(ciphertextBuffer.capacity());
+ if (!completed) {
+ // Partial message: more bytes needed from the next
channelRead() call.
+ break;
}
- nettyBufReadableBytes = ciphertextNettyBuf.readableBytes();
+ // Flush any remaining accumulator not yet fired inside
the segment
+ // loop (e.g. a zero-length ciphertext where the loop
never ran).
+ if (plaintextAccumulator != null) {
+ flushAccumulator(ctx);
+ }
+ // Current message is fully decoded. Reset state so the
handler can
+ // decode the next independent GCM message on the same
channel.
+ resetForNextMessage();
+ if (ciphertextNettyBuf.readableBytes() == 0) {
+ break;
+ }
+ // Remaining bytes may belong to another message; loop to
process them.
}
} finally {
ciphertextNettyBuf.release();
}
}
+
+ private void flushAccumulator(ChannelHandlerContext ctx) {
+ CompositeByteBuf out = plaintextAccumulator;
+ // null the accumulator before firing
+ plaintextAccumulator = null;
+ ctx.fireChannelRead(out);
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws
Exception {
+ releaseAccumulator();
+ ctx.fireChannelInactive();
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable
cause) throws Exception {
+ releaseAccumulator();
+ super.exceptionCaught(ctx, cause);
+ }
+
+ private void releaseAccumulator() {
+ if (plaintextAccumulator != null) {
+ plaintextAccumulator.release();
+ plaintextAccumulator = null;
+ }
+ }
}
}
diff --git
a/common/network-common/src/test/java/org/apache/spark/network/crypto/GcmAuthEngineSuite.java
b/common/network-common/src/test/java/org/apache/spark/network/crypto/GcmAuthEngineSuite.java
index f25277aa1a99..37fd32f17a36 100644
---
a/common/network-common/src/test/java/org/apache/spark/network/crypto/GcmAuthEngineSuite.java
+++
b/common/network-common/src/test/java/org/apache/spark/network/crypto/GcmAuthEngineSuite.java
@@ -35,6 +35,7 @@ import java.nio.ByteBuffer;
import java.nio.channels.WritableByteChannel;
import java.util.Arrays;
+import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.*;
@@ -94,13 +95,13 @@ public class GcmAuthEngineSuite extends AuthEngineSuite {
// Capture the decrypted values and verify them
ArgumentCaptor<ByteBuf> captorPlaintext =
ArgumentCaptor.forClass(ByteBuf.class);
decryptionHandler.channelRead(ctx, ciphertext);
- verify(ctx, times(2))
+ verify(ctx, times(1))
.fireChannelRead(captorPlaintext.capture());
- ByteBuf lastPlaintextSegment = captorPlaintext.getValue();
- assertEquals(plaintextSegmentSize/2,
- lastPlaintextSegment.readableBytes());
+ ByteBuf plaintext = captorPlaintext.getValue();
+ assertEquals(plaintextSegmentSize + (plaintextSegmentSize / 2),
+ plaintext.readableBytes());
assertEquals('c',
- lastPlaintextSegment.getByte((plaintextSegmentSize/2) - 10));
+ plaintext.getByte(plaintext.readableBytes() - 10));
}
}
@@ -223,13 +224,11 @@ public class GcmAuthEngineSuite extends AuthEngineSuite {
// Capture the decrypted values and verify them
ArgumentCaptor<ByteBuf> captorPlaintext =
ArgumentCaptor.forClass(ByteBuf.class);
decryptionHandler.channelRead(ctx, ciphertext);
- verify(ctx, times(2)).fireChannelRead(captorPlaintext.capture());
+ verify(ctx, times(1)).fireChannelRead(captorPlaintext.capture());
ByteBuf plaintext = captorPlaintext.getValue();
- // We expect this to be the last partial plaintext segment
- int expectedLength = totalSize % plaintextSegmentSize;
- assertEquals(expectedLength, plaintext.readableBytes());
- // This will be the "remainder" segment that is filled to 'c'
- assertEquals('c', plaintext.getByte(0));
+ assertEquals(totalSize, plaintext.readableBytes());
+ // 'c' starts at the second plaintext segment (offset
plaintextSegmentSize)
+ assertEquals('c', plaintext.getByte(plaintextSegmentSize));
}
}
@@ -285,12 +284,501 @@ public class GcmAuthEngineSuite extends AuthEngineSuite {
// Capture the decrypted values and verify them
ArgumentCaptor<ByteBuf> captorPlaintext =
ArgumentCaptor.forClass(ByteBuf.class);
decryptionHandler.channelRead(ctx, mockCiphertext);
- verify(ctx, times(2)).fireChannelRead(captorPlaintext.capture());
- ByteBuf lastPlaintextSegment = captorPlaintext.getValue();
- assertEquals(plaintextSegmentSize/2,
- lastPlaintextSegment.readableBytes());
- assertEquals('x',
- lastPlaintextSegment.getByte((plaintextSegmentSize/2) - 10));
+ verify(ctx, times(1)).fireChannelRead(captorPlaintext.capture());
+ ByteBuf plaintext = captorPlaintext.getValue();
+ assertEquals(plaintextSize, plaintext.readableBytes());
+ assertEquals('x', plaintext.getByte(plaintextSize - 10));
+ }
+ }
+
+ /**
+ * Verifies that the same DecryptionHandler instance correctly decodes
multiple independent
+ * GCM-encrypted messages sent over the same channel. This is the regression
test for the
+ * bug where DecryptionHandler.completed was never reset, causing every
message after the
+ * first to be silently dropped - which manifested as YARN container launch
failures.
+ */
+ @Test
+ public void testMultipleMessages() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ // --- First message ---
+ byte[] data1 = new byte[1024];
+ Arrays.fill(data1, (byte) 'A');
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> captor1 =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data1), promise);
+ verify(ctx).write(captor1.capture(), eq(promise));
+ ByteBuffer ct1 = ByteBuffer.allocate((int) captor1.getValue().count());
+ captor1.getValue().transferTo(new ByteBufferWriteableChannel(ct1), 0);
+ ((Buffer) ct1).flip();
+
+ ArgumentCaptor<ByteBuf> plaintextCaptor1 =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx, Unpooled.wrappedBuffer(ct1));
+ verify(ctx, atLeastOnce()).fireChannelRead(plaintextCaptor1.capture());
+ byte[] decrypted1 = new byte[data1.length];
+ int offset = 0;
+ for (ByteBuf segment : plaintextCaptor1.getAllValues()) {
+ int len = segment.readableBytes();
+ segment.readBytes(decrypted1, offset, len);
+ offset += len;
+ }
+ assertEquals(data1.length, offset);
+ assertArrayEquals(data1, decrypted1);
+
+ // --- Second message (same handler, different content) ---
+ reset(ctx);
+ byte[] data2 = new byte[2048];
+ Arrays.fill(data2, (byte) 'B');
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> captor2 =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data2), promise);
+ verify(ctx).write(captor2.capture(), eq(promise));
+ ByteBuffer ct2 = ByteBuffer.allocate((int) captor2.getValue().count());
+ captor2.getValue().transferTo(new ByteBufferWriteableChannel(ct2), 0);
+ ((Buffer) ct2).flip();
+
+ ArgumentCaptor<ByteBuf> plaintextCaptor2 =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx, Unpooled.wrappedBuffer(ct2));
+ verify(ctx, atLeastOnce()).fireChannelRead(plaintextCaptor2.capture());
+ byte[] decrypted2 = new byte[data2.length];
+ offset = 0;
+ for (ByteBuf segment : plaintextCaptor2.getAllValues()) {
+ int len = segment.readableBytes();
+ segment.readBytes(decrypted2, offset, len);
+ offset += len;
+ }
+ assertEquals(data2.length, offset);
+ assertArrayEquals(data2, decrypted2);
+ }
+ }
+
+ /**
+ * Verifies that multiple GCM-encrypted messages delivered inside a single
channelRead()
+ * call (TCP coalescing) are all decoded correctly. This is the regression
test for the
+ * IllegalStateException("Invalid expected ciphertext length") observed
under SparkSQL
+ * shuffle load: when Netty batches two messages into one ByteBuf, the old
code released
+ * the buffer after the first message, discarding remaining bytes. The next
channelRead()
+ * then read bytes from the middle of the second message as a length header.
+ */
+ @Test
+ public void testBatchedMessages() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ byte[] data1 = new byte[1024];
+ Arrays.fill(data1, (byte) 'A');
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> captor1 =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data1), promise);
+ verify(ctx).write(captor1.capture(), eq(promise));
+ ByteBuffer ct1 = ByteBuffer.allocate((int) captor1.getValue().count());
+ captor1.getValue().transferTo(new ByteBufferWriteableChannel(ct1), 0);
+ ((Buffer) ct1).flip();
+
+ reset(ctx);
+ byte[] data2 = new byte[2048];
+ Arrays.fill(data2, (byte) 'B');
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> captor2 =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data2), promise);
+ verify(ctx).write(captor2.capture(), eq(promise));
+ ByteBuffer ct2 = ByteBuffer.allocate((int) captor2.getValue().count());
+ captor2.getValue().transferTo(new ByteBufferWriteableChannel(ct2), 0);
+ ((Buffer) ct2).flip();
+
+ // Simulate TCP coalescing: deliver both ciphertexts in one
channelRead() call.
+ reset(ctx);
+ ByteBuf batched = Unpooled.wrappedBuffer(ct1, ct2);
+ ArgumentCaptor<ByteBuf> plaintextCaptor =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx, batched);
+ verify(ctx, atLeastOnce()).fireChannelRead(plaintextCaptor.capture());
+
+ byte[] decrypted = new byte[data1.length + data2.length];
+ int offset = 0;
+ for (ByteBuf segment : plaintextCaptor.getAllValues()) {
+ int len = segment.readableBytes();
+ segment.readBytes(decrypted, offset, len);
+ offset += len;
+ }
+ assertEquals(data1.length + data2.length, offset);
+ assertArrayEquals(data1, Arrays.copyOfRange(decrypted, 0, data1.length));
+ assertArrayEquals(data2, Arrays.copyOfRange(decrypted, data1.length,
decrypted.length));
+ }
+ }
+
+ /**
+ * Verifies that DecryptionHandler correctly handles a GCM message whose
framing header
+ * is split across two channelRead() calls. This is the regression test for
the
+ * IndexOutOfBoundsException in initializeDecrypter observed in
benchmarking: when only
+ * 4 bytes of the 24-byte GCM internal header arrived in one Netty buffer,
+ * ByteBuf.readBytes(ByteBuffer) threw because it requires all
dst.remaining() bytes to
+ * be available rather than performing a partial fill.
+ */
+ @Test
+ public void testSplitGcmHeader() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ byte[] data = new byte[1024];
+ Arrays.fill(data, (byte) 'X');
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> captor =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data), promise);
+ verify(ctx).write(captor.capture(), eq(promise));
+
+ ByteBuffer ciphertextBuffer = ByteBuffer.allocate((int)
captor.getValue().count());
+ captor.getValue().transferTo(new
ByteBufferWriteableChannel(ciphertextBuffer), 0);
+ ((Buffer) ciphertextBuffer).flip();
+ byte[] ciphertext = new byte[ciphertextBuffer.remaining()];
+ ciphertextBuffer.get(ciphertext);
+
+ // Split in the middle of the 24-byte GCM internal header:
+ // chunk1 = [8-byte length field][4 bytes of GCM header]
+ // chunk2 = [remaining 20 bytes of GCM header][full ciphertext]
+ int splitPoint = 8 + 4;
+ ByteBuf chunk1 = Unpooled.wrappedBuffer(ciphertext, 0, splitPoint);
+ ByteBuf chunk2 = Unpooled.wrappedBuffer(
+ ciphertext, splitPoint, ciphertext.length - splitPoint);
+
+ decryptionHandler.channelRead(ctx, chunk1);
+ // Only a partial header was delivered; no plaintext should be emitted
yet.
+ verify(ctx, never()).fireChannelRead(any());
+
+ ArgumentCaptor<ByteBuf> plaintextCaptor =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx, chunk2);
+ verify(ctx, atLeastOnce()).fireChannelRead(plaintextCaptor.capture());
+
+ byte[] decrypted = new byte[data.length];
+ int offset = 0;
+ for (ByteBuf segment : plaintextCaptor.getAllValues()) {
+ int len = segment.readableBytes();
+ segment.readBytes(decrypted, offset, len);
+ offset += len;
+ }
+ assertEquals(data.length, offset);
+ assertArrayEquals(data, decrypted);
+ }
+ }
+
+ /**
+ * Verifies that DecryptionHandler correctly handles a GCM message whose
8-byte Spark length
+ * prefix is split across two channelRead() calls. This exercises the
partial-fill path in
+ * {@code initializeExpectedLength}: when fewer than 8 bytes of the length
prefix arrive,
+ * the method narrows {@code expectedLengthBuffer}'s limit so that {@code
readBytes()} does
+ * not throw, accumulates the partial result, and returns {@code false} to
signal that the
+ * handler must wait for the rest. The second call completes the prefix and
proceeds normally.
+ */
+ @Test
+ public void testSplitLengthPrefix() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ byte[] data = new byte[1024];
+ Arrays.fill(data, (byte) 'Y');
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> captor =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data), promise);
+ verify(ctx).write(captor.capture(), eq(promise));
+
+ ByteBuffer ciphertextBuffer = ByteBuffer.allocate((int)
captor.getValue().count());
+ captor.getValue().transferTo(new
ByteBufferWriteableChannel(ciphertextBuffer), 0);
+ ((Buffer) ciphertextBuffer).flip();
+ byte[] ciphertext = new byte[ciphertextBuffer.remaining()];
+ ciphertextBuffer.get(ciphertext);
+
+ // Split in the middle of the 8-byte Spark length prefix:
+ // chunk1 = [4 bytes of length prefix]
+ // chunk2 = [remaining 4 bytes of length prefix][GCM header][ciphertext]
+ int splitPoint = 4;
+ ByteBuf chunk1 = Unpooled.wrappedBuffer(ciphertext, 0, splitPoint);
+ ByteBuf chunk2 = Unpooled.wrappedBuffer(
+ ciphertext, splitPoint, ciphertext.length - splitPoint);
+
+ decryptionHandler.channelRead(ctx, chunk1);
+ // Only a partial length prefix was delivered; no plaintext should be
emitted yet.
+ verify(ctx, never()).fireChannelRead(any());
+
+ ArgumentCaptor<ByteBuf> plaintextCaptor =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx, chunk2);
+ verify(ctx, atLeastOnce()).fireChannelRead(plaintextCaptor.capture());
+
+ byte[] decrypted = new byte[data.length];
+ int offset = 0;
+ for (ByteBuf segment : plaintextCaptor.getAllValues()) {
+ int len = segment.readableBytes();
+ segment.readBytes(decrypted, offset, len);
+ offset += len;
+ }
+ assertEquals(data.length, offset);
+ assertArrayEquals(data, decrypted);
+ }
+ }
+
+ /**
+ * Regression test for the encryptedCount miscalculation that caused shuffle
fetch stalls
+ * for plaintext sizes in (plaintextSegmentSize - getCiphertextOffset(),
plaintextSegmentSize]
+ * = (32728, 32752].
+ *
+ * Root cause: encryptedCount was computed using {@code
expectedCiphertextSize(P)} directly.
+ * Tink's formula internally adds {@code getCiphertextOffset()} = 24 to P
before dividing by
+ * plaintextSegmentSize to count segments. For P in (32728, 32752] this
predicted two
+ * ciphertext segments, but {@code transferTo()} writes the Tink header
separately and passes
+ * all P bytes to a single {@code encryptSegment()} call, producing only one
segment. The
+ * resulting encryptedCount was inflated by TAG_SIZE_IN_BYTES = 16, so
+ * {@code count() > transferred()} after all ciphertext was written.
Subsequent
+ * {@code transferTo()} calls returned 0 and the receiver stalled waiting
indefinitely for
+ * 16 bytes that were never sent.
+ */
+ @Test
+ public void testEncryptedCountBoundary() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ // plaintextSegmentSize = CIPHERTEXT_BUFFER_SIZE - TAG_SIZE = 32768 - 16
= 32752
+ // getCiphertextOffset() = 24 (Tink streaming header, written separately
by transferTo)
+ // Buggy range: P in (32728, 32752] - test lower boundary, midpoint, and
upper boundary.
+ int plaintextSegmentSize = GcmTransportCipher.CIPHERTEXT_BUFFER_SIZE -
16;
+ int[] plaintextSizes = {
+ plaintextSegmentSize - 23, // 32729: one above the lower boundary
+ plaintextSegmentSize - 12, // 32740: midpoint of the affected range
+ plaintextSegmentSize // 32752: exactly one full segment (upper
boundary)
+ };
+
+ for (int plaintextSize : plaintextSizes) {
+ reset(ctx);
+ byte[] data = new byte[plaintextSize];
+ Arrays.fill(data, (byte) 'Z');
+
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> encryptedCaptor
=
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data), promise);
+ verify(ctx).write(encryptedCaptor.capture(), eq(promise));
+
+ GcmTransportCipher.GcmEncryptedMessage encrypted =
encryptedCaptor.getValue();
+ ByteBuffer ciphertextBuf = ByteBuffer.allocate((int)
encrypted.count());
+ encrypted.transferTo(new ByteBufferWriteableChannel(ciphertextBuf), 0);
+
+ // Before the fix: count() was inflated by 16 bytes for these sizes, so
+ // transferred() < count() after all plaintext was consumed. The
channel stalled
+ // because subsequent transferTo() calls returned 0 instead of
completing.
+ assertEquals("count() != transferred() for plaintextSize=" +
plaintextSize,
+ encrypted.count(), encrypted.transferred());
+
+ // Verify the full round-trip also decrypts correctly.
+ reset(ctx);
+ ((Buffer) ciphertextBuf).flip();
+ ArgumentCaptor<ByteBuf> plaintextCaptor =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx,
Unpooled.wrappedBuffer(ciphertextBuf));
+ verify(ctx, times(1)).fireChannelRead(plaintextCaptor.capture());
+ ByteBuf plaintext = plaintextCaptor.getValue();
+ assertEquals(plaintextSize, plaintext.readableBytes());
+ assertEquals('Z', plaintext.getByte(0));
+ assertEquals('Z', plaintext.getByte(plaintextSize - 1));
+ }
+ }
+ }
+
+ /**
+ * Regression test for the executor heartbeat timeout caused by per-segment
+ * {@code ctx.fireChannelRead()} calls in {@code DecryptionHandler}. The old
code fired one
+ * EventLoop callback per 32 KB ciphertext segment; decoding a large shuffle
block produced
+ * many synchronous callbacks inside a single {@code processSelectedKeys()}
call,
+ * monopolising the Netty EventLoop and starving the executor-driver
heartbeat task.
+ *
+ * The fix accumulates all decrypted segments into a {@code
CompositeByteBuf} and reduce
+ * the number of {@code ctx.fireChannelRead()} calls. This test verifies
that a
+ * multi-segment plaintext below the 1 MB accumulator threshold
+ * ({@code MAX_PLAINTEXT_BATCH_BYTES}) produces exactly one {@code
fireChannelRead} call.
+ * Above that threshold the accumulator flushes mid-message; see
+ * {@code testLargeMessageBatchedFlush}.
+ */
+ @Test
+ public void testSingleFirePerMessage() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ // Use a 5-segment plaintext so the old per-segment fire (5 calls) is
clearly
+ // distinguishable from the expected single-fire behaviour (1 call).
+ int plaintextSegmentSize = GcmTransportCipher.CIPHERTEXT_BUFFER_SIZE -
16;
+ int plaintextSize = plaintextSegmentSize * 5;
+ byte[] data = new byte[plaintextSize];
+ Arrays.fill(data, (byte) 'M');
+
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> encryptedCaptor =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data), promise);
+ verify(ctx).write(encryptedCaptor.capture(), eq(promise));
+
+ GcmTransportCipher.GcmEncryptedMessage encrypted =
encryptedCaptor.getValue();
+ ByteBuffer ciphertextBuf = ByteBuffer.allocate((int) encrypted.count());
+ encrypted.transferTo(new ByteBufferWriteableChannel(ciphertextBuf), 0);
+ ((Buffer) ciphertextBuf).flip();
+
+ reset(ctx);
+ ArgumentCaptor<ByteBuf> plaintextCaptor =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx,
Unpooled.wrappedBuffer(ciphertextBuf));
+ // The old code fired once per 32 KB segment (5 times for this plaintext
size).
+ // The fix must fire exactly once for the whole message.
+ verify(ctx, times(1)).fireChannelRead(plaintextCaptor.capture());
+ ByteBuf plaintext = plaintextCaptor.getValue();
+ assertEquals(plaintextSize, plaintext.readableBytes());
+ assertEquals('M', plaintext.getByte(0));
+ assertEquals('M', plaintext.getByte(plaintextSize - 1));
+ }
+ }
+
+ /**
+ * Verifies that DecryptionHandler flushes plaintext in multiple batches
when the message
+ * plaintext exceeds {@code MAX_PLAINTEXT_BATCH_BYTES} (1 MB). The bounded
accumulation
+ * prevents full on-heap materialisation of large shuffle blocks that route
to disk via
+ * {@code spark.maxRemoteBlockSizeFetchToMem}. This test uses a 2 MB
plaintext, which
+ * crosses the 1 MB threshold mid-message (triggering the first flush) and
flushes once
+ * more at message completion, producing at least two {@code
fireChannelRead} calls whose
+ * concatenated output must reproduce the original plaintext exactly.
+ */
+ @Test
+ public void testLargeMessageBatchedFlush() throws Exception {
+ TransportConf gcmConf = getConf(2, false);
+ try (AuthEngine client = new AuthEngine("appId", "secret", gcmConf);
+ AuthEngine server = new AuthEngine("appId", "secret", gcmConf)) {
+ AuthMessage clientChallenge = client.challenge();
+ AuthMessage serverResponse = server.response(clientChallenge);
+ client.deriveSessionCipher(clientChallenge, serverResponse);
+ TransportCipher cipher = server.sessionCipher();
+ assert (cipher instanceof GcmTransportCipher);
+ GcmTransportCipher gcmTransportCipher = (GcmTransportCipher) cipher;
+
+ GcmTransportCipher.EncryptionHandler encryptionHandler =
+ gcmTransportCipher.getEncryptionHandler();
+ GcmTransportCipher.DecryptionHandler decryptionHandler =
+ gcmTransportCipher.getDecryptionHandler();
+
+ ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+ ChannelPromise promise = mock(ChannelPromise.class);
+
+ // 2 MB plaintext exceeds MAX_PLAINTEXT_BATCH_BYTES (1 MB): the
accumulator flushes
+ // when the threshold is crossed (after ~33 segments) and again at
completion,
+ // so at least two fireChannelRead() calls are expected rather than one.
+ int plaintextSize = 2 * GcmTransportCipher.MAX_PLAINTEXT_BATCH_BYTES;
+ byte[] data = new byte[plaintextSize];
+ Arrays.fill(data, (byte) 'L');
+
+ ArgumentCaptor<GcmTransportCipher.GcmEncryptedMessage> encryptedCaptor =
+
ArgumentCaptor.forClass(GcmTransportCipher.GcmEncryptedMessage.class);
+ encryptionHandler.write(ctx, Unpooled.wrappedBuffer(data), promise);
+ verify(ctx).write(encryptedCaptor.capture(), eq(promise));
+
+ GcmTransportCipher.GcmEncryptedMessage encrypted =
encryptedCaptor.getValue();
+ ByteBuffer ciphertextBuf = ByteBuffer.allocate((int) encrypted.count());
+ encrypted.transferTo(new ByteBufferWriteableChannel(ciphertextBuf), 0);
+ ((Buffer) ciphertextBuf).flip();
+
+ reset(ctx);
+ ArgumentCaptor<ByteBuf> plaintextCaptor =
ArgumentCaptor.forClass(ByteBuf.class);
+ decryptionHandler.channelRead(ctx,
Unpooled.wrappedBuffer(ciphertextBuf));
+ // At least two batches: one when the accumulator crosses 1 MB, one at
completion.
+ verify(ctx, atLeast(2)).fireChannelRead(plaintextCaptor.capture());
+
+ byte[] decrypted = new byte[plaintextSize];
+ int offset = 0;
+ for (ByteBuf segment : plaintextCaptor.getAllValues()) {
+ int len = segment.readableBytes();
+ segment.readBytes(decrypted, offset, len);
+ offset += len;
+ }
+ assertEquals(plaintextSize, offset);
+ assertArrayEquals(data, decrypted);
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]