Skip to content

Commit 1a9ad6a

Browse files
jamesarichclaude
andauthored
feat(transport): add TLS trust hook to TcpTransportFactory (#103)
* docs(transport): add TLS trust hook design for #102 Design for a caller-supplied TLS customisation lambda on TcpTransportFactory, so a private/self-signed CA can be trusted for the MQTT socket alone instead of app-wide via network_security_config.xml. - Adopts issue option 1 (factory lambda); rejects option 2 (MqttConfig) because it would pull ktor TLS types across the :core boundary, and option 3 (open class) as strictly worse. - Resolves a contradiction in the issue: the caller lambda must run BEFORE configurePlatformTrust for a caller trustManager to compose with Android's HostnameAwareTrustManager rather than replace it. - Notes the ktor-network-tls implementation -> api promotion and the apiDump regeneration it forces. Refs #102 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(transport): add TLS trust hook implementation plan Five-task TDD plan for #102: extract the applyMqttTls seam, expose the public configureTls hook plus the ktor-network-tls api promotion and ABI dumps, add the JVM trust-manager proof, document, then run the full gate. Refs #102 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(transport): fix overclaiming test names in TLS hook plan * refactor(transport): extract applyMqttTls TLS seam * feat(transport): add TLS trust hook to TcpTransportFactory * test(transport): cover TLS hook trust manager on JVM * docs(transport): document the TLS trust hook * 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> * fix(transport): restore no-arg klib ctors, narrow trust docs Addresses PR #103 review feedback. - Add explicit `constructor() : this(null)` to TcpTransport and TcpTransportFactory. The klib dump now carries `constructor <init>()` alongside the Function1 overload, so the native ABI change is additive rather than a replacement. The JVM dump is unchanged — no signature clash with the constructor Kotlin already synthesises for an all-defaults primary constructor. This contradicts the PR body's claim that the klib replacement was inherent and unfixable; it was not. - Narrow the platform-trust claim in PlatformTls.kt, TcpTransport.kt, README.md, and Module.md. Installing a trust manager REPLACES the platform's trust decision; network_security_config anchors, pinning, and CT policy then hold only insofar as that manager enforces them, and apply as before only when trustManager is left unset. The wrapping preserves the hostname-aware call path, not the platform's policy. X509TrustManagerExtensions also accepts any manager declaring the three-arg checkServerTrusted reflectively, so "must come from TrustManagerFactory" was too strong. - Module.md heading h3 -> h2; four-backtick outer fences in the plan doc so nested Kotlin fences stop terminating the block early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent dfeebb3 commit 1a9ad6a

13 files changed

Lines changed: 1312 additions & 13 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,64 @@ val config = MqttConfig(
332332

333333
Log levels from most to least verbose: `TRACE``DEBUG``INFO``WARN``ERROR``NONE`.
334334

335+
### Custom TLS trust
336+
337+
By default the TCP transport validates the broker certificate against the platform CA store. To
338+
reach a broker behind a private or self-signed CA, pass a TLS customisation lambda to
339+
`TcpTransportFactory`. It runs against ktor's `TLSConfigBuilder`:
340+
341+
```kotlin
342+
import org.meshtastic.mqtt.transport.tcp.TcpTransportFactory
343+
344+
val client = MqttClient("my-client") {
345+
transportFactory = TcpTransportFactory { trustManager = myPrivateCaTrustManager }
346+
}
347+
client.connect(MqttEndpoint.parse("mqtts://broker.internal:8883"))
348+
```
349+
350+
The hook is applied after the SNI server name is resolved and before platform trust is configured.
351+
On Android that ordering means your trust manager is reached through the hostname-aware
352+
`checkServerTrusted(chain, authType, hostname)` overload, which the platform requires whenever
353+
`network_security_config.xml` holds any domain-specific configuration — rather than being discarded
354+
in favour of the platform wrapper.
355+
356+
Be clear about what that does *not* buy you. Installing your own trust manager **replaces the
357+
platform's trust decision**: your anchors are used instead of the platform's, and
358+
`network_security_config.xml` anchors, certificate pinning, and Certificate Transparency policy are
359+
then enforced only insofar as your manager enforces them itself. Those platform policies apply as
360+
before only if you leave `trustManager` unset. The wrapping preserves the hostname-aware *call
361+
path*, not the platform's *policy*.
362+
363+
RFC 6125 subject-name matching is separate again. Android's 3-arg overload uses the hostname for
364+
policy lookup, not for subject-name matching; that check comes from ktor and only runs when the SNI
365+
server name is set, so it is absent for IP-literal brokers such as `mqtts://192.168.1.50:8883`. On
366+
JVM and native targets there is no platform trust wrapping at all, so ktor's SNI-gated subject-name
367+
check is the only peer-identity verification beyond chain validation. If your trust manager accepts
368+
any chain, nothing else will stop a mismatched certificate.
369+
370+
On Android the manager must be one `X509TrustManagerExtensions` can wrap: either obtained from a
371+
`TrustManagerFactory` initialised with a `KeyStore` containing your CA, or declaring the three-arg
372+
`checkServerTrusted(chain, authType, host)` that the platform looks up reflectively. A hand-written
373+
`X509TrustManager` implementing only the two-arg overload cannot be wrapped, and the handshake fails
374+
with an `IllegalArgumentException` explaining this.
375+
376+
This scopes the extra trust to the MQTT connection alone. It replaces the app-wide workaround of
377+
adding `<certificates src="user"/>` to `network_security_config.xml`, which would affect every
378+
HTTPS connection the app makes.
379+
380+
The hook composes with transport selection as usual:
381+
382+
```kotlin
383+
transportFactory = TcpTransportFactory { trustManager = myPrivateCaTrustManager } +
384+
WebSocketTransportFactory()
385+
```
386+
387+
`TLSConfigBuilder` comes from `io.ktor:ktor-network-tls`, exposed transitively by
388+
`mqtt-client-transport-tcp` — no extra dependency needed. The WebSocket transport has no equivalent
389+
hook yet. `trustManager` specifically is available on the JVM and Android actuals of
390+
`TLSConfigBuilder`; on Apple, Linux, and Windows targets the hook still runs, but `TLSConfigBuilder`
391+
exposes a different set of properties there.
392+
335393
## Android / KMP Integration
336394

337395
The library is designed as a drop-in MQTT client for KMP projects. Consumer ProGuard/R8 rules are bundled automatically.

docs/superpowers/plans/2026-07-25-tls-trust-hook.md

Lines changed: 691 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# TLS trust configuration hook for the TCP transport
2+
3+
Design for [issue #102](https://github.com/meshtastic/MQTTastic-Client-KMP/issues/102).
4+
5+
## Problem
6+
7+
A caller cannot influence TLS trust for the TCP transport. Connecting to a broker whose
8+
certificate chain is anchored in a private or self-signed CA — the common self-hosted case —
9+
is therefore impossible without replacing the transport wholesale.
10+
11+
As of 0.5.0 there is no seam:
12+
13+
- `TcpTransportFactory` is `final` with a no-arg constructor; `TcpTransport` is `final` too.
14+
- `MqttConfig.Builder` exposes no TLS property.
15+
- `MqttEndpoint.Tcp(host, port, tls: Boolean)` carries only an on/off flag.
16+
- ktor's `TLSConfigBuilder` is configured in a private lambda inside `TcpTransport.connect`
17+
(`transport-tcp/src/commonMain/kotlin/org/meshtastic/mqtt/transport/tcp/TcpTransport.kt:102-108`),
18+
so it never reaches the caller.
19+
20+
The only workaround available to an Android consumer is to opt the *entire app* into trusting
21+
user-installed CAs via `network_security_config.xml`, which applies to every HTTPS connection
22+
the app makes rather than just the MQTT socket. The alternative — reimplementing
23+
`MqttTransport` + `MqttTransportFactory` in the application — duplicates packet framing and
24+
reconnect handling in every consumer that needs custom trust.
25+
26+
## Approach
27+
28+
Add an optional TLS-customisation lambda to `TcpTransportFactory`, threaded through to
29+
`TcpTransport`. This is option 1 from the issue.
30+
31+
The issue's option 2 (a unified `MqttConfig.Builder.tlsConfig {}`) was rejected: it would pull
32+
ktor TLS types into `:core`, breaking the transport-free boundary that ADR-0006 establishes and
33+
that `core/build.gradle.kts`'s `verifyModuleBoundary` check plus the Konsist suite enforce.
34+
There is also no shared lambda type to unify on — `:transport-ws` configures a ktor `HttpClient`
35+
and never touches `TLSConfigBuilder`. Option 3 (making `TcpTransport` `open`) was rejected as a
36+
strictly worse version of option 1.
37+
38+
## Public API
39+
40+
```kotlin
41+
public class TcpTransportFactory(
42+
private val configureTls: (TLSConfigBuilder.() -> Unit)? = null,
43+
) : MqttTransportFactory {
44+
override fun supports(endpoint: MqttEndpoint): Boolean = endpoint is MqttEndpoint.Tcp
45+
46+
override fun create(endpoint: MqttEndpoint): MqttTransport = TcpTransport(configureTls)
47+
}
48+
49+
public class TcpTransport(
50+
private val configureTls: (TLSConfigBuilder.() -> Unit)? = null,
51+
) : MqttTransport
52+
```
53+
54+
Both parameters are defaulted, so existing call sites are untouched. The hook composes with the
55+
existing factory `+` operator:
56+
57+
```kotlin
58+
transportFactory = TcpTransportFactory { trustManager = myTrustManager } + WebSocketTransportFactory()
59+
```
60+
61+
`TcpTransport`'s constructor parameter is public for symmetry and for callers who construct the
62+
transport directly, but the factory is the intended entry point.
63+
64+
## The seam
65+
66+
The TLS setup currently inlined in `TcpTransport.connect` moves into a single internal function:
67+
68+
```kotlin
69+
internal fun TLSConfigBuilder.applyMqttTls(
70+
host: String,
71+
configureTls: (TLSConfigBuilder.() -> Unit)?,
72+
) {
73+
serverName = sniServerName(host)
74+
configureTls?.invoke(this) // caller first…
75+
configurePlatformTrust(host) // …so Android wraps their trustManager, not the reverse
76+
}
77+
```
78+
79+
`connect` then calls `applyMqttTls(endpoint.host, configureTls)` inside `rawSocket.tls(tlsContext) { … }`.
80+
81+
### Ordering: caller lambda runs *before* `configurePlatformTrust`
82+
83+
This corrects a contradiction in the issue text, which asks for the hook to run *after* the
84+
platform defaults while also stating that a caller-supplied trust manager would "compose with
85+
Android's hostname-aware checking rather than bypass it." Only one of those is achievable.
86+
87+
`configurePlatformTrust` on Android
88+
(`transport-tcp/src/androidMain/kotlin/org/meshtastic/mqtt/transport/tcp/PlatformTls.android.kt:41-52`)
89+
reads `trustManager` off the builder, falling back to the platform default, and wraps whatever it
90+
finds in `HostnameAwareTrustManager`. Composition therefore requires the caller's assignment to
91+
already be present — i.e. the caller's lambda must run first. Running it last would replace
92+
`HostnameAwareTrustManager` outright and silently drop Android's 3-arg hostname verification.
93+
94+
Running the caller first delivers the intended behaviour: a private-CA trust manager is still
95+
subject to the platform's hostname-aware check.
96+
97+
Ordering is enforced structurally rather than by convention — `applyMqttTls` is the only place the
98+
order can be expressed, so there is no second call site to drift.
99+
100+
`serverName` remains first and is not exposed for override beyond what the lambda can already do;
101+
a caller may reassign it inside the lambda if they need to.
102+
103+
## Build and compatibility
104+
105+
- `transport-tcp/build.gradle.kts`: `libs.ktor.network.tls` moves from `implementation` to `api`.
106+
A public signature now names `TLSConfigBuilder`, so consumers need it on their compile classpath.
107+
`libs.ktor.network` stays `implementation`.
108+
- `./gradlew apiDump` regenerates `transport-tcp/api/transport-tcp.klib.api` and the JVM dump.
109+
Both are committed.
110+
- Binary compatibility holds. Kotlin emits a zero-arg constructor for an all-defaults constructor,
111+
so already-compiled callers of `TcpTransportFactory()` keep linking.
112+
- No change to `:core`, `:transport-ws`, or the BOM.
113+
114+
## Testing
115+
116+
Extends the pure-function style already in
117+
`transport-tcp/src/commonTest/kotlin/org/meshtastic/mqtt/transport/tcp/TcpTransportTlsTest.kt`.
118+
`TLSConfigBuilder` is directly instantiable in common code, so no broker or socket is needed.
119+
120+
`commonTest`:
121+
122+
- `applyMqttTls` sets `serverName` to the host for a DNS name and to `null` for IPv4 and IPv6
123+
literals — the existing SNI-suppression guarantee, now asserted through the new entry point.
124+
- The lambda is invoked exactly once (recorder counter).
125+
- A `null` lambda is a no-op and leaves `serverName` behaviour unchanged.
126+
- `TcpTransportFactory { }` still reports `supports()` correctly, `create()` returns a
127+
`TcpTransport`, and the instance composes with `WebSocketTransportFactory` via `+`.
128+
129+
`jvmTest`:
130+
131+
- A `trustManager` assigned inside the lambda is still the builder's `trustManager` afterwards.
132+
JVM's `configurePlatformTrust` is a no-op, so this proves the hook reaches ktor's real builder
133+
state rather than a discarded copy.
134+
135+
Not covered: the Android wrapping order. `X509TrustManagerExtensions` is an Android framework
136+
class, so asserting that a caller's manager ends up inside `HostnameAwareTrustManager` requires an
137+
instrumentation test. That is out of scope; the single-call-site structure of `applyMqttTls` is the
138+
mitigation.
139+
140+
Existing `koverVerify` (≥80%), `detekt`, `spotlessCheck`, and `apiCheck` gates all apply.
141+
142+
## Documentation
143+
144+
- `transport-tcp/Module.md` — a private-CA usage snippet, and a note that the caller's
145+
configuration is applied before platform trust so Android hostname verification is preserved.
146+
- `README.md` TLS section — a pointer to the hook, framed as the replacement for the app-wide
147+
`<certificates src="user"/>` workaround.
148+
- `AGENTS.md` — no change needed; the public-surface list already covers transport modules
149+
generically via "Transport modules add `TcpTransport`/`TcpTransportFactory`…".
150+
151+
## Out of scope (YAGNI)
152+
153+
- No `host` parameter on the lambda — a factory can close over whatever it needs.
154+
- No second, post-platform-trust seam.
155+
- No WebSocket equivalent. `:transport-ws` would need a differently-typed `HttpClient` hook; it can
156+
be added later without disturbing this API.
157+
- No `MqttConfig`-level plumbing or transport-neutral trust abstraction in `:core`.

transport-tcp/Module.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,40 @@ val client = MqttClient("sensor") {
1212
client.connect(MqttEndpoint.Tcp("broker.example.com", port = 8883, tls = true))
1313
```
1414

15+
## Trusting a private CA
16+
17+
If the broker's certificate is issued by a private or self-signed CA that is not in the platform
18+
trust store, pass a TLS customisation lambda. It receives ktor's `TLSConfigBuilder`:
19+
20+
```kotlin
21+
val client = MqttClient("sensor") {
22+
transportFactory = TcpTransportFactory { trustManager = myTrustManager }
23+
}
24+
```
25+
26+
The lambda is applied after the SNI server name is set and before platform trust configuration. On
27+
Android that ordering means the trust manager on the builder is reached through the hostname-aware
28+
`checkServerTrusted(chain, authType, hostname)` overload, which the platform requires whenever
29+
`network_security_config.xml` holds any domain-specific configuration.
30+
31+
Be precise about what that does *not* buy you. Installing your own trust manager **replaces the
32+
platform's trust decision**: your anchors are used instead of the platform's, and
33+
`network_security_config.xml` anchors, certificate pinning, and Certificate Transparency policy are
34+
then enforced only insofar as your manager enforces them itself. Those platform policies apply as
35+
before only if you leave `trustManager` unset. Wrapping preserves the hostname-aware *call path*,
36+
not the platform's *policy*. RFC 6125 subject-name matching is separate again — it comes from ktor
37+
and only when the SNI server name is set, so it does not happen for IP-literal brokers, and no
38+
platform wrapping happens at all on JVM or native targets.
39+
40+
On Android the manager must be one `X509TrustManagerExtensions` can wrap: either obtained from a
41+
`TrustManagerFactory`, or declaring the three-arg `checkServerTrusted(chain, authType, host)` that
42+
the platform looks up reflectively. Otherwise the handshake fails with an `IllegalArgumentException`
43+
explaining this.
44+
45+
The added trust applies only to this MQTT connection — unlike Android's app-wide
46+
`network_security_config.xml` trust anchors. `trustManager` itself is available on the JVM and
47+
Android actuals of `TLSConfigBuilder`; on Apple, Linux, and Windows the lambda still runs, but
48+
`TLSConfigBuilder` exposes a different set of properties there.
49+
1550
Available on JVM, Android, iOS, macOS, Linux, and Windows. Not available on the browser (wasmJs) —
1651
use `mqtt-client-transport-ws` there.

transport-tcp/api/jvm/transport-tcp.api

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
public final class org/meshtastic/mqtt/transport/tcp/TcpTransport : org/meshtastic/mqtt/MqttTransport {
22
public static final field MAX_PACKET_REMAINING_LENGTH I
33
public fun <init> ()V
4+
public fun <init> (Lkotlin/jvm/functions/Function1;)V
5+
public synthetic fun <init> (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
46
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
57
public fun connect (Lorg/meshtastic/mqtt/MqttEndpoint;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
68
public fun isConnected ()Z
@@ -10,6 +12,8 @@ public final class org/meshtastic/mqtt/transport/tcp/TcpTransport : org/meshtast
1012

1113
public final class org/meshtastic/mqtt/transport/tcp/TcpTransportFactory : org/meshtastic/mqtt/MqttTransportFactory {
1214
public fun <init> ()V
15+
public fun <init> (Lkotlin/jvm/functions/Function1;)V
16+
public synthetic fun <init> (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
1317
public fun create (Lorg/meshtastic/mqtt/MqttEndpoint;)Lorg/meshtastic/mqtt/MqttTransport;
1418
public fun supports (Lorg/meshtastic/mqtt/MqttEndpoint;)Z
1519
}

transport-tcp/api/transport-tcp.klib.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// Library unique name: <MQTTastic-Client-KMP:transport-tcp>
99
final class org.meshtastic.mqtt.transport.tcp/TcpTransport : org.meshtastic.mqtt/MqttTransport { // org.meshtastic.mqtt.transport.tcp/TcpTransport|null[0]
1010
constructor <init>() // org.meshtastic.mqtt.transport.tcp/TcpTransport.<init>|<init>(){}[0]
11+
constructor <init>(kotlin/Function1<io.ktor.network.tls/TLSConfigBuilder, kotlin/Unit>? = ...) // org.meshtastic.mqtt.transport.tcp/TcpTransport.<init>|<init>(kotlin.Function1<io.ktor.network.tls.TLSConfigBuilder,kotlin.Unit>?){}[0]
1112

1213
final val isConnected // org.meshtastic.mqtt.transport.tcp/TcpTransport.isConnected|{}isConnected[0]
1314
final fun <get-isConnected>(): kotlin/Boolean // org.meshtastic.mqtt.transport.tcp/TcpTransport.isConnected.<get-isConnected>|<get-isConnected>(){}[0]
@@ -20,6 +21,7 @@ final class org.meshtastic.mqtt.transport.tcp/TcpTransport : org.meshtastic.mqtt
2021

2122
final class org.meshtastic.mqtt.transport.tcp/TcpTransportFactory : org.meshtastic.mqtt/MqttTransportFactory { // org.meshtastic.mqtt.transport.tcp/TcpTransportFactory|null[0]
2223
constructor <init>() // org.meshtastic.mqtt.transport.tcp/TcpTransportFactory.<init>|<init>(){}[0]
24+
constructor <init>(kotlin/Function1<io.ktor.network.tls/TLSConfigBuilder, kotlin/Unit>? = ...) // org.meshtastic.mqtt.transport.tcp/TcpTransportFactory.<init>|<init>(kotlin.Function1<io.ktor.network.tls.TLSConfigBuilder,kotlin.Unit>?){}[0]
2325

2426
final fun create(org.meshtastic.mqtt/MqttEndpoint): org.meshtastic.mqtt/MqttTransport // org.meshtastic.mqtt.transport.tcp/TcpTransportFactory.create|create(org.meshtastic.mqtt.MqttEndpoint){}[0]
2527
final fun supports(org.meshtastic.mqtt/MqttEndpoint): kotlin/Boolean // org.meshtastic.mqtt.transport.tcp/TcpTransportFactory.supports|supports(org.meshtastic.mqtt.MqttEndpoint){}[0]

transport-tcp/build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ kotlin {
4444
commonMain.dependencies {
4545
api(project(":core"))
4646
implementation(libs.ktor.network)
47-
implementation(libs.ktor.network.tls)
47+
// api, not implementation: TLSConfigBuilder appears in TcpTransportFactory's
48+
// public constructor signature, so consumers need it on their compile classpath.
49+
api(libs.ktor.network.tls)
4850
}
4951
}
5052
}

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
/**

0 commit comments

Comments
 (0)