ktorio / ktorio/ktor

CertificatePinner (Darwin): pin match still fails with -1200 because completionHandler receives nil proposedCredential

Open
#5,871 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Kotlin
Stars
14.5k
Forks
1.3k
Avg merge
2d 13h
Merged PRs (30d)
49

Description

### Describe the bug

`CertificatePinner` (Darwin/iOS engine) is meant to let you pin a certificate that has no trusted CA chain (e.g. a self-signed cert) via `validateTrust(false)`. When the configured pin matches the presented certificate, `applyPinning()` returns `true` and `invoke()` does:

```kotlin
override fun invoke(
session: NSURLSession,
task: NSURLSessionTask,
challenge: NSURLAuthenticationChallenge,
completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit
) {
if (applyPinning(challenge)) {
completionHandler(NSURLSessionAuthChallengeUseCredential, challenge.proposedCredential)
} else {
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
}
}
```

`challenge.proposedCredential` is `nil` for a `NSURLAuthenticationMethodServerTrust` challenge — it's the *default credential for client authentication*, not something that asserts "trust this evaluated `SecTrustRef`". Passing `NSURLSessionAuthChallengeUseCredential` together with a `nil` credential does not actually make `NSURLSession` accept the connection: for a self-signed certificate (no valid chain), the OS's own default trust verdict is still "untrusted", so the request fails with `-1200` even though the pin genuinely matched.

The standard/correct pattern (see Apple's own docs and countless ObjC/Swift examples) is to build the credential from the trust object that was just evaluated, e.g.:

```swift
let credential = URLCredential(trust: trust) // trust == challenge.protectionSpace.serverTrust
completionHandler(.useCredential, credential)
```

### To reproduce

1. Stand up any HTTPS server presenting a self-signed certificate (no CA chain).
2. Configure a Darwin `HttpClient` with pinning and no trust validation, since the cert can't validate through the OS trust store:
```kotlin
HttpClient(Darwin) {
engine {
handleChallenge(
CertificatePinner.Builder()
.add(host, "sha256/")
.validateTrust(false)
.build()
)
}
}
```
3. Make a request to that host from an iOS app (device or Simulator — not engine/target-specific, since the flawed line runs unconditionally regardless of `validateTrust`).

### Expected behavior

Since the pin matches, the connection should succeed — that's the entire point of pinning a certificate with no valid chain.

### Actual behavior

I added temporary logging directly around the challenge handling to confirm what was happening:

```kotlin
handleChallenge { session, task, challenge, completionHandler ->
println("challenge received - host=${challenge.protectionSpace.host} authMethod=${challenge.protectionSpace.authenticationMethod}")
myCertificatePinner(session, task, challenge) { disposition, credential ->
println("challenge resolved - disposition=$disposition")
completionHandler(disposition, credential)
}
}
```

Output:
```
challenge received - host= authMethod=NSURLAuthenticationMethodServerTrust
challenge resolved - disposition=0 // NSURLSessionAuthChallengeUseCredential
```

Immediately followed by the actual request failing with:
```
Error Domain=NSURLErrorDomain Code=-1200 "A TLS error caused the secure connection to fail."
UserInfo={... NSErrorPeerCertificateChainKey=(), ...
NSUnderlyingError=... {Error Domain=kCFErrorDomainCFNetwork Code=-1200 ...
_kCFStreamErrorDomainKey=3, _kCFNetworkCFStreamSSLErrorOriginalValue=-9802,
_kCFStreamErrorCodeKey=-9802 ...}}
```

So `applyPinning()` genuinely returned `true` (disposition `0` = `UseCredential`) but the connection still fails with `errSSLXCertChainInvalid` (`-9802`) — confirming the credential handed to `completionHandler` isn't actually asserting trust for this connection.

### Attempted local workaround (also worth knowing about)

I tried patching this myself by rebuilding the credential from `challenge.protectionSpace.serverTrust`, e.g.:

```kotlin
val credential = challenge.protectionSpace.serverTrust?.let { NSURLCredential.credentialForTrust(it) }
```

With Kotlin 2.4.0's bundled `platform.Foundation`/`platform.Security` klibs, neither `NSURLProtectionSpace.serverTrust` nor an `NSURLCredential` trust-based factory resolves as a callable Kotlin member for either `iosArm64` or `iosSimulatorArm64` (confirmed identical binding metadata for both targets) — `Unresolved reference`. `ktor-client-darwin`'s own precompiled artifact still works at runtime referencing the same underlying ObjC symbol, since it was compiled against a different Kotlin/Native snapshot where the binding existed and a compiled klib doesn't need to re-resolve the symbol name locally. This may be relevant if the fix needs to avoid relying on a binding that isn't reliably available across recent Kotlin/Native versions.

### Ktor version

3.5.1 (the `invoke()` implementation quoted above is unchanged going back through at least 3.0.0/3.1.0, based on reading those sources too)

### Kotlin version

2.4.0

### Platform(s)

iOS (Darwin engine) — reproduced on `iosSimulatorArm64`; the flawed code path is unconditional in `invoke()`, so it applies equally to `iosArm64` (real device).

Contributor guide

Open the contributing guide

Research direction

Start at the Darwin CertificatePinner invoke() and applyPinning() entry points, then reproduce the self-signed certificate request described in the issue on an iOS target. Check the available Kotlin/Native Foundation and Security bindings for the challenge's server trust and credential handling. Done means a matching pin permits the connection while a non-matching pin retains default handling.

Written by the indexing model from the issue text.

Assessment

Tech stack
ios, kotlin
Domain
mobile-dev, networking, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.