Skip to content

Commit f0438b2

Browse files
jamesarichclaude
andcommitted
fix(transport): make TLS trust hook honest and tested
Addresses the final whole-branch review of the #102 TLS trust hook. - Android: rethrow X509TrustManagerExtensions' opaque IllegalArgumentException with the actual requirement and fix, so a hand-written X509TrustManager fails diagnosably instead of looping forever under autoReconnect. No silent fallback to the unwrapped caller manager. - Docs: correct the overclaim in README.md, transport-tcp/Module.md and the applyMqttTls KDoc. The wrapping keeps caller trust anchors subject to Android's network-security-config, pinning and CT policy; it is NOT RFC 6125 subject-name matching, which is ktor's and only when SNI is set, so absent for IP-literal brokers and absent entirely on JVM/native. - Add a defaulted platformTrust seam to applyMqttTls and a commonTest that pins caller-hook-before-platform-trust ordering on every target. - TcpTransport.configureTls is now internal so commonTest can assertSame that TcpTransportFactory.create() forwards the lambda. Not in the public ABI. - Drop the unresolvable [serverName] Dokka link and warn against setting serverName = null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4d95f8c commit f0438b2

7 files changed

Lines changed: 122 additions & 12 deletions

File tree

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,22 @@ client.connect(MqttEndpoint.parse("mqtts://broker.internal:8883"))
349349

350350
The hook is applied after the SNI server name is resolved and before platform trust is configured.
351351
On Android that ordering matters: your trust manager is *wrapped* by the hostname-aware trust
352-
manager rather than replacing it, so certificate hostname verification still happens.
352+
manager rather than replacing it, so your trust anchors remain subject to the platform's
353+
domain-specific `network_security_config.xml` rules, certificate pinning, and Certificate
354+
Transparency policy instead of replacing that policy wholesale.
355+
356+
Be clear about what the wrapping does *not* buy you. Android's hostname-aware
357+
`checkServerTrusted(chain, authType, hostname)` uses the hostname to look up that policy — it does
358+
not perform RFC 6125 subject-name matching. That check comes from ktor and only runs when the SNI
359+
server name is set, so it is absent for IP-literal brokers such as `mqtts://192.168.1.50:8883`. On
360+
JVM and native targets there is no platform trust wrapping at all, so ktor's SNI-gated
361+
subject-name check is the only peer-identity verification beyond chain validation. If your trust
362+
manager accepts any chain, nothing else will stop a mismatched certificate.
363+
364+
On Android the trust manager you install must be one the platform can wrap: obtain it from a
365+
`TrustManagerFactory` initialised with a `KeyStore` containing your CA, rather than hand-writing an
366+
`X509TrustManager`. A hand-written one that implements only the two-arg `checkServerTrusted` cannot
367+
be wrapped and the handshake fails with an `IllegalArgumentException` explaining this.
353368

354369
This scopes the extra trust to the MQTT connection alone. It replaces the app-wide workaround of
355370
adding `<certificates src="user"/>` to `network_security_config.xml`, which would affect every

transport-tcp/Module.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ val client = MqttClient("sensor") {
2424
```
2525

2626
The lambda is applied after the SNI server name is set and before platform trust configuration, so
27-
on Android a trust manager installed here is still wrapped in the hostname-aware delegate rather
28-
than replacing it. The added trust applies only to this MQTT connection — unlike Android's
27+
on Android a trust manager installed here is wrapped in the hostname-aware delegate rather than
28+
replacing it: the trust anchors you add stay subject to the platform's domain-specific
29+
`network_security_config.xml` rules, certificate pinning, and Certificate Transparency policy. That
30+
is all the wrapping buys — RFC 6125 subject-name matching comes from ktor and only when the SNI
31+
server name is set, so it does not happen for IP-literal brokers, and the platform policy checks do
32+
not happen at all on JVM or native targets. On Android the trust manager must be one the platform
33+
can wrap (obtained from a `TrustManagerFactory`, not hand-implemented) or the handshake fails.
34+
The added trust applies only to this MQTT connection — unlike Android's
2935
app-wide `network_security_config.xml` trust anchors. `trustManager` itself is available on the
3036
JVM and Android actuals of `TLSConfigBuilder`; on Apple, Linux, and Windows the lambda still runs,
3137
but `TLSConfigBuilder` exposes a different set of properties there.

transport-tcp/src/androidMain/kotlin/org/meshtastic/mqtt/transport/tcp/PlatformTls.android.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ import javax.net.ssl.X509TrustManager
3737
* the target host, so an IP-only broker (a common private-broker setup) hits the same
3838
* failure. The [host] — an IP literal or DNS name — is a valid argument for the 3-arg
3939
* overload even though an IP must never be sent as the TLS SNI server name.
40+
*
41+
* **Constraint on caller-supplied trust managers.** Whatever [X509TrustManager] is on the
42+
* builder must be one Android can wrap for hostname-aware checking. In practice that means it
43+
* must come from a [TrustManagerFactory] (which yields the platform's `TrustManagerImpl`), or
44+
* it must itself declare a `checkServerTrusted(X509Certificate[], String, String)` method.
45+
* A hand-written `X509TrustManager` that implements only the two-arg overloads cannot be
46+
* wrapped, and this function throws [IllegalArgumentException] rather than silently dropping
47+
* Android's policy checks. To trust a private CA, load it into a [KeyStore] and initialise a
48+
* [TrustManagerFactory] with that store.
49+
*
50+
* Note that the hostname passed to the 3-arg overload drives network-security-config lookup,
51+
* certificate pinning, and Certificate Transparency policy — it does **not** perform RFC 6125
52+
* subject-name matching. That comes from ktor and only when the SNI server name is set.
4053
*/
4154
internal actual fun TLSConfigBuilder.configurePlatformTrust(host: String) {
4255
if (host.isBlank()) return
@@ -48,7 +61,20 @@ internal actual fun TLSConfigBuilder.configurePlatformTrust(host: String) {
4861
tmf.trustManagers.filterIsInstance<X509TrustManager>().first()
4962
}
5063

51-
trustManager = HostnameAwareTrustManager(baseTm, host)
64+
trustManager =
65+
try {
66+
HostnameAwareTrustManager(baseTm, host)
67+
} catch (e: IllegalArgumentException) {
68+
throw IllegalArgumentException(
69+
"Android cannot wrap the configured X509TrustManager (${baseTm::class.java.name}) " +
70+
"for hostname-aware certificate checking. The trust manager must either be " +
71+
"obtained from TrustManagerFactory (which yields the platform TrustManagerImpl) " +
72+
"or declare checkServerTrusted(X509Certificate[], String, String). To trust a " +
73+
"private CA, load it into a KeyStore and initialise a TrustManagerFactory with " +
74+
"that KeyStore instead of hand-implementing X509TrustManager.",
75+
e,
76+
)
77+
}
5278
}
5379

5480
/**

transport-tcp/src/commonMain/kotlin/org/meshtastic/mqtt/transport/tcp/PlatformTls.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,39 @@ internal expect fun TLSConfigBuilder.configurePlatformTrust(host: String)
4242
*
4343
* This is the single call site for TLS setup, so the ordering below cannot drift:
4444
*
45-
* 1. [serverName] — the SNI value, `null` for IP literals (RFC 6066 §3 forbids them).
45+
* 1. `serverName` — the SNI value, `null` for IP literals (RFC 6066 §3 forbids them).
4646
* 2. [configureTls] — the caller's hook, so it can read or override the SNI value and
4747
* install its own trust manager (e.g. a private CA).
4848
* 3. [configurePlatformTrust] — reads whatever trust manager is now on the builder and,
4949
* on Android, wraps it in a hostname-aware delegate.
5050
*
5151
* Step 2 must precede step 3. Reversing them would let a caller-supplied trust manager
52-
* *replace* Android's `HostnameAwareTrustManager`, silently dropping the platform's 3-arg
53-
* `checkServerTrusted` hostname verification. Running the caller first composes instead:
54-
* a private-CA trust manager is still subject to the hostname check.
52+
* *replace* Android's `HostnameAwareTrustManager`, so the caller's trust anchors would no
53+
* longer be evaluated against the platform's domain-specific `network_security_config.xml`
54+
* rules, certificate pinning, or Certificate Transparency policy. Running the caller first
55+
* composes instead: a private-CA trust manager stays subject to those platform checks.
56+
*
57+
* Note what this ordering does *not* provide. RFC 6125 subject-name matching (verifying the
58+
* certificate actually names the host being contacted) comes from ktor, which performs it only
59+
* when `serverName` is non-`null` — so it is absent for IP-literal brokers. Android's 3-arg
60+
* `checkServerTrusted(chain, authType, hostname)` uses the hostname for config lookup, pinning,
61+
* and CT policy, not for subject-name matching. On JVM and native targets
62+
* [configurePlatformTrust] is a no-op, so no platform policy check happens there at all.
63+
*
64+
* The hook may read or replace `serverName`, but setting it to `null` disables ktor's
65+
* subject-name verification entirely — the only name matching this transport has on JVM and
66+
* native — so do not do that unless you verify the peer identity yourself.
5567
*
5668
* @param host the broker host (DNS name or IP literal), used for both SNI and trust evaluation.
5769
* @param configureTls optional caller customisation; `null` preserves the default behaviour.
70+
* @param platformTrust test seam for [configurePlatformTrust]; production callers use the default.
5871
*/
5972
internal fun TLSConfigBuilder.applyMqttTls(
6073
host: String,
6174
configureTls: (TLSConfigBuilder.() -> Unit)? = null,
75+
platformTrust: TLSConfigBuilder.(String) -> Unit = { configurePlatformTrust(it) },
6276
) {
6377
serverName = sniServerName(host)
6478
configureTls?.invoke(this)
65-
configurePlatformTrust(host)
79+
platformTrust(host)
6680
}

transport-tcp/src/commonMain/kotlin/org/meshtastic/mqtt/transport/tcp/TcpTransport.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import org.meshtastic.mqtt.packet.VariableByteInt
5656
* non-TLS endpoints.
5757
*/
5858
public class TcpTransport(
59-
private val configureTls: (TLSConfigBuilder.() -> Unit)? = null,
59+
internal val configureTls: (TLSConfigBuilder.() -> Unit)? = null,
6060
) : MqttTransport {
6161
private var socket: Socket? = null
6262
private var selectorManager: SelectorManager? = null
@@ -279,8 +279,10 @@ internal fun isIpLiteral(host: String): Boolean {
279279
*
280280
* @param configureTls optional hook applied to ktor's [TLSConfigBuilder] for every transport
281281
* this factory creates. It runs after the SNI server name is set and before platform trust
282-
* is configured, so on Android a trust manager installed here is still wrapped in the
283-
* hostname-aware delegate rather than replacing it.
282+
* is configured, so on Android a trust manager installed here is wrapped in the
283+
* hostname-aware delegate rather than replacing it — the caller's trust anchors stay subject
284+
* to the platform's network-security-config, pinning, and Certificate Transparency policy.
285+
* That wrapping is Android-only and is not subject-name matching; see [applyMqttTls].
284286
*/
285287
public class TcpTransportFactory(
286288
private val configureTls: (TLSConfigBuilder.() -> Unit)? = null,

transport-tcp/src/commonTest/kotlin/org/meshtastic/mqtt/transport/tcp/TcpTransportFactoryTlsTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@
1616
*/
1717
package org.meshtastic.mqtt.transport.tcp
1818

19+
import io.ktor.network.tls.TLSConfigBuilder
1920
import org.meshtastic.mqtt.MqttEndpoint
2021
import org.meshtastic.mqtt.plus
2122
import kotlin.test.Test
2223
import kotlin.test.assertFalse
2324
import kotlin.test.assertIs
25+
import kotlin.test.assertNull
26+
import kotlin.test.assertSame
2427
import kotlin.test.assertTrue
2528

2629
/**
@@ -46,6 +49,23 @@ class TcpTransportFactoryTlsTest {
4649
assertFalse(transport.isConnected)
4750
}
4851

52+
@Test
53+
fun factoryForwardsTlsHookToCreatedTransport() {
54+
// Guards against create() silently dropping configureTls: the transport must hold the
55+
// very lambda instance the factory was constructed with.
56+
val hook: TLSConfigBuilder.() -> Unit = { serverName = "override.example.com" }
57+
val transport = TcpTransportFactory(hook).create(MqttEndpoint.Tcp("broker.example.com", 8883, tls = true))
58+
assertIs<TcpTransport>(transport)
59+
assertSame(hook, transport.configureTls)
60+
}
61+
62+
@Test
63+
fun noArgFactoryLeavesTlsHookNull() {
64+
val transport = TcpTransportFactory().create(MqttEndpoint.Tcp("broker.example.com", 1883, tls = false))
65+
assertIs<TcpTransport>(transport)
66+
assertNull(transport.configureTls)
67+
}
68+
4969
@Test
5070
fun noArgFactoryStillWorks() {
5171
val factory = TcpTransportFactory()

transport-tcp/src/commonTest/kotlin/org/meshtastic/mqtt/transport/tcp/TcpTransportTlsTest.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,33 @@ class TcpTransportTlsTest {
101101
assertEquals("broker.example.com", observed)
102102
}
103103

104+
@Test
105+
fun applyMqttTlsRunsCallerHookBeforePlatformTrust() {
106+
// Security-relevant ordering: the caller hook must run BEFORE platform trust
107+
// configuration. Reversed, a caller-supplied trust manager would replace Android's
108+
// hostname-aware wrapper instead of being wrapped by it, dropping the platform's
109+
// network-security-config, pinning, and CT policy checks. The platformTrust seam
110+
// makes that order observable on every target.
111+
val order = mutableListOf<String>()
112+
val builder = TLSConfigBuilder()
113+
builder.applyMqttTls(
114+
host = "broker.example.com",
115+
configureTls = { order += "callerHook" },
116+
platformTrust = { order += "platformTrust" },
117+
)
118+
assertEquals(listOf("callerHook", "platformTrust"), order)
119+
}
120+
121+
@Test
122+
fun applyMqttTlsPassesTrustHostToPlatformTrustEvenWhenSniSuppressed() {
123+
// IP literals suppress SNI but must still reach the platform trust hook with the raw host.
124+
var trustHost: String? = null
125+
val builder = TLSConfigBuilder()
126+
builder.applyMqttTls(host = "192.168.1.50", platformTrust = { trustHost = it })
127+
assertNull(builder.serverName)
128+
assertEquals("192.168.1.50", trustHost)
129+
}
130+
104131
@Test
105132
fun applyMqttTlsWithoutHookStillSetsSni() {
106133
val builder = TLSConfigBuilder()

0 commit comments

Comments
 (0)