From 8a9c59d8b3602c7552493ae7abd69079b7b3509c Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:40:31 +0200 Subject: [PATCH 1/7] implement mutual tls support --- .../javasabr/rlib/network/NetworkFactory.java | 35 +++++++++++++++++-- .../network/impl/AbstractSslConnection.java | 3 ++ .../impl/StringDataMtlsServerConnection.java | 28 +++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 rlib-network/src/main/java/javasabr/rlib/network/impl/StringDataMtlsServerConnection.java diff --git a/rlib-network/src/main/java/javasabr/rlib/network/NetworkFactory.java b/rlib-network/src/main/java/javasabr/rlib/network/NetworkFactory.java index 0c09b9a5..e0b0b00b 100644 --- a/rlib-network/src/main/java/javasabr/rlib/network/NetworkFactory.java +++ b/rlib-network/src/main/java/javasabr/rlib/network/NetworkFactory.java @@ -7,6 +7,7 @@ import javasabr.rlib.network.impl.DefaultBufferAllocator; import javasabr.rlib.network.impl.DefaultConnection; import javasabr.rlib.network.impl.StringDataConnection; +import javasabr.rlib.network.impl.StringDataMtlsServerConnection; import javasabr.rlib.network.impl.StringDataSslConnection; import javasabr.rlib.network.packet.impl.DefaultReadableNetworkPacket; import javasabr.rlib.network.packet.registry.ReadableNetworkPacketRegistry; @@ -140,7 +141,11 @@ public static ClientNetwork stringDataSslClientNetwork( SSLContext sslContext) { return clientNetwork( networkConfig, - (network, channel) -> new StringDataSslConnection(network, channel, bufferAllocator, sslContext, true)); + (network, channel) -> { + StringDataSslConnection connection = new StringDataSslConnection(network, channel, bufferAllocator, sslContext, true); + connection.beginHandshake(); + return connection; + }); } /** @@ -196,7 +201,11 @@ public static ServerNetwork stringDataSslServerNetwork( SSLContext sslContext) { return serverNetwork( networkConfig, - (network, channel) -> new StringDataSslConnection(network, channel, bufferAllocator, sslContext, false)); + (network, channel) -> { + StringDataSslConnection connection = new StringDataSslConnection(network, channel, bufferAllocator, sslContext, false); + connection.beginHandshake(); + return connection; + }); } /** @@ -231,4 +240,26 @@ public static ServerNetwork defaultServerNetwork( networkConfig, (network, channel) -> new DefaultConnection(network, channel, bufferAllocator, packetRegistry)); } + + /** + * Create string packet based asynchronous Mutual TLS server network. + * + * @param networkConfig the server network configuration + * @param bufferAllocator the buffer allocator + * @param sslContext SSL context + * @return a new mTLS server network + * @since 10.0.0 + */ + public static ServerNetwork stringDataMtlsServerNetwork( + ServerNetworkConfig networkConfig, + BufferAllocator bufferAllocator, + SSLContext sslContext) { + return serverNetwork( + networkConfig, + (network, channel) -> { + StringDataMtlsServerConnection connection = new StringDataMtlsServerConnection(network, channel, bufferAllocator, sslContext); + connection.beginHandshake(); + return connection; + }); + } } diff --git a/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java b/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java index 9ae853d7..f2bf27e0 100644 --- a/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java +++ b/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java @@ -26,6 +26,9 @@ public AbstractSslConnection( super(network, channel, bufferAllocator, maxPacketsByRead); this.sslEngine = sslContext.createSSLEngine(); this.sslEngine.setUseClientMode(clientMode); + } + + public void beginHandshake(){ try { this.sslEngine.beginHandshake(); } catch (SSLException e) { diff --git a/rlib-network/src/main/java/javasabr/rlib/network/impl/StringDataMtlsServerConnection.java b/rlib-network/src/main/java/javasabr/rlib/network/impl/StringDataMtlsServerConnection.java new file mode 100644 index 00000000..9bd21203 --- /dev/null +++ b/rlib-network/src/main/java/javasabr/rlib/network/impl/StringDataMtlsServerConnection.java @@ -0,0 +1,28 @@ +package javasabr.rlib.network.impl; + +import javasabr.rlib.network.BufferAllocator; +import javasabr.rlib.network.Network; +import javasabr.rlib.network.packet.impl.StringReadableNetworkPacket; + +import javax.net.ssl.SSLContext; +import java.nio.channels.AsynchronousSocketChannel; + +/** + * @author crazyrokr + */ +public class StringDataMtlsServerConnection extends DefaultDataSslConnection { + + public StringDataMtlsServerConnection( + Network network, + AsynchronousSocketChannel channel, + BufferAllocator bufferAllocator, + SSLContext sslContext) { + super(network, channel, bufferAllocator, sslContext, 100, 2, false); + sslEngine.setNeedClientAuth(true); + } + + @Override + protected StringReadableNetworkPacket createReadablePacket() { + return new StringReadableNetworkPacket<>(); + } +} From 6e49fff9271aaf2886d8219cb29b3c8b74fdad6a Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:41:22 +0200 Subject: [PATCH 2/7] fix mutual tls flow --- .../network/packet/impl/AbstractSslNetworkPacketWriter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketWriter.java b/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketWriter.java index f15f9aee..b2b0fb57 100644 --- a/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketWriter.java +++ b/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketWriter.java @@ -197,7 +197,7 @@ protected ByteBuffer doHandshake(HandshakeStatus handshakeStatus) { break; } case NEED_UNWRAP: { - break; + return EMPTY_BUFFER; } default: { throw new IllegalStateException("Invalid SSL status:" + handshakeStatus); From 740086c43e4146c45fb08eb4a13378537eb3c7da Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:41:37 +0200 Subject: [PATCH 3/7] add mutual tls tests --- .../rlib/network/SslMutualTlsTest.java | 198 ++++++++++++++++++ .../resources/ssl/rlib_test_client_cert.p12 | Bin 0 -> 2595 bytes 2 files changed, 198 insertions(+) create mode 100644 rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java create mode 100644 rlib-network/src/test/resources/ssl/rlib_test_client_cert.p12 diff --git a/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java b/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java new file mode 100644 index 00000000..1c544b8a --- /dev/null +++ b/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java @@ -0,0 +1,198 @@ +package javasabr.rlib.network; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javasabr.rlib.network.client.ClientNetwork; +import javasabr.rlib.network.impl.DefaultBufferAllocator; +import javasabr.rlib.network.impl.StringDataMtlsServerConnection; +import javasabr.rlib.network.impl.StringDataSslConnection; +import javasabr.rlib.network.packet.impl.StringReadableNetworkPacket; +import javasabr.rlib.network.packet.impl.StringWritableNetworkPacket; +import javasabr.rlib.network.server.ServerNetwork; +import javasabr.rlib.network.util.NetworkUtils; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import lombok.CustomLog; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for mutual TLS (mTLS) — server requires client certificate. + * + * @author JavaSaBr + */ +@CustomLog +public class SslMutualTlsTest extends BaseNetworkTest { + + @Test + @SneakyThrows + void shouldAcceptConnectionWithValidClientCertificate() { + + InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); + InputStream clientCertAsTrustStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); + + SSLContext serverSslContext = NetworkUtils.createSslContext( + "PKCS12", serverKeystoreStream, "test", + "PKCS12", clientCertAsTrustStream, "testpw"); + ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); + BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); + + ServerNetwork serverNetwork = NetworkFactory.stringDataMtlsServerNetwork(serverConfig, bufferAllocator, serverSslContext); + + InetSocketAddress serverAddress = serverNetwork.start(); + CountDownLatch receivedLatch = new CountDownLatch(1); + + serverNetwork + .accepted() + .flatMap(Connection::receivedEvents) + .subscribe(event -> { + log.info(((StringReadableNetworkPacket) event.packet()).data(), "mTLS server received: [%s]"::formatted); + receivedLatch.countDown(); + }); + + InputStream clientKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); + SSLContext clientSslContext = createClientSslContextWithCert(clientKeystoreStream, "testpw"); + + ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork( + NetworkConfig.DEFAULT_CLIENT, + new DefaultBufferAllocator(NetworkConfig.DEFAULT_CLIENT), + clientSslContext); + + try { + clientNetwork + .connectReactive(serverAddress) + .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("Hello mTLS"))) + .doOnError(Throwable::printStackTrace) + .subscribe(); + + assertThat(receivedLatch.await(15, TimeUnit.SECONDS)) + .as("Server should receive packet from mTLS client") + .isTrue(); + } finally { + serverNetwork.shutdown(); + clientNetwork.shutdown(); + } + } + + @Test + @SneakyThrows + void shouldAcceptConnectionUsingConfigEmbeddedSslContext() { + + InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); + InputStream clientCertAsTrustStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); + + SSLContext serverSslContext = NetworkUtils.createSslContext( + "PKCS12", serverKeystoreStream, "test", + "PKCS12", clientCertAsTrustStream, "testpw"); + ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); + BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); + + ServerNetwork serverNetwork = NetworkFactory.stringDataSslServerNetwork(serverConfig, bufferAllocator, serverSslContext); + + InetSocketAddress serverAddress = serverNetwork.start(); + CountDownLatch receivedLatch = new CountDownLatch(1); + + serverNetwork + .accepted() + .flatMap(Connection::receivedEvents) + .subscribe(event -> receivedLatch.countDown()); + + InputStream clientKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); + SSLContext clientSslContext = createClientSslContextWithCert(clientKeystoreStream, "testpw"); + NetworkConfig clientConfig = NetworkConfig.SimpleNetworkConfig.builder().build(); + bufferAllocator = new DefaultBufferAllocator(clientConfig); + + ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork(clientConfig, bufferAllocator, clientSslContext); + + try { + clientNetwork + .connectReactive(serverAddress) + .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("Hello config-embedded mTLS"))) + .doOnError(Throwable::printStackTrace) + .subscribe(); + + assertThat(receivedLatch.await(15, TimeUnit.SECONDS)) + .as("Server should receive packet when SSL context is embedded in config") + .isTrue(); + } finally { + serverNetwork.shutdown(); + clientNetwork.shutdown(); + } + } + + @SneakyThrows + private static SSLContext createClientSslContextWithCert(InputStream keystoreStream, String password) { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(keystoreStream, password.toCharArray()); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, password.toCharArray()); + SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); + sslContext.init(kmf.getKeyManagers(), new TrustManager[]{new NetworkUtils.AllTrustManager()}, new SecureRandom()); + return sslContext; + } + + + /** + * Regression test for the {@code sslEngine.setNeedClientAuth(true)} call + * in {@code AbstractSslConnection}. + */ + @Test + @SneakyThrows + void serverShouldRejectClientWithoutCertificateWhenRequireClientAuthIsTrue() { + InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); + SSLContext serverSslContext = NetworkUtils.createSslContext(serverKeystoreStream, "test"); + ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); + BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); + + ServerNetwork serverNetwork = NetworkFactory.stringDataMtlsServerNetwork(serverConfig, bufferAllocator, serverSslContext); + + InetSocketAddress serverAddress = serverNetwork.start(); + CountDownLatch dataReceivedByServer = new CountDownLatch(1); + + serverNetwork + .accepted() + .flatMap(Connection::receivedEvents) + .subscribe(event -> dataReceivedByServer.countDown()); + + SSLContext clientWithoutCertContext = NetworkUtils.createAllTrustedClientSslContext(); + ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork( + NetworkConfig.DEFAULT_CLIENT, + new DefaultBufferAllocator(NetworkConfig.DEFAULT_CLIENT), + clientWithoutCertContext); + + try { + clientNetwork + .connectReactive(serverAddress) + .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("no cert"))) + .subscribe(); + + assertThat(dataReceivedByServer.await(5, TimeUnit.SECONDS)) + .as("Server must reject a client that presents no certificate when requireClientAuth=true.") + .isFalse(); + } finally { + serverNetwork.shutdown(); + clientNetwork.shutdown(); + } + } + + @Test + void plainTcpShouldWorkWhenNoSslContextConfigured() { + try (TestNetwork network = buildStringNetwork()) { + network.clientToServer.sendInBackground( + new javasabr.rlib.network.packet.impl.StringWritableNetworkPacket<>("plain TCP")); + assertThat(network.serverToClient + .receivedValidPackets() + .blockFirst(Duration.ofSeconds(5))) + .as("Plain TCP should still work when no SSL is configured") + .isNotNull(); + } + } +} diff --git a/rlib-network/src/test/resources/ssl/rlib_test_client_cert.p12 b/rlib-network/src/test/resources/ssl/rlib_test_client_cert.p12 new file mode 100644 index 0000000000000000000000000000000000000000..87de0552a0a8d687ca6f2ba370bf40ffbc7f7701 GIT binary patch literal 2595 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 literal 0 HcmV?d00001 From a3d0505725d1283aaa1bdace9dc59ccfda079d23 Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:44:46 +0200 Subject: [PATCH 4/7] update javadocs --- .../test/java/javasabr/rlib/network/SslMutualTlsTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java b/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java index 1c544b8a..db81e4a2 100644 --- a/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java +++ b/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java @@ -139,14 +139,13 @@ private static SSLContext createClientSslContextWithCert(InputStream keystoreStr return sslContext; } - /** * Regression test for the {@code sslEngine.setNeedClientAuth(true)} call - * in {@code AbstractSslConnection}. + * in {@code StringDataMtlsServerConnection}. */ @Test @SneakyThrows - void serverShouldRejectClientWithoutCertificateWhenRequireClientAuthIsTrue() { + void serverShouldRejectClientWithoutCertificateWhenNeedClientAuthIsTrue() { InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); SSLContext serverSslContext = NetworkUtils.createSslContext(serverKeystoreStream, "test"); ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); From e968498f6f08ca22fe9173eee5349085296ba891 Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:28:24 +0200 Subject: [PATCH 5/7] fix tls flow --- .../impl/AbstractSslNetworkPacketReader.java | 3 + .../packet/impl/SslPacketReaderTest.java | 146 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java diff --git a/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketReader.java b/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketReader.java index 6ab75309..1225f4c4 100644 --- a/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketReader.java +++ b/rlib-network/src/main/java/javasabr/rlib/network/packet/impl/AbstractSslNetworkPacketReader.java @@ -159,6 +159,9 @@ protected int doHandshake(ByteBuffer networkBuffer, int receivedBytes) { case NEED_WRAP: { log.debug(remoteAddress, "[%s] Send command to wrap data"::formatted); packetWriter.accept(SslWrapRequestNetworkPacket.getInstance()); + if (networkBuffer.hasRemaining()) { + return decryptAndRead(networkBuffer); + } NetworkUtils.cleanNetworkBuffer(networkBuffer); return SKIP_READ_PACKETS; } diff --git a/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java b/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java new file mode 100644 index 00000000..5dce6747 --- /dev/null +++ b/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java @@ -0,0 +1,146 @@ +package javasabr.rlib.network.packet.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import javasabr.rlib.network.BufferAllocator; +import javasabr.rlib.network.Network; +import javasabr.rlib.network.NetworkConfig; +import javasabr.rlib.network.UnsafeConnection; +import javasabr.rlib.network.impl.DefaultBufferAllocator; +import javasabr.rlib.network.packet.ReadableNetworkPacket; +import javasabr.rlib.network.packet.WritableNetworkPacket; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLEngineResult; +import javax.net.ssl.SSLEngineResult.HandshakeStatus; +import javax.net.ssl.SSLEngineResult.Status; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SslPacketReaderTest { + + private interface TestConnection extends UnsafeConnection {} + + @Mock + private TestConnection connection; + + @Mock + private Network network; + + @Mock + private SSLEngine sslEngine; + + @Mock + private SSLSession sslSession; + + @Mock + private Consumer> packetHandler; + + @Mock + private Consumer> packetWriter; + + private BufferAllocator bufferAllocator; + + @BeforeEach + void setUp() { + bufferAllocator = new DefaultBufferAllocator(NetworkConfig.DEFAULT_CLIENT); + when(connection.bufferAllocator()).thenReturn(bufferAllocator); + when(connection.network()).thenReturn((Network) network); + when(connection.remoteAddress()).thenReturn("test-address"); + when(network.config()).thenReturn(NetworkConfig.DEFAULT_CLIENT); + when(sslEngine.getSession()).thenReturn(sslSession); + when(sslSession.getApplicationBufferSize()).thenReturn(1024); + when(sslSession.getPacketBufferSize()).thenReturn(1024); + } + + private static class TestSslPacketReader extends + AbstractSslNetworkPacketReader, TestConnection> { + + private final AtomicInteger readPacketsCount = new AtomicInteger(); + + protected TestSslPacketReader( + TestConnection connection, + Consumer> packetHandler, + SSLEngine sslEngine, + Consumer> packetWriter) { + super(connection, () -> {}, packetHandler, packetHandler, sslEngine, packetWriter, 100); + } + + @Override + protected boolean canStartReadPacket(ByteBuffer buffer) { + return buffer.remaining() >= 1; + } + + @Override + protected int readFullPacketLength(ByteBuffer buffer) { + return 1; + } + + @Override + protected ReadableNetworkPacket createPacketFor( + ByteBuffer buffer, + int startPacketPosition, + int packetFullLength, + int packetDataLength) { + buffer.get(); // consume 1 byte + readPacketsCount.incrementAndGet(); + return mock(ReadableNetworkPacket.class); + } + } + + @Test + void testShouldNotLoseDataOnNeedWrapDuringHandshake() throws Exception { + // given + var reader = new TestSslPacketReader(connection, packetHandler, sslEngine, packetWriter); + + // Initial state: NEED_UNWRAP + when(sslEngine.getHandshakeStatus()).thenReturn(HandshakeStatus.NEED_UNWRAP); + + // First unwrap will result in NEED_WRAP and status OK, consuming some data + // MQTT broker received 10 bytes, first 5 bytes are handshake, and the last 5 bytes are application data + ByteBuffer networkData = ByteBuffer.allocate(10); + networkData.put(new byte[10]); + networkData.flip(); + + // doHandshake calls unwrap in NEED_UNWRAP, consumes first 5 bytes, then returns OK + when(sslEngine.unwrap(any(ByteBuffer.class), any(ByteBuffer[].class))).thenAnswer(invocation -> { + ByteBuffer in = invocation.getArgument(0); + in.position(in.position() + 5); // consume 5 bytes of handshake + // Change status to NEED_WRAP for next getHandshakeStatus() call + when(sslEngine.getHandshakeStatus()).thenReturn(HandshakeStatus.NEED_WRAP); + return new SSLEngineResult(Status.OK, HandshakeStatus.NEED_WRAP, 5, 0); + }); + + // decryptAndRead calls unwrap, consumes the remaining 5 bytes, then return FINISHED or NOT_HANDSHAKING + when(sslEngine.unwrap(any(ByteBuffer.class), any(ByteBuffer.class))).thenAnswer(invocation -> { + ByteBuffer in = invocation.getArgument(0); + ByteBuffer out = invocation.getArgument(1); + int remaining = in.remaining(); + in.position(in.limit()); // consume all + out.put(new byte[remaining]); // put decrypted data (mocked) + when(sslEngine.getHandshakeStatus()).thenReturn(HandshakeStatus.NOT_HANDSHAKING); + return new SSLEngineResult(Status.OK, HandshakeStatus.NOT_HANDSHAKING, remaining, remaining); + }); + + // when + reader.readPackets(networkData); + + // then + // readPackets should have been called for the remaining 5 bytes, + // since each packet is 1 byte, it should have read 5 packets + assertThat(reader.readPacketsCount.get()).isEqualTo(5); + verify(packetWriter).accept(any(SslWrapRequestNetworkPacket.class)); + } +} From 1b17a2591f4905cd18ad73f1ef86dac7e5ea16c2 Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:32:43 +0200 Subject: [PATCH 6/7] apply formatting Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/javasabr/rlib/network/impl/AbstractSslConnection.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java b/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java index f2bf27e0..f015070b 100644 --- a/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java +++ b/rlib-network/src/main/java/javasabr/rlib/network/impl/AbstractSslConnection.java @@ -28,7 +28,7 @@ public AbstractSslConnection( this.sslEngine.setUseClientMode(clientMode); } - public void beginHandshake(){ + public void beginHandshake() { try { this.sslEngine.beginHandshake(); } catch (SSLException e) { From 963d0b7f5d09b0c556647fb0b2c51629f7b6eb69 Mon Sep 17 00:00:00 2001 From: Maksim Kashapov <56276969+crazyrokr@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:11:30 +0200 Subject: [PATCH 7/7] remove unnecessary changes --- .../rlib/network/SslMutualTlsTest.java | 197 ------------------ .../rlib/network/StringSslNetworkTest.java | 41 ++++ .../packet/impl/SslPacketReaderTest.java | 9 +- .../resources/ssl/rlib_test_client_cert.p12 | Bin 2595 -> 0 bytes 4 files changed, 49 insertions(+), 198 deletions(-) delete mode 100644 rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java delete mode 100644 rlib-network/src/test/resources/ssl/rlib_test_client_cert.p12 diff --git a/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java b/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java deleted file mode 100644 index db81e4a2..00000000 --- a/rlib-network/src/test/java/javasabr/rlib/network/SslMutualTlsTest.java +++ /dev/null @@ -1,197 +0,0 @@ -package javasabr.rlib.network; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.InputStream; -import java.net.InetSocketAddress; -import java.security.KeyStore; -import java.security.SecureRandom; -import java.time.Duration; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import javasabr.rlib.network.client.ClientNetwork; -import javasabr.rlib.network.impl.DefaultBufferAllocator; -import javasabr.rlib.network.impl.StringDataMtlsServerConnection; -import javasabr.rlib.network.impl.StringDataSslConnection; -import javasabr.rlib.network.packet.impl.StringReadableNetworkPacket; -import javasabr.rlib.network.packet.impl.StringWritableNetworkPacket; -import javasabr.rlib.network.server.ServerNetwork; -import javasabr.rlib.network.util.NetworkUtils; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import lombok.CustomLog; -import lombok.SneakyThrows; -import org.junit.jupiter.api.Test; - -/** - * Integration tests for mutual TLS (mTLS) — server requires client certificate. - * - * @author JavaSaBr - */ -@CustomLog -public class SslMutualTlsTest extends BaseNetworkTest { - - @Test - @SneakyThrows - void shouldAcceptConnectionWithValidClientCertificate() { - - InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); - InputStream clientCertAsTrustStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); - - SSLContext serverSslContext = NetworkUtils.createSslContext( - "PKCS12", serverKeystoreStream, "test", - "PKCS12", clientCertAsTrustStream, "testpw"); - ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); - BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); - - ServerNetwork serverNetwork = NetworkFactory.stringDataMtlsServerNetwork(serverConfig, bufferAllocator, serverSslContext); - - InetSocketAddress serverAddress = serverNetwork.start(); - CountDownLatch receivedLatch = new CountDownLatch(1); - - serverNetwork - .accepted() - .flatMap(Connection::receivedEvents) - .subscribe(event -> { - log.info(((StringReadableNetworkPacket) event.packet()).data(), "mTLS server received: [%s]"::formatted); - receivedLatch.countDown(); - }); - - InputStream clientKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); - SSLContext clientSslContext = createClientSslContextWithCert(clientKeystoreStream, "testpw"); - - ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork( - NetworkConfig.DEFAULT_CLIENT, - new DefaultBufferAllocator(NetworkConfig.DEFAULT_CLIENT), - clientSslContext); - - try { - clientNetwork - .connectReactive(serverAddress) - .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("Hello mTLS"))) - .doOnError(Throwable::printStackTrace) - .subscribe(); - - assertThat(receivedLatch.await(15, TimeUnit.SECONDS)) - .as("Server should receive packet from mTLS client") - .isTrue(); - } finally { - serverNetwork.shutdown(); - clientNetwork.shutdown(); - } - } - - @Test - @SneakyThrows - void shouldAcceptConnectionUsingConfigEmbeddedSslContext() { - - InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); - InputStream clientCertAsTrustStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); - - SSLContext serverSslContext = NetworkUtils.createSslContext( - "PKCS12", serverKeystoreStream, "test", - "PKCS12", clientCertAsTrustStream, "testpw"); - ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); - BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); - - ServerNetwork serverNetwork = NetworkFactory.stringDataSslServerNetwork(serverConfig, bufferAllocator, serverSslContext); - - InetSocketAddress serverAddress = serverNetwork.start(); - CountDownLatch receivedLatch = new CountDownLatch(1); - - serverNetwork - .accepted() - .flatMap(Connection::receivedEvents) - .subscribe(event -> receivedLatch.countDown()); - - InputStream clientKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_client_cert.p12"); - SSLContext clientSslContext = createClientSslContextWithCert(clientKeystoreStream, "testpw"); - NetworkConfig clientConfig = NetworkConfig.SimpleNetworkConfig.builder().build(); - bufferAllocator = new DefaultBufferAllocator(clientConfig); - - ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork(clientConfig, bufferAllocator, clientSslContext); - - try { - clientNetwork - .connectReactive(serverAddress) - .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("Hello config-embedded mTLS"))) - .doOnError(Throwable::printStackTrace) - .subscribe(); - - assertThat(receivedLatch.await(15, TimeUnit.SECONDS)) - .as("Server should receive packet when SSL context is embedded in config") - .isTrue(); - } finally { - serverNetwork.shutdown(); - clientNetwork.shutdown(); - } - } - - @SneakyThrows - private static SSLContext createClientSslContextWithCert(InputStream keystoreStream, String password) { - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load(keystoreStream, password.toCharArray()); - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keyStore, password.toCharArray()); - SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); - sslContext.init(kmf.getKeyManagers(), new TrustManager[]{new NetworkUtils.AllTrustManager()}, new SecureRandom()); - return sslContext; - } - - /** - * Regression test for the {@code sslEngine.setNeedClientAuth(true)} call - * in {@code StringDataMtlsServerConnection}. - */ - @Test - @SneakyThrows - void serverShouldRejectClientWithoutCertificateWhenNeedClientAuthIsTrue() { - InputStream serverKeystoreStream = SslMutualTlsTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); - SSLContext serverSslContext = NetworkUtils.createSslContext(serverKeystoreStream, "test"); - ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); - BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); - - ServerNetwork serverNetwork = NetworkFactory.stringDataMtlsServerNetwork(serverConfig, bufferAllocator, serverSslContext); - - InetSocketAddress serverAddress = serverNetwork.start(); - CountDownLatch dataReceivedByServer = new CountDownLatch(1); - - serverNetwork - .accepted() - .flatMap(Connection::receivedEvents) - .subscribe(event -> dataReceivedByServer.countDown()); - - SSLContext clientWithoutCertContext = NetworkUtils.createAllTrustedClientSslContext(); - ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork( - NetworkConfig.DEFAULT_CLIENT, - new DefaultBufferAllocator(NetworkConfig.DEFAULT_CLIENT), - clientWithoutCertContext); - - try { - clientNetwork - .connectReactive(serverAddress) - .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("no cert"))) - .subscribe(); - - assertThat(dataReceivedByServer.await(5, TimeUnit.SECONDS)) - .as("Server must reject a client that presents no certificate when requireClientAuth=true.") - .isFalse(); - } finally { - serverNetwork.shutdown(); - clientNetwork.shutdown(); - } - } - - @Test - void plainTcpShouldWorkWhenNoSslContextConfigured() { - try (TestNetwork network = buildStringNetwork()) { - network.clientToServer.sendInBackground( - new javasabr.rlib.network.packet.impl.StringWritableNetworkPacket<>("plain TCP")); - assertThat(network.serverToClient - .receivedValidPackets() - .blockFirst(Duration.ofSeconds(5))) - .as("Plain TCP should still work when no SSL is configured") - .isNotNull(); - } - } -} diff --git a/rlib-network/src/test/java/javasabr/rlib/network/StringSslNetworkTest.java b/rlib-network/src/test/java/javasabr/rlib/network/StringSslNetworkTest.java index a91a4419..552de2d0 100644 --- a/rlib-network/src/test/java/javasabr/rlib/network/StringSslNetworkTest.java +++ b/rlib-network/src/test/java/javasabr/rlib/network/StringSslNetworkTest.java @@ -23,6 +23,7 @@ import javasabr.rlib.common.util.Utils; import javasabr.rlib.network.client.ClientNetwork; import javasabr.rlib.network.impl.DefaultBufferAllocator; +import javasabr.rlib.network.impl.StringDataMtlsServerConnection; import javasabr.rlib.network.impl.StringDataSslConnection; import javasabr.rlib.network.packet.ReadableNetworkPacket; import javasabr.rlib.network.packet.impl.StringReadableNetworkPacket; @@ -328,6 +329,46 @@ void shouldReceiveManyPacketsFromSmallToBigSize() { } } + @Test + @SneakyThrows + void shouldRejectClientWithoutCertificateWithinMutualTls() { + InputStream serverKeystoreFile = StringSslNetworkTest.class.getResourceAsStream("/ssl/rlib_test_cert.p12"); + SSLContext serverSslContext = NetworkUtils.createSslContext(serverKeystoreFile, "test"); + ServerNetworkConfig serverConfig = ServerNetworkConfig.SimpleServerNetworkConfig.builder().build(); + BufferAllocator bufferAllocator = new DefaultBufferAllocator(serverConfig); + + ServerNetwork serverNetwork = + NetworkFactory.stringDataMtlsServerNetwork(serverConfig, bufferAllocator, serverSslContext); + + InetSocketAddress serverAddress = serverNetwork.start(); + CountDownLatch dataReceivedByServer = new CountDownLatch(1); + + serverNetwork + .accepted() + .flatMap(Connection::receivedEvents) + .subscribe(event -> dataReceivedByServer.countDown()); + + SSLContext clientWithoutCertContext = NetworkUtils.createAllTrustedClientSslContext(); + ClientNetwork clientNetwork = NetworkFactory.stringDataSslClientNetwork( + NetworkConfig.DEFAULT_CLIENT, + new DefaultBufferAllocator(NetworkConfig.DEFAULT_CLIENT), + clientWithoutCertContext); + + try { + clientNetwork + .connectReactive(serverAddress) + .doOnNext(connection -> connection.sendInBackground(new StringWritableNetworkPacket<>("no cert"))) + .subscribe(); + + assertThat(dataReceivedByServer.await(5, TimeUnit.SECONDS)) + .as("Server must reject a client that presents no certificate when requireClientAuth=true.") + .isFalse(); + } finally { + serverNetwork.shutdown(); + clientNetwork.shutdown(); + } + } + private static StringWritableNetworkPacket newMessage(int minMessageLength, int maxMessageLength) { return new StringWritableNetworkPacket<>(StringUtils.generate(minMessageLength, maxMessageLength)); } diff --git a/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java b/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java index 5dce6747..47eb7482 100644 --- a/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java +++ b/rlib-network/src/test/java/javasabr/rlib/network/packet/impl/SslPacketReaderTest.java @@ -2,7 +2,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.nio.ByteBuffer; import java.util.concurrent.atomic.AtomicInteger; @@ -27,6 +29,11 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +/** + * The tests of SSL packet reader + * + * @author crazyrokr + */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) public class SslPacketReaderTest { diff --git a/rlib-network/src/test/resources/ssl/rlib_test_client_cert.p12 b/rlib-network/src/test/resources/ssl/rlib_test_client_cert.p12 deleted file mode 100644 index 87de0552a0a8d687ca6f2ba370bf40ffbc7f7701..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2595 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