diff --git a/crates/bemo/src/tls/engine.rs b/crates/bemo/src/tls/engine.rs index 1526bf4..a54dfba 100644 --- a/crates/bemo/src/tls/engine.rs +++ b/crates/bemo/src/tls/engine.rs @@ -1091,6 +1091,41 @@ mod tests { assert!(error.to_string().contains("overflow")); } + /// A destination too small for a complete application-data record triggers a pre-check overflow + /// that decrypts nothing and queues no plaintext, so a subsequent zero-length drain reports + /// underflow. The Rust-side contract the Java array-destination drain loop relies on: it must + /// not read the drain's underflow as the genuine status, and the record stays decryptable. + #[test] + #[cfg_attr(miri, ignore = "rustls handshakes call into AWS-LC")] + fn engine_pre_check_overflow_queues_no_plaintext_and_empty_drain_underflows() { + let (mut client, mut server) = pair(&[&rustls::version::TLS13], "localhost"); + handshake(&mut client, &mut server); + let payload: Vec = (0..10_000u32).map(|i| (i * 31 + 7) as u8).collect(); + let mut wire = Vec::new(); + assert_eq!(drain(&mut client, &payload, &mut wire).unwrap(), payload.len()); + assert!(!wire.is_empty()); + let record_len = wire.len(); + let mut small = vec![0u8; 100]; + let overflow = server.unwrap(Input::Shared(&wire), &mut small).unwrap(); + assert_eq!(overflow.status, Status::BufferOverflow); + assert_eq!(overflow.consumed, 0); + assert_eq!(overflow.produced, 0); + assert!( + small.iter().all(|byte| *byte == 0), + "pre-check overflow decrypts nothing" + ); + let drain = server.unwrap(Input::Shared(&[]), &mut small).unwrap(); + assert_eq!(drain.status, Status::BufferUnderflow); + assert_eq!(drain.consumed, 0); + assert_eq!(drain.produced, 0); + let mut large = vec![0u8; MAX_RECORD]; + let outcome = server.unwrap(Input::Shared(&wire), &mut large).unwrap(); + assert_eq!(outcome.status, Status::Ok); + assert_eq!(outcome.consumed, record_len); + assert_eq!(outcome.produced, payload.len()); + assert_eq!(&large[..payload.len()], &payload[..]); + } + #[test] #[cfg_attr(miri, ignore = "rustls handshakes call into AWS-LC")] fn engine_retains_fragmented_handshake_records() { diff --git a/packages/netty/src/main/java/dev/elide/bemo/transport/tls/NativeSslEngine.java b/packages/netty/src/main/java/dev/elide/bemo/transport/tls/NativeSslEngine.java index 27aed4a..2fa4cc9 100644 --- a/packages/netty/src/main/java/dev/elide/bemo/transport/tls/NativeSslEngine.java +++ b/packages/netty/src/main/java/dev/elide/bemo/transport/tls/NativeSslEngine.java @@ -308,12 +308,22 @@ public synchronized SSLEngineResult unwrap( int consumed = consumed(packed); int produced = produced(packed); boolean finished = finished(packed); - // Plaintext beyond one destination stays queued natively; drain it into the next ones. + // Plaintext beyond one destination stays queued natively; drain it into the next ones, or retry + // the record against a later destination when an overflow pre-check queued nothing to drain. while (STATUS[(int) (packed >>> 48 & 3)] == Status.BUFFER_OVERFLOW && ++index < end) { if (!destinations[index].hasRemaining()) continue; - packed = unwrapOnce(source, 0, destinations[index]); - produced += produced(packed); - finished |= finished(packed); + long drain = unwrapOnce(source, 0, destinations[index]); + if (produced(drain) > 0) { + packed = drain; + produced += produced(drain); + finished |= finished(drain); + } else { + long retry = unwrapOnce(source, source.remaining(), destinations[index]); + packed = retry; + consumed += consumed(retry); + produced += produced(retry); + finished |= finished(retry); + } } return result(packed, consumed, produced, finished); } diff --git a/tests/transport/java/NativeSslEngineTest.java b/tests/transport/java/NativeSslEngineTest.java index 793d651..d93ee1f 100644 --- a/tests/transport/java/NativeSslEngineTest.java +++ b/tests/transport/java/NativeSslEngineTest.java @@ -17,6 +17,7 @@ import io.netty.util.ReferenceCountUtil; import java.io.ByteArrayOutputStream; import java.net.InetSocketAddress; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.security.cert.X509Certificate; @@ -24,6 +25,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLEngineResult; import javax.net.ssl.SSLHandshakeException; /** Netty SslHandler contracts over NativeSslEngine, on v2 sockets and on NIO with heap buffers. */ @@ -92,6 +95,7 @@ public static void verify(TransportNative api, byte[] cert, byte[] key) throws E System.out.println("SslHandler contracts over NativeSslEngine passed: " + stack.name()); } derContext(api, cert, key, v2); + arrayUnwrapOverflow(api, server, client); } finally { for (NativeSslContext context : new NativeSslContext[] { @@ -659,6 +663,81 @@ static void echoAndSessionWithoutAlpn( } } + /** + * The array-destination {@code unwrap} must not mask a {@code BUFFER_OVERFLOW} with {@code + * BUFFER_UNDERFLOW} when the first destination is too small for a pending record. When a later + * destination has room, the record is delivered into it; otherwise the reported status is {@code + * BUFFER_OVERFLOW}, never {@code BUFFER_UNDERFLOW}. + */ + static void arrayUnwrapOverflow( + TransportNative api, NativeSslContext server, NativeSslContext client) throws Exception { + SSLEngine clientEngine = client.newEngine(UnpooledByteBufAllocator.DEFAULT, "localhost", 443); + SSLEngine serverEngine = server.newEngine(UnpooledByteBufAllocator.DEFAULT); + try { + handshake(clientEngine, serverEngine); + ByteBuffer payload = ByteBuffer.allocate(10000); + for (int i = 0; i < payload.capacity(); i++) payload.put(i, (byte) (i * 31 + 7)); + ByteBuffer wire = ByteBuffer.allocateDirect(65536); + SSLEngineResult wrapped = clientEngine.wrap(payload, wire); + wire.flip(); + long recordLength = wrapped.bytesProduced(); + // Control: a single too-small destination reports overflow (matching the 2-arg unwrap). + ByteBuffer small = ByteBuffer.allocate(100); + SSLEngineResult control = serverEngine.unwrap(wire, new ByteBuffer[] {small}, 0, 1); + require( + control.getStatus() == SSLEngineResult.Status.BUFFER_OVERFLOW, + "single small destination reports overflow: " + control.getStatus()); + require(control.bytesConsumed() == 0, "overflow consumes nothing"); + require(control.bytesProduced() == 0, "overflow produces nothing"); + require(wire.remaining() == recordLength, "overflow leaves the record on the wire"); + // Bug case: the first destination is too small, the second has room. The record must be + // delivered into the second destination rather than stalling on BUFFER_UNDERFLOW 0/0. + ByteBuffer large = ByteBuffer.allocate(65536); + SSLEngineResult result = serverEngine.unwrap(wire, new ByteBuffer[] {small, large}, 0, 2); + require( + result.getStatus() == SSLEngineResult.Status.OK, + "array unwrap delivers the record into a room destination: " + result.getStatus()); + require(result.bytesConsumed() == recordLength, "array unwrap consumed the full record"); + require(result.bytesProduced() == 10000, "array unwrap produced the full plaintext"); + require(small.position() == 0, "small destination untouched"); + large.flip(); + require(large.remaining() == 10000, "large destination holds the plaintext"); + for (int i = 0; i < 10000; i++) + require(large.get(i) == (byte) (i * 31 + 7), "plaintext byte " + i); + } finally { + ReferenceCountUtil.release(clientEngine); + ReferenceCountUtil.release(serverEngine); + } + System.out.println("NativeSslEngine array-destination unwrap overflow contract passed"); + } + + static void handshake(SSLEngine client, SSLEngine server) throws Exception { + ByteBuffer empty = ByteBuffer.allocate(0); + ByteBuffer toServer = ByteBuffer.allocateDirect(65536); + ByteBuffer toClient = ByteBuffer.allocateDirect(65536); + client.beginHandshake(); + server.beginHandshake(); + for (int round = 0; round < 64; round++) { + client.wrap(empty, toServer); + drainHandshake(server, toServer); + server.wrap(empty, toClient); + drainHandshake(client, toClient); + if (client.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING + && server.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING) return; + } + throw new AssertionError("direct engine handshake stalled"); + } + + static void drainHandshake(SSLEngine engine, ByteBuffer wire) throws Exception { + wire.flip(); + ByteBuffer sink = ByteBuffer.allocateDirect(65536); + while (wire.hasRemaining()) { + SSLEngineResult result = engine.unwrap(wire, sink); + if (result.bytesConsumed() == 0 && result.bytesProduced() == 0) break; + } + wire.compact(); + } + static void require(boolean condition, String message) { if (!condition) throw new AssertionError(message); }