diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt index 36ef905e..64b769f7 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManager.kt @@ -19,9 +19,11 @@ import com.redhat.devtools.gateway.util.toServerBaseUrl import io.kubernetes.client.util.KubeConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.IOException import java.net.URI import java.security.cert.X509Certificate import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException class DefaultTlsTrustManager( @@ -29,7 +31,7 @@ class DefaultTlsTrustManager( private val kubeConfigWriter: suspend (KubeConfigNamedCluster, List) -> Unit, private val sessionTrustStore: SessionTlsTrustStore, private val persistentKeyStore: PersistentKeyStore, - private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsProbe.connect(uri, ctx.sslContext) }, + private val tlsProbe: (URI, TlsContext) -> Unit = { uri, ctx -> TlsConnectionProbe.connect(uri, ctx.sslContext) }, private val oauthDiscovery: suspend (String, SSLContext) -> List = { apiBaseUrl, sslContext -> OAuthDiscovery(apiBaseUrl, sslContext).endpointBaseUrls() } @@ -105,6 +107,10 @@ class DefaultTlsTrustManager( } catch (e: SSLHandshakeException) { thisLogger().debug("TLS trust: JVM CAs do not trust $serverUrl (${e.message})") null + } catch (e: SSLException) { + throw e + } catch (e: IOException) { + throwConnectionError(serverUrl, e) } } @@ -134,6 +140,10 @@ class DefaultTlsTrustManager( "TLS trust: handshake failed with known certificate(s) for $serverUrl; will prompt (${e.message})" ) null + } catch (e: SSLException) { + throw e + } catch (e: IOException) { + throwConnectionError(serverUrl, e) } } @@ -153,18 +163,32 @@ class DefaultTlsTrustManager( null // probe succeeded without throwing — no cert info, caller logs unexpected success } catch (e: SSLHandshakeException) { val chain = (captureContext.trustManager as? CapturingTrustManager) - ?.serverCertificateChain?.toList() ?: throw e + ?.serverCertificateChain?.toList() + ?.takeIf { it.isNotEmpty() } + ?: throw e val trustAnchor = chain.first() CapturedCertInfo( - problem = if (trustedCerts.isEmpty()) TlsTrustProblem.UNTRUSTED_CERTIFICATE - else TlsTrustProblem.CERTIFICATE_CHANGED, + problem = if (trustedCerts.isEmpty()) { + TlsTrustProblem.UNTRUSTED_CERTIFICATE + } else { + TlsTrustProblem.CERTIFICATE_CHANGED + }, chain = chain, trustAnchor = trustAnchor, ) + } catch (e: SSLException) { + throw e + } catch (e: IOException) { + throwConnectionError(serverUri.toString(), e) } } + private fun throwConnectionError(serverUrl: String, e: IOException): Nothing { + thisLogger().warn("TLS trust: connectivity failure probing $serverUrl (${e.message})") + throw IOException("Cannot connect to $serverUrl (check network / IDE HTTP proxy): ${e.message}", e) + } + private suspend fun persistAndVerifyAcceptedTrust( serverUrl: String, trustedCerts: List, @@ -201,7 +225,13 @@ class DefaultTlsTrustManager( val finalCerts = (trustedCerts + trustAnchor).distinctBy { it.serialNumber } val tlsContext = SslContextFactory.fromTrustedCerts(finalCerts) - withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) } + try { + withContext(Dispatchers.IO) { tlsProbe(serverUri, tlsContext) } + } catch (e: SSLException) { + throw e + } catch (e: IOException) { + throwConnectionError(serverUrl, e) + } thisLogger().info("TLS trust: verified connection to $serverUrl after user acceptance") return tlsContext } diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt new file mode 100644 index 00000000..76b1d743 --- /dev/null +++ b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbe.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025-2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import com.redhat.devtools.gateway.util.IdeHttpProxy +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.Authenticator +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.Socket +import java.net.URI +import java.nio.charset.StandardCharsets +import java.util.Base64 +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException +import javax.net.ssl.SSLSocket + +object TlsConnectionProbe { + + private const val DEFAULT_HTTPS_PORT = 443 + private const val TIMEOUT_MS = 30_000 + private const val MAX_LINE_BYTES = 8 * 1024 + internal const val MAX_HEADER_LINES = 100 + + fun connect( + serverUri: URI, + sslContext: SSLContext, + proxySelector: ProxySelector = IdeHttpProxy.proxySelector(), + ) { + val host = serverUri.host + ?: throw IOException("TLS probe URL has no host: $serverUri") + val port = if (serverUri.port != -1) { + serverUri.port + } else { + DEFAULT_HTTPS_PORT + } + val selectUri = URI("https", null, host, port, null, null, null) + val proxies = proxySelector.select(selectUri) + .ifEmpty { listOf(Proxy.NO_PROXY) } + + var lastException: IOException? = null + val connected = proxies.any { proxy -> + try { + connectViaProxy(host, port, sslContext, proxy) + true + } catch (e: SSLException) { + // Handshake / TLS failures must surface for trust capture; do not try another proxy. + throw e + } catch (e: IOException) { + lastException = e + false + } + } + if (!connected) { + throw IOException("TLS probe failed for $host:$port", lastException) + } + } + + private fun connectViaProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) { + when (proxy.type()) { + Proxy.Type.HTTP -> connectViaHttpProxy(host, port, sslContext, proxy) + Proxy.Type.SOCKS -> connectViaSocksOrDirect(host, port, sslContext, proxy) + else -> connectViaSocksOrDirect(host, port, sslContext, Proxy.NO_PROXY) + } + } + + private fun connectViaHttpProxy(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) { + val proxyAddress = proxy.address() as? InetSocketAddress + ?: throw IOException("HTTP proxy has no InetSocketAddress") + openTunneledSocket(host, port, proxyAddress, proxyAuthorization = null).use { plain -> + val status = readConnectStatus(plain.getInputStream()) + if (status == 200) { + handshakeOver(plain, host, port, sslContext) + return + } + if (status != 407) { + throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status") + } + } + + val authHeader = basicProxyAuthorization(proxyAddress, host, port) + ?: throw IOException("HTTP proxy $proxyAddress requires authentication (407)") + openTunneledSocket(host, port, proxyAddress, authHeader).use { plain -> + val status = readConnectStatus(plain.getInputStream()) + if (status != 200) { + throw IOException("HTTP CONNECT to $host:$port via $proxyAddress failed with status $status") + } + handshakeOver(plain, host, port, sslContext) + } + } + + private fun openTunneledSocket( + host: String, + port: Int, + proxyAddress: InetSocketAddress, + proxyAuthorization: String?, + ): Socket { + val socket = Socket() + socket.soTimeout = TIMEOUT_MS + socket.connect(InetSocketAddress(proxyAddress.hostString, proxyAddress.port), TIMEOUT_MS) + try { + writeConnectRequest(socket, host, port, proxyAuthorization) + return socket + } catch (e: IOException) { + socket.close() + throw e + } + } + + private fun connectViaSocksOrDirect(host: String, port: Int, sslContext: SSLContext, proxy: Proxy) { + Socket(proxy).use { plain -> + plain.soTimeout = TIMEOUT_MS + plain.connect(InetSocketAddress(host, port), TIMEOUT_MS) + handshakeOver(plain, host, port, sslContext) + } + } + + private fun writeConnectRequest(plain: Socket, host: String, port: Int, proxyAuthorization: String?) { + val request = buildString { + append("CONNECT $host:$port HTTP/1.1\r\n") + append("Host: $host:$port\r\n") + if (proxyAuthorization != null) { + append("Proxy-Authorization: $proxyAuthorization\r\n") + } + append("\r\n") + } + plain.getOutputStream().write(request.toByteArray(StandardCharsets.US_ASCII)) + plain.getOutputStream().flush() + } + + /** + * Reads the CONNECT response status and headers one byte at a time so we do not + * buffer TLS handshake bytes that follow a successful tunnel. + */ + private fun readConnectStatus(input: InputStream): Int { + val statusLine = readAsciiLine(input) + ?: throw IOException("HTTP CONNECT closed with no response") + val status = statusLine.split(' ').getOrNull(1)?.toIntOrNull() + ?: throw IOException("Malformed CONNECT response: $statusLine") + repeat(MAX_HEADER_LINES) { + val line = readAsciiLine(input) + if (line == null) throw IOException("CONNECT response closed before header terminator") + if (line.isEmpty()) return status + } + throw IOException("CONNECT response headers exceed limit or are unterminated") + } + + private fun readAsciiLine(input: InputStream): String? { + val buffer = ByteArrayOutputStream() + while (true) { + val b = input.read() + if (b == -1) { + return if (buffer.size() == 0) null else buffer.toString(StandardCharsets.US_ASCII) + } + if (b == '\n'.code) { + val bytes = buffer.toByteArray() + val end = if (bytes.isNotEmpty() && bytes.last() == '\r'.code.toByte()) bytes.size - 1 else bytes.size + return String(bytes, 0, end, StandardCharsets.US_ASCII) + } + if (buffer.size() >= MAX_LINE_BYTES) { + throw IOException("CONNECT response line exceeds $MAX_LINE_BYTES bytes") + } + buffer.write(b) + } + } + + private fun basicProxyAuthorization(proxyAddress: InetSocketAddress, host: String, port: Int): String? { + // IDE ProxySelectors often return unresolved addresses (address == null). + val proxyInetAddress = proxyAddress.address + ?: runCatching { InetAddress.getByName(proxyAddress.hostString) }.getOrNull() + val auth = Authenticator.requestPasswordAuthentication( + proxyAddress.hostString, + proxyInetAddress, + proxyAddress.port, + "https", + "", + "Basic", + URI("https", null, host, port, null, null, null).toURL(), + Authenticator.RequestorType.PROXY, + ) ?: return null + val password = auth.password + return try { + val token = Base64.getEncoder().encodeToString( + "${auth.userName}:${String(password)}".toByteArray(StandardCharsets.ISO_8859_1) + ) + "Basic $token" + } finally { + password.fill('\u0000') + } + } + + private fun handshakeOver(plain: Socket, host: String, port: Int, sslContext: SSLContext) { + val sslSocket = sslContext.socketFactory.createSocket(plain, host, port, true) as SSLSocket + sslSocket.soTimeout = TIMEOUT_MS + sslSocket.sslParameters = sslSocket.sslParameters.apply { + endpointIdentificationAlgorithm = "HTTPS" + } + sslSocket.use { it.startHandshake() } + } +} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt b/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt deleted file mode 100644 index 25515ef8..00000000 --- a/src/main/kotlin/com/redhat/devtools/gateway/auth/tls/TlsProbe.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2025-2026 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.redhat.devtools.gateway.auth.tls - -import java.net.URI -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocket - -object TlsProbe { - - private const val DEFAULT_HTTPS_PORT = 443 - - fun connect(serverUri: URI, sslContext: SSLContext) { - val socketFactory = sslContext.socketFactory - val port = if (serverUri.port != -1) serverUri.port else DEFAULT_HTTPS_PORT - - (socketFactory.createSocket(serverUri.host, port) as SSLSocket).use { socket -> - socket.startHandshake() - } - } -} diff --git a/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt b/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt index f8c8b819..e9b0bddd 100644 --- a/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt +++ b/src/main/kotlin/com/redhat/devtools/gateway/util/IdeHttpProxy.kt @@ -40,7 +40,7 @@ object IdeHttpProxy { } fun configure(builder: OkHttpClient.Builder): OkHttpClient.Builder = - configure(builder, ideProxySelector()) + configure(builder, proxySelector()) fun configure(builder: OkHttpClient.Builder, selector: ProxySelector): OkHttpClient.Builder = builder @@ -63,11 +63,11 @@ object IdeHttpProxy { * test scenarios that require a custom proxy configuration. * * @param builder the HTTP client builder to configure - * @param proxySelector optional proxy selector; defaults to the IDE proxy selector from [ideProxySelector] + * @param proxySelector optional proxy selector; defaults to the IDE proxy selector from [proxySelector] */ fun configure( builder: HttpClient.Builder, - proxySelector: ProxySelector = ideProxySelector(), + proxySelector: ProxySelector = this.proxySelector(), ): HttpClient.Builder { runCatching { JdkProxyProvider.ensureDefault() } .onFailure { thisLogger().warn("Failed to ensure default JDK proxy provider", it) } @@ -104,7 +104,12 @@ object IdeHttpProxy { } } - private fun ideProxySelector(): ProxySelector = + /** + * Returns the IDE-compatible [ProxySelector] from [JdkProxyProvider], + * falling back to the JVM default selector or a no-proxy selector when + * unavailable. + */ + fun proxySelector(): ProxySelector = runCatching { JdkProxyProvider.ensureDefault() JdkProxyProvider.getInstance().proxySelector diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt index 787cb46d..f2312e1b 100644 --- a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/DefaultTlsTrustManagerTrustTest.kt @@ -23,6 +23,9 @@ import com.redhat.devtools.gateway.auth.tls.TlsTrustManagerTestFixtures.successT import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException import javax.net.ssl.X509TrustManager class DefaultTlsTrustManagerTrustTest { @@ -289,4 +292,54 @@ class DefaultTlsTrustManagerTrustTest { .containsExactly(kubeCa.serialNumber) } } + + @Test + fun `#createTlsContext fails clearly on ConnectException without prompting`() { + runBlocking { + var prompted = false + val manager = createManager( + tlsProbe = { _, _ -> throw ConnectException("connection timed out") }, + ) + + val error = runCatching { + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + ) + }.exceptionOrNull() + + assertThat(prompted).isFalse() + assertThat(error).isInstanceOf(IOException::class.java) + assertThat(error!!.message).contains("Cannot connect").contains("IDE HTTP proxy") + assertThat(error.cause).isInstanceOf(ConnectException::class.java) + } + } + + @Test + fun `#createTlsContext fails clearly on SocketTimeoutException without prompting`() { + runBlocking { + var prompted = false + val manager = createManager( + tlsProbe = { _, _ -> throw SocketTimeoutException("read timed out") }, + ) + + val error = runCatching { + manager.createTlsContext( + API_SERVER_URL, + decisionHandler = { + prompted = true + TlsTrustDecision.sessionOnly() + }, + ) + }.exceptionOrNull() + + assertThat(prompted).isFalse() + assertThat(error).isInstanceOf(IOException::class.java) + assertThat(error!!.message).contains("Cannot connect").contains("IDE HTTP proxy") + assertThat(error.cause).isInstanceOf(SocketTimeoutException::class.java) + } + } } diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeDirectTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeDirectTest.kt new file mode 100644 index 00000000..5850cfeb --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeDirectTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.net.URI +import javax.net.ssl.SSLException + +class TlsConnectionProbeDirectTest { + + private val ctx = TlsConnectionProbeTestContext() + + @BeforeEach + fun setUp() { + ctx.captureAuthenticator() + } + + @AfterEach + fun tearDown() { + ctx.close() + } + + @Test + fun `#connect succeeds over DIRECT`() { + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect( + uri, + SslContextFactory.insecure().sslContext, + TlsConnectionProbeTestFixtures.directSelector() + ) + }.doesNotThrowAnyException() + } + + @Test + fun `#connect succeeds with matching hostname`() { + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx) + val uri = URI("https://localhost:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect( + uri, + SslContextFactory.insecure().sslContext, + TlsConnectionProbeTestFixtures.directSelector() + ) + }.doesNotThrowAnyException() + } + + @Test + fun `#connect fails with hostname mismatch`() { + val tlsServer = TlsConnectionProbeTestFixtures.startMismatchCertServer(ctx) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + val error = runCatching { + TlsConnectionProbe.connect( + uri, + SslContextFactory.insecure().sslContext, + TlsConnectionProbeTestFixtures.directSelector() + ) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(SSLException::class.java) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeProxyTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeProxyTest.kt new file mode 100644 index 00000000..f01e7c9c --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeProxyTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.Authenticator +import java.net.PasswordAuthentication +import java.net.URI +import java.util.concurrent.atomic.AtomicInteger + +class TlsConnectionProbeProxyTest { + + private val ctx = TlsConnectionProbeTestContext() + + @BeforeEach + fun setUp() { + ctx.captureAuthenticator() + } + + @AfterEach + fun tearDown() { + ctx.close() + } + + @Test + fun `#connect succeeds via HTTP CONNECT proxy`() { + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx) + val proxy = TlsConnectionProbeTestFixtures.startConnectProxy(ctx, tlsServer.localPort, requireAuth = false) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect( + uri, + SslContextFactory.insecure().sslContext, + TlsConnectionProbeTestFixtures.httpProxySelector(proxy.localPort) + ) + }.doesNotThrowAnyException() + } + + @Test + fun `#connect fails when CONNECT headers are unterminated`() { + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx) + val proxy = TlsConnectionProbeTestFixtures.startMalformedConnectProxy(ctx) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + val error = runCatching { + TlsConnectionProbe.connect( + uri, + SslContextFactory.insecure().sslContext, + TlsConnectionProbeTestFixtures.httpProxySelector(proxy.localPort) + ) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(IOException::class.java) + .hasMessageContaining("TLS probe failed") + val cause = error?.cause + assertThat(cause).isInstanceOf(IOException::class.java) + .hasMessageContaining("CONNECT response headers exceed limit or are unterminated") + } + + @Test + fun `#connect retries CONNECT with Basic proxy auth after 407`() { + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx) + val unauthorizedAttempts = AtomicInteger(0) + val proxy = TlsConnectionProbeTestFixtures.startConnectProxy(ctx, tlsServer.localPort, requireAuth = true, unauthorizedAttempts) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication = + PasswordAuthentication("proxy-user", "proxy-pass".toCharArray()) + }) + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + assertThatCode { + TlsConnectionProbe.connect( + uri, + SslContextFactory.insecure().sslContext, + TlsConnectionProbeTestFixtures.httpProxySelector(proxy.localPort) + ) + }.doesNotThrowAnyException() + assertThat(unauthorizedAttempts.get()).isGreaterThanOrEqualTo(1) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeRetryTest.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeRetryTest.kt new file mode 100644 index 00000000..0732f09a --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeRetryTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.SSLHandshakeException + +class TlsConnectionProbeRetryTest { + + private val ctx = TlsConnectionProbeTestContext() + + @BeforeEach + fun setUp() { + ctx.captureAuthenticator() + } + + @AfterEach + fun tearDown() { + ctx.close() + } + + @Test + fun `#connect propagates SSLHandshakeException after retrying a failed proxy`() { + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx) + val failProxy = TlsConnectionProbeTestFixtures.startAcceptThenCloseProxy(ctx) + val selector = object : ProxySelector() { + override fun select(uri: URI): List = listOf( + Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", failProxy.localPort)), + Proxy.NO_PROXY, + ) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} + } + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + val error = runCatching { + TlsConnectionProbe.connect( + uri, + SslContextFactory.fromSystemTrust().sslContext, + selector + ) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(SSLHandshakeException::class.java) + } + + @Test + fun `#connect does not try another proxy after SSLHandshakeException`() { + val handshakeAttempts = AtomicInteger(0) + val tlsServer = TlsConnectionProbeTestFixtures.startTlsServer(ctx, onHandshake = { handshakeAttempts.incrementAndGet() }) + val proxy = TlsConnectionProbeTestFixtures.startConnectProxy(ctx, tlsServer.localPort, requireAuth = false) + val selector = object : ProxySelector() { + override fun select(uri: URI): List = listOf( + Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", proxy.localPort)), + Proxy.NO_PROXY, + ) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) {} + } + val uri = URI("https://127.0.0.1:${tlsServer.localPort}") + + val error = runCatching { + TlsConnectionProbe.connect( + uri, + SslContextFactory.fromSystemTrust().sslContext, + selector + ) + }.exceptionOrNull() + + assertThat(error).isInstanceOf(SSLHandshakeException::class.java) + assertThat(handshakeAttempts.get()).isEqualTo(1) + } +} diff --git a/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTestFixtures.kt b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTestFixtures.kt new file mode 100644 index 00000000..b2460fae --- /dev/null +++ b/src/test/kotlin/com/redhat/devtools/gateway/auth/tls/TlsConnectionProbeTestFixtures.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2026 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.redhat.devtools.gateway.auth.tls + +import java.io.InputStream +import java.io.OutputStream +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketAddress +import java.net.URI +import java.nio.charset.StandardCharsets +import java.security.KeyStore +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.SSLSocket + +class TlsConnectionProbeTestContext { + + val executor: ExecutorService = Executors.newCachedThreadPool() + val sockets = CopyOnWriteArrayList() + val tasks = CopyOnWriteArrayList>() + var originalAuthenticator: Authenticator? = null + + fun captureAuthenticator() { + originalAuthenticator = Authenticator.getDefault() + } + + fun close() { + tasks.forEach { it.cancel(true) } + sockets.asReversed().forEach { runCatching { it.close() } } + executor.shutdownNow() + Authenticator.setDefault(originalAuthenticator) + } +} + +object TlsConnectionProbeTestFixtures { + + private val CHAR_ARRAY_EMPTY = CharArray(0) + + val SERVER_KEY_PEM = TlsConnectionProbeTestFixtures::class.java.classLoader + .getResourceAsStream("tls/server-key.pem")!!.readBytes().toString(Charsets.UTF_8) + + val SERVER_CERT_PEM = TlsConnectionProbeTestFixtures::class.java.classLoader + .getResourceAsStream("tls/server-cert.pem")!!.readBytes().toString(Charsets.UTF_8) + + val MISMATCH_KEY_PEM = TlsConnectionProbeTestFixtures::class.java.classLoader + .getResourceAsStream("tls/mismatch-key.pem")!!.readBytes().toString(Charsets.UTF_8) + + val MISMATCH_CERT_PEM = TlsConnectionProbeTestFixtures::class.java.classLoader + .getResourceAsStream("tls/mismatch-cert.pem")!!.readBytes().toString(Charsets.UTF_8) + + fun createServerSslContext(keyPem: String, certPem: String): SSLContext { + val key = PemUtils.parsePrivateKey(keyPem) + val cert = PemUtils.parseCertificate(certPem) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, null) + setKeyEntry("server", key, CHAR_ARRAY_EMPTY, arrayOf(cert)) + } + val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(keyStore, CHAR_ARRAY_EMPTY) + } + return SSLContext.getInstance("TLS").apply { + init(kmf.keyManagers, null, null) + } + } + + fun directSelector(): ProxySelector = object : ProxySelector() { + override fun select(uri: URI): List = listOf(Proxy.NO_PROXY) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: java.io.IOException) {} + } + + fun httpProxySelector(proxyPort: Int): ProxySelector = object : ProxySelector() { + override fun select(uri: URI): List = + listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", proxyPort))) + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: java.io.IOException) {} + } + + fun startTlsServer( + ctx: TlsConnectionProbeTestContext, + onHandshake: () -> Unit = {}, + ): SSLServerSocket { + val sslContext = createServerSslContext(SERVER_KEY_PEM, SERVER_CERT_PEM) + return startTlsServer(ctx, sslContext, onHandshake) + } + + fun startMismatchCertServer( + ctx: TlsConnectionProbeTestContext, + onHandshake: () -> Unit = {}, + ): SSLServerSocket { + val sslContext = createServerSslContext(MISMATCH_KEY_PEM, MISMATCH_CERT_PEM) + return startTlsServer(ctx, sslContext, onHandshake) + } + + private fun startTlsServer( + ctx: TlsConnectionProbeTestContext, + sslContext: SSLContext, + onHandshake: () -> Unit, + ): SSLServerSocket { + val server = sslContext.serverSocketFactory.createServerSocket(0) as SSLServerSocket + ctx.sockets += server + ctx.tasks += ctx.executor.submit { + while (!server.isClosed) { + try { + (server.accept() as SSLSocket).use { client -> + client.soTimeout = 5_000 + onHandshake() + client.startHandshake() + client.getInputStream().read() + } + } catch (_: Exception) { + break + } + } + } + return server + } + + fun startConnectProxy( + ctx: TlsConnectionProbeTestContext, + targetPort: Int, + requireAuth: Boolean, + unauthorizedAttempts: AtomicInteger = AtomicInteger(0), + ): ServerSocket { + val server = ServerSocket(0) + ctx.sockets += server + ctx.tasks += ctx.executor.submit { + while (!server.isClosed) { + try { + val client = server.accept() + ctx.tasks += ctx.executor.submit { + handleConnectClient(ctx, client, targetPort, requireAuth, unauthorizedAttempts) + } + } catch (_: Exception) { + break + } + } + } + return server + } + + fun startAcceptThenCloseProxy(ctx: TlsConnectionProbeTestContext): ServerSocket { + val server = ServerSocket(0) + ctx.sockets += server + ctx.tasks += ctx.executor.submit { + try { + server.accept().close() + } catch (_: Exception) {} + } + return server + } + + fun startMalformedConnectProxy(ctx: TlsConnectionProbeTestContext): ServerSocket { + val server = ServerSocket(0) + ctx.sockets += server + ctx.tasks += ctx.executor.submit { + while (!server.isClosed) { + try { + val client = server.accept() + ctx.tasks += ctx.executor.submit { + handleMalformedConnectClient(client) + } + } catch (_: Exception) { + break + } + } + } + return server + } + + private fun handleConnectClient( + ctx: TlsConnectionProbeTestContext, + client: Socket, + targetPort: Int, + requireAuth: Boolean, + unauthorizedAttempts: AtomicInteger, + ) { + client.use { proxyClient -> + proxyClient.soTimeout = 5_000 + val request = readHttpHeaders(proxyClient.getInputStream()) + val authorized = request.lines().any { + it.startsWith("Proxy-Authorization: Basic ", ignoreCase = true) + } + if (requireAuth && !authorized) { + unauthorizedAttempts.incrementAndGet() + proxyClient.getOutputStream().write( + "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"test\"\r\n\r\n" + .toByteArray(StandardCharsets.US_ASCII) + ) + proxyClient.getOutputStream().flush() + return + } + if (!request.startsWith("CONNECT ")) { + proxyClient.getOutputStream().write( + "HTTP/1.1 400 Bad Request\r\n\r\n".toByteArray(StandardCharsets.US_ASCII) + ) + return + } + Socket("127.0.0.1", targetPort).use { origin -> + proxyClient.getOutputStream().write( + "HTTP/1.1 200 Connection Established\r\n\r\n".toByteArray(StandardCharsets.US_ASCII) + ) + proxyClient.getOutputStream().flush() + val up = ctx.executor.submit { copy(proxyClient.getInputStream(), origin.getOutputStream()) } + try { + copy(origin.getInputStream(), proxyClient.getOutputStream()) + } finally { + up.cancel(true) + } + } + } + } + + private fun handleMalformedConnectClient(client: Socket) { + client.use { proxyClient -> + proxyClient.soTimeout = 5_000 + val request = readHttpHeaders(proxyClient.getInputStream()) + if (!request.startsWith("CONNECT ")) { + proxyClient.getOutputStream().write( + "HTTP/1.1 400 Bad Request\r\n\r\n".toByteArray(StandardCharsets.US_ASCII) + ) + return + } + val headers = buildString { + append("HTTP/1.1 200 Connection Established\r\n") + repeat(TlsConnectionProbe.MAX_HEADER_LINES + 1) { i -> + append("X-Custom-Header-$i: value\r\n") + } + } + proxyClient.getOutputStream().write(headers.toByteArray(StandardCharsets.US_ASCII)) + proxyClient.getOutputStream().flush() + } + } + + fun readHttpHeaders(input: InputStream): String { + val bytes = ArrayList() + var state = 0 + while (true) { + val b = input.read() + if (b == -1) break + bytes += b.toByte() + state = when { + state == 0 && b == '\r'.code -> 1 + state == 1 && b == '\n'.code -> 2 + state == 2 && b == '\r'.code -> 3 + state == 3 && b == '\n'.code -> break + else -> 0 + } + } + return String(bytes.toByteArray(), StandardCharsets.US_ASCII) + } + + fun copy(from: InputStream, to: OutputStream) { + val buffer = ByteArray(8_192) + while (true) { + val n = try { + from.read(buffer) + } catch (_: Exception) { + -1 + } + if (n < 0) break + to.write(buffer, 0, n) + to.flush() + } + } +} diff --git a/src/test/resources/tls/mismatch-cert.pem b/src/test/resources/tls/mismatch-cert.pem new file mode 100644 index 00000000..6133edde --- /dev/null +++ b/src/test/resources/tls/mismatch-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDCDCCAfCgAwIBAgIUU5FawByDw+WH5a0wYrou541sQZ8wDQYJKoZIhvcNAQEL +BQAwHTEbMBkGA1UEAwwSb3RoZXIuaG9zdC5pbnZhbGlkMB4XDTI2MDgxMTE5MjE0 +OFoXDTM2MDgwODE5MjE0OFowHTEbMBkGA1UEAwwSb3RoZXIuaG9zdC5pbnZhbGlk +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiqNK6zMbTzKMRvPbaPKn +oXDFd9z4oor7lrEz4pJVa2mUrrq/6ZSqQIxrt4JrNm6Mo19Pu3IoPNr0hQ4U7HwD +PPx2eIdwjHiEyN+tdjQ2gI/GJXyx9JJdimcdC358UPQaeR+/QQNVm7Zc/n7ogLrN +EKLqVBCUUk1oVD+kCDrFFQ3Du5W+oP4ahi2wMtwe66IfsO7XXxc7DX3/kpZzcSXM +uIPgdWkWTy7PrnbQLN+HV+wNkrtxGB8woE0uRgcP0q2njNnfzY7HnYzN+N8i/WaO +GYMAORglX2u/bv0b66RMHu8owBpOBFgoxC9p1wkSMEVcsly10mHMKtr73RYjWTIb +EwIDAQABo0AwPjAdBgNVHREEFjAUghJvdGhlci5ob3N0LmludmFsaWQwHQYDVR0O +BBYEFHR5k5X4uFgCgUXLPCSOCqs2lrZFMA0GCSqGSIb3DQEBCwUAA4IBAQBxIqbf +TQI9TU1ci23Eq+691e+2006WJnA0Jd0TCz9+nkqn5crns7BVbVjMJM7D8B4vTFrk +f/R88HCNnG5J3bVbaP/1m90FAiwpeZ9NBD1Z8Rh/ZlhI86e1KG5dj5jEHEhfuJHw +UW5ee3EPECSPjzu7x5LDLYpizTio3sPXQR+K2d0852JGob8LoTltfamFSkFl3SE9 +Y7MZ0uy4hAoB/3oDNLau9j/vq52VMCsw5ocOdtR/JChHevNwzLi1UmAwkPlwJQ5C +4L6+F5zKNlJogK4Ye9u4qtqGrl72k/4SiE2B9PprZGEmc5tuDRij48OVPZ1uhLjH +ReGRmdYPJJ/fcFwa +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/mismatch-key.pem b/src/test/resources/tls/mismatch-key.pem new file mode 100644 index 00000000..ed8e3d5b --- /dev/null +++ b/src/test/resources/tls/mismatch-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCKo0rrMxtPMoxG +89to8qehcMV33PiiivuWsTPiklVraZSuur/plKpAjGu3gms2boyjX0+7cig82vSF +DhTsfAM8/HZ4h3CMeITI3612NDaAj8YlfLH0kl2KZx0LfnxQ9Bp5H79BA1Wbtlz+ +fuiAus0QoupUEJRSTWhUP6QIOsUVDcO7lb6g/hqGLbAy3B7roh+w7tdfFzsNff+S +lnNxJcy4g+B1aRZPLs+udtAs34dX7A2Su3EYHzCgTS5GBw/SraeM2d/NjsedjM34 +3yL9Zo4ZgwA5GCVfa79u/RvrpEwe7yjAGk4EWCjEL2nXCRIwRVyyXLXSYcwq2vvd +FiNZMhsTAgMBAAECggEAGTMqVItDHQeyoNkFmlN1Le6qLinv1dfOeGs4Y6g2t6IG +4SXX6XTY54e+sqoVo9y/WpjiDM/oyXsBNEhFfGDkU8sOG8V+q0Hi3QJvbMt71q2L +llfOnwaV0ykACg1ROoPMZhTjtkUNZBLnX2khCPkreikqz+H9m+IvIxU4Uzifhz5n +Ru80JOAKVL3BUdoCTd8LEIyhkNqzsOhAGERDGnAsqk1gWo6zlgdgIzzjdZwZHP0N +dGqXQP1VNLAkaBu1id9iTe2V4fMfP94CfoD7A6RjfNOw5WzWy0jzH3vnJq/3hu+c +0bAqNhYkCGYueMzedLaLZMZbCyk3zGrBkhFsL+yhVQKBgQC9jRsr/c97+O3rxpwB +GnMgnz406fZB7sJFDRmV3sklxibGca+CGegEgjLrq7GwuwlDjyS44TVV/v3/3PW4 +f7fsbV9w3R0jwBpdDqWVb6Z1Jgg0hEWggc22fD5FEU5gwI7wH4gX076o2RGYxfNf +WOuuTuGQL0p92KYL9KLqh3cp7QKBgQC7PRIpa6idI1roapayS1jRPVe/1bBBxodb +ez12Q8yUq4KZLuhvU4ZLCUHjyOPapq6JjiqOfWjgnVkADREBPnXGhwfEQP/K2SH5 +gVqgLtS8YM5uHWCFOnWzPJWsFfUJqTN+teO8fIQt012Jkd4AbeCSZBaRG72/QlMR +AaW1ETG4/wKBgCGm0oMd3V8iE4M/UGT1wCizfMTCIKEV9VFzAO7VjZpyMmIavtD3 +LnTWdJnYcoRwwDVJTXixKtnomkl5ffpSWmHUDRSpfMnjCgspVYZROxkjNmO0c/QJ +926FYjy61NJifT3uKygnxHqPhws7YXi0/aqFgJArhSsQcnI+7Ul74VEFAoGBALhM +eKAEtEdYEZdLs2F0AMyUUNcmgwN7YqiNOgY63hhIaZhWL7vEvx8E0bJgfsct4gbL +xN/UWXMd7gi6084hqNPs/ZwTTrRf3a01wfqBnix+LTL9/4+j506xh7GHO1ZzEZjb +eHqDJaflsOBEj/xkKvRtSDk00SR9KD5JHM6JSiQJAoGAaZT40uRw5KGK/cEun5cc +h4s8t+xgs6Ykg4b/cBKoMcOqbPb/lgcjzCeuL6A7OREa1VgHHoQ9A+qCkRcYLeZr +ry4HkkoUjWDWBqUI+GaR4jGw7nskkRNcmEqLhIkC/gAwo4qc0bfmx3hdhyw1Ewnj +tyaEU5omDAMjltVQe3wweLE= +-----END PRIVATE KEY----- diff --git a/src/test/resources/tls/server-cert.pem b/src/test/resources/tls/server-cert.pem new file mode 100644 index 00000000..14c21b61 --- /dev/null +++ b/src/test/resources/tls/server-cert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC8zCCAdugAwIBAgIUUiPVjiQiVdPhIU1SHl2oihlrA9YwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgxMTE5MjAxNFoXDTM2MDgw +ODE5MjAxNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAvbUpugRqqJNFr5+L44xg+qDc536xFYUhIwHPQJbMkH7T +ODHwX4GlY0aZqYMHA8mOBpEL33hkZYRZ1+MYs3CytXHD1eyVMpTCTH8urbgettjk +h63B5/HMWjp1K5sDszlxgPHdsAANffpAfSTgC4MH+nDncqizT2TJw3hJSCxYVkRG +hAB+gAFBPVBBWuDyQWrWf10isRHhMEOs1s7PAhJKlvMPZih2QJSE1HnRdrYYoTff +iWSjhY3foHVLTdb0ukuWWwVVKE6SR6JL1P2oEjS5gc9fVjn8IPoMUW6uDJEmiugz +e1Cnm8zw17Mn3+WyFGqFnkHbf8RpGz/65OR+mFuhLwIDAQABoz0wOzAaBgNVHREE +EzARgglsb2NhbGhvc3SHBH8AAAEwHQYDVR0OBBYEFIe8h5m4JrN7jzgqFi6/+/Zm +gc4mMA0GCSqGSIb3DQEBCwUAA4IBAQA0OhYn78cZujVQS7qFK/18BOKMnbFirhmF +N2lR1Q0n0hfO0cPWbI5cV2xJqy+vWuDEH/L3CXiTaNQhy43b1ZPqETOiGiEyS19E ++lfVy6x6Y34diezItlzksRaB1oyOYQ4DOVSa06dLvjnJmMK7pKAWuDUu6Yy6wpG7 +VGJTI7Y4UqJPhLVeyymExI9VPKEMvT4ftkTXUDDKH7hYYoNQDOKSrbLco7hMfKxn +9R4alQt4wbrbMS5Pvn8B85ZjvLvbnQ7viKWI4ZWBS43xLh3vL46sgtxj6HyMOIQ1 +rQBLyLEyY3I46I+Y3dzG+SyDAF0mN9YvfmVWSibsoAzXHctfuQOj +-----END CERTIFICATE----- diff --git a/src/test/resources/tls/server-key.pem b/src/test/resources/tls/server-key.pem new file mode 100644 index 00000000..9625d4b5 --- /dev/null +++ b/src/test/resources/tls/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9tSm6BGqok0Wv +n4vjjGD6oNznfrEVhSEjAc9AlsyQftM4MfBfgaVjRpmpgwcDyY4GkQvfeGRlhFnX +4xizcLK1ccPV7JUylMJMfy6tuB622OSHrcHn8cxaOnUrmwOzOXGA8d2wAA19+kB9 +JOALgwf6cOdyqLNPZMnDeElILFhWREaEAH6AAUE9UEFa4PJBatZ/XSKxEeEwQ6zW +zs8CEkqW8w9mKHZAlITUedF2thihN9+JZKOFjd+gdUtN1vS6S5ZbBVUoTpJHokvU +/agSNLmBz19WOfwg+gxRbq4MkSaK6DN7UKebzPDXsyff5bIUaoWeQdt/xGkbP/rk +5H6YW6EvAgMBAAECggEAKENqi0IDtyqmuCsXICD7tkXtDK79e7FJQqpQKAe5fBOG +1d73gvW7ufHSjEE0fG/LVoZzbCcbu9YW0XSwGemZ7FuscltnoRgkkuLNb+5rimhH +PKq6xHUz55SPqMUaSHAxMGplIEUql+nExSrN6HOTfmp3HQVEJV4hWnzNpOVQjGJL +zEGxY7AUg7GM3D5Z3oLI70VMeKI7JZ8jLwgcbu4Rzdjdaqn6bTBmzQSHUisMUDK9 +yn0U+FKA7nt1/klMaOaAc0Z22oIjnae4jv8x8BBFHtZzaMnwmaVMnChQrN1lqs/v +r2yhvft2KO4Q9z7uZ/alHA+NFoHZMC4xULYxJSjzEQKBgQD+GEWHknzE9Ex0K2Fd +gIKFxTtEmABWFG594p+5BzbwPeersxrItBFnC6eNJa80r2J0/rmeLFKJie+KTUOj +Ij/XTSgGOkLKxxnEPlm5AAL3tVq89kbyXSsqHype3gUFqraPcmcczE8nDvQmj4yW +cFUPxGFtbZ8WSkoiVSFK6KSn/wKBgQC/IU1MK9rRcqd95uWt0sHXYkqoOC2M12rT +iN62lnDZkl86Jnr49YE/msRKndKoguqzjtuCQPTcLPNYVuW0JboPUjPhsHkn9KRt +XfCnrwkIhEVnPs4oPdCNv0tMBaj3zUWg2Bs9s7SrGwUltmwgXRrvNTKhu1UBpqsm +jZ5ELgGG0QKBgQDvpwzNFYjAanpBhzjJ648vhBqmU4bdqV+dM8XZycgzRu/erYqx +icRwIDnjkotxcYm3Nxjy6nLebk6TbtRAZOk5s/fwB7s/bhILWZNePXiPEWmHl/DK +Xr17ePGJkHXEJoMRQKGoM7rj5+K3C+VZbpGL0XjisOW/LEpHn2tLL6k9GQKBgCqs +2957GEOVzxIMgRH7se5Ocw72ZC1DWAXdVKT+GDpsNTj9qI13+6DhBJ0IeiXH/XGU +Hy0JJeMXYkpCNM2xcsbC+vSUe6jRNfl5dSdrrJ+rMPfpjQxtKOjNYpmZ1e1mdvp/ +5i0zt85QnSibf68GOuJYRXLZKhTRSvuzuTfN8edRAoGAXBrkv5LxnTmiWO1cVzqa +X/argB3Go3w6wZaO/WWEo3lA/IwChEkEXexqmt1e3QelMj5JcobvigZeibyULpQr +bi14OPnNUSmwPdoUu8VywXNyL4lsQmyrM0lDU9DggU8QmfH/Om8n812y4ZLZnK5L +An5d9Zd94OK4KZKKDtmSQJ4= +-----END PRIVATE KEY-----