fix(android): preserve TLS hostname context (#105072)

* fix(android): preserve TLS hostname context

Co-authored-by: whisky0809 <32197959+whisky0809@users.noreply.github.com>

* style(android): order TLS test imports

* style(android): group TLS test imports

---------

Co-authored-by: whisky0809 <32197959+whisky0809@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-12 08:32:03 +01:00
committed by GitHub
parent 9b057036e3
commit e4d8a2a55b
3 changed files with 174 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
import java.io.EOFException
import java.net.ConnectException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@@ -19,11 +20,13 @@ import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */
@@ -63,15 +66,27 @@ fun buildGatewayTlsConfig(
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig? {
if (params == null) return null
return buildGatewayTlsConfig(
params = params,
defaultTrust = defaultTrustManager(),
onStore = onStore,
)
}
internal fun buildGatewayTlsConfig(
params: GatewayTlsParams,
defaultTrust: X509TrustManager,
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig {
val expected =
params.expectedFingerprint
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
val defaultTrust = defaultTrustManager()
val usesPlatformTrust = expected == null && !params.allowTOFU
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509TrustManager {
object : X509ExtendedTrustManager() {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
@@ -79,6 +94,30 @@ fun buildGatewayTlsConfig(
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
if (defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkClientTrusted(chain, authType, socket)
} else {
checkClientTrusted(chain, authType)
}
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
if (defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkClientTrusted(chain, authType, engine)
} else {
checkClientTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
@@ -101,6 +140,31 @@ fun buildGatewayTlsConfig(
defaultTrust.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
if (usesPlatformTrust && defaultTrust is X509ExtendedTrustManager) {
// Preserve the connected hostname for Android's domain-aware platform trust manager.
defaultTrust.checkServerTrusted(chain, authType, socket)
} else {
checkServerTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
if (usesPlatformTrust && defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkServerTrusted(chain, authType, engine)
} else {
checkServerTrusted(chain, authType)
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
}
@@ -147,12 +211,24 @@ internal suspend fun probeGatewayTlsFingerprint(
val fingerprintRef = AtomicReference<String?>(null)
val probeTrustManager =
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
object : X509ExtendedTrustManager() {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("gateway TLS probe does not accept client certificates")
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = checkClientTrusted(chain, authType)
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
@@ -163,6 +239,18 @@ internal suspend fun probeGatewayTlsFingerprint(
throw CertificateException("gateway TLS probe captured fingerprint")
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = checkServerTrusted(chain, authType)
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = checkServerTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}

View File

@@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- This app is primarily used on a trusted tailnet; allow cleartext for IP-based endpoints too. -->
<!-- This app is primarily used on a trusted tailnet; allow cleartext for every endpoint. -->
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" />
<!-- Allow HTTP for tailnet/local dev endpoints (e.g. canvas/background web). -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">openclaw.local</domain>
</domain-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">ts.net</domain>
</domain-config>
</network-security-config>

View File

@@ -7,9 +7,43 @@ import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.X509ExtendedTrustManager
import kotlin.concurrent.thread
class GatewayTlsTest {
@Test
fun buildGatewayTlsConfig_forwardsPlatformTrustWithSocketAndEngineContext() {
val defaultTrust = RecordingExtendedTrustManager()
val config =
buildGatewayTlsConfig(
params =
GatewayTlsParams(
required = true,
expectedFingerprint = null,
allowTOFU = false,
stableId = "gateway-1",
),
defaultTrust = defaultTrust,
)
val extendedTrust = config.trustManager as X509ExtendedTrustManager
Socket().use { socket ->
extendedTrust.checkServerTrusted(emptyArray(), "RSA", socket)
}
extendedTrust.checkServerTrusted(
emptyArray(),
"RSA",
SSLContext.getDefault().createSSLEngine(),
)
assertEquals(1, defaultTrust.serverSocketCalls)
assertEquals(1, defaultTrust.serverEngineCalls)
assertEquals(0, defaultTrust.serverTwoArgumentCalls)
}
@Test
fun probeGatewayTlsFingerprint_reportsHandshakeTimeoutAfterTcpConnect() =
runBlocking {
@@ -109,6 +143,54 @@ class GatewayTlsTest {
}
}
private class RecordingExtendedTrustManager : X509ExtendedTrustManager() {
var serverTwoArgumentCalls = 0
var serverSocketCalls = 0
var serverEngineCalls = 0
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) = Unit
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = Unit
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = Unit
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
serverTwoArgumentCalls += 1
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
serverSocketCalls += 1
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
serverEngineCalls += 1
}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
private companion object {
const val LOOPBACK_HOST = "127.0.0.1"
val LOOPBACK_ADDRESS: InetAddress = InetAddress.getByName(LOOPBACK_HOST)