Engineering

Certificate pinning has no shared implementation, and should not

Each engine pins through its own trust machinery, and one cannot pin at all.

MobileByteSensei 11 min read
kmpsecuritynetworking

TL;DR

  • A certificatePinner block the security review counted as "pinning enabled" that was enforced on exactly one of four engines. The other three shipped with an empty engine config, and a proxy holding a trusted-CA certificate for our own host was answered with 200 OK on all three.
  • A proxied-traffic test is the only check that distinguishes them, and it had never been run on anything but Android.
  • The fix declares pinning per engine and returns the set of platforms where it could not be applied, so an unpinnable target is a logged, asserted fact rather than a silent gap.
  • What reading on buys: the four engines' actual behaviour under a MITM proxy, why the browser cannot pin, and the one assertion that makes the gap impossible to ship unnoticed.

The case this post is about

A shared HTTP client with pinning configured once, in commonMain:

// commonMain/kotlin/network/Client.kt
fun httpClient(engine: HttpClientEngine) = HttpClient(engine) {
    install(ContentNegotiation) { json() }

    engine {
        // what we believed was a cross-platform pin
        pipelining = true
    }
}

And the pinning itself, added in the Android source set because that is where the example code was, with a comment that turned out to be the whole problem:

// androidMain/kotlin/network/Engine.android.kt
actual fun platformEngine(): HttpClientEngine = OkHttp.create {
    config {
        certificatePinner(
            CertificatePinner.Builder()
                .add("api.example.com", "sha256/K8f3…=", "sha256/Lr29…=")   // leaf + backup
                .build()
        )
    }
}
// iosMain, wasmJsMain, desktopMain — the actuals that shipped
actual fun platformEngine(): HttpClientEngine = Darwin.create { }
actual fun platformEngine(): HttpClientEngine = Js.create { }
actual fun platformEngine(): HttpClientEngine = CIO.create { }

Three engines with an empty configuration block. The security review had asked "is pinning enabled?" and the honest answer given was yes — it was, in the file somebody opened.

The symptom

A penetration test, scoped to all four platforms, and a one-line finding:

F-03  TLS interception succeeds on iOS, web and desktop clients.
      Android client correctly refuses the proxied connection.

Reproducing it took a proxy and four builds:

$ # mitmproxy with its CA installed in the device/host trust store
                            request reached the proxy?   client behaviour
  android (OkHttp)                   no                  SSLPeerUnverifiedException
  ios (Darwin)                       YES                 200 OK, body read
  web (Js)                           YES                 200 OK, body read
  desktop (CIO)                      YES                 200 OK, body read

Three of four clients happily spoke to a proxy holding a certificate for api.example.com signed by a CA that was in the trust store and is not ours. Which is exactly what TLS is specified to do and exactly what pinning exists to prevent.

A penetration test pointing a TLS-intercepting proxy at all four clients and finding that only the Android one refuses the connection, while the iOS, web and desktop clients complete the request and read the body

Why the obvious fix failed

The first attempt was to move the pin into commonMain so all four engines would get it:

// commonMain — does not compile
engine {
    certificatePinner(...)      // e: Unresolved reference: certificatePinner
}

certificatePinner is an OkHttp API, reachable only from the OkHttp engine's config. Ktor's HttpClientEngineConfig has no pinning surface, because pinning is not something Ktor implements — it is delegated entirely to whatever does the TLS.

The second attempt was Ktor's own TLS configuration, which exists for CIO:

// desktopMain
CIO.create {
    https {
        trustManager = pinningTrustManager(pins)     // compiles, and read the next paragraph
    }
}

That compiles and it did change behaviour — the proxied request now failed on desktop. It also failed against the real server, intermittently, because the trust manager I had written validated the leaf certificate's public key and the server was behind a load balancer presenting one of two valid certificates. A pin set that omits a rotation target is an outage waiting for a certificate renewal, and mine omitted one of the two.

The third attempt was the browser, and this is where the design had to change. There is no API:

// wasmJsMain — there is nothing to write here
Js.create {
    // fetch() does not expose certificate validation. The browser owns the TLS stack,
    // the trust store is the user's, and a page cannot inspect or constrain the chain.
}

A web page cannot pin. Not "it is awkward" — the capability is deliberately absent, because a page is untrusted code and the browser's trust decisions are the user's. So any design whose correctness depends on all four targets pinning is a design that cannot be satisfied, and pretending otherwise is what produced the finding.

The actual mechanism

Pinning is an assertion about the certificate chain, and the chain is validated by whatever owns the TLS session — which is a different component on each target.

OkHttp owns its own TLS and exposes the hook. CertificatePinner is checked after the platform trust manager has accepted the chain, so it is an additional constraint. Pins are SPKI hashes and the API takes several, which is what makes rotation survivable.

Darwin's TLS belongs to NSURLSession. There is no pinning parameter; the mechanism is an authentication-challenge delegate — you are handed the server's trust object and decide. Ktor's Darwin engine exposes it, and the shape is a callback rather than a configuration value, so a "configure pinning" API that returns Unit has nowhere to put it.

CIO implements TLS in Kotlin and takes a trust manager. Pinning is expressible and is your code, including the parts that are easy to get wrong — which chain element to pin, whether to accept a backup, what to do when the chain is longer than expected.

And the browser's TLS is the browser's. fetch() has no certificate surface at all. The trust store is the user's, and the deliberate absence of this capability is a security property of the web platform rather than a gap in it.

So a single shared pinning API has three possible shapes and all of them lie about one target: Unit return implies success everywhere, a boolean implies a per-call answer that cannot be produced before a request is made, and a throwing version makes the browser target unusable.

The shape that does not lie returns what it could not do.

Who owns the TLS session on each target and what shape its pinning hook takes, showing a configuration value on one engine, a challenge callback on another, your own trust manager on a third, and no surface at all in a browser

The fix

Configure pinning per engine, and have the configuration report the platforms where it could not be applied — so the gap is a value the application asserts on rather than an absence nobody sees.

// commonMain/kotlin/network/Pinning.kt
//
// The pins, shared — hashes are platform-independent even though their enforcement is not.
// Several per host ON PURPOSE: a single pin is an outage at the next certificate renewal,
// which is a self-inflicted incident that pinning is famous for.
data class PinSet(val host: String, val spkiSha256: List<String>) {
    init {
        require(spkiSha256.size >= 2) {
            "$host has ${spkiSha256.size} pin(s). At least one BACKUP pin is required, or " +
            "the next certificate rotation is an outage with no client-side remedy."
        }
    }
}

/** What a platform could NOT enforce. Empty means fully pinned. */
data class PinningResult(val unpinnable: Set<String>) {
    val isFullyPinned: Boolean get() = unpinnable.isEmpty()
}

/**
 * Applies pinning to the engine being configured and REPORTS what it could not apply.
 * A Unit return would make an unpinnable platform indistinguishable from a pinned one,
 * which is the exact defect this replaces.
 */
expect fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult
// androidMain — OkHttp: a real additional constraint on an already-validated chain
actual fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult {
    (this as? OkHttpConfig)?.config {
        certificatePinner(
            CertificatePinner.Builder().apply {
                pins.forEach { p -> p.spkiSha256.forEach { add(p.host, it) } }
            }.build()
        )
    } ?: return PinningResult(pins.map { it.host }.toSet())   // wrong engine: say so
    return PinningResult(emptySet())
}
// iosMain — Darwin: a challenge handler, because there is no configuration value
actual fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult {
    (this as? DarwinClientEngineConfig)?.handleChallenge { _, _, challenge, completion ->
        val host = challenge.protectionSpace.host
        val expected = pins.firstOrNull { it.host == host }

        // A host with no pins is NOT a failure — it is out of scope, and must fall through
        // to the system's own validation rather than being rejected or blindly accepted.
        if (expected == null) {
            completion(NSURLSessionAuthChallengePerformDefaultHandling, null); return@handleChallenge
        }

        val trust = challenge.protectionSpace.serverTrust
        // The system's own evaluation runs FIRST. Pinning is an additional constraint on a
        // chain that already validates; replacing the system check with a hash comparison
        // discards expiry, revocation and hostname verification.
        if (trust == null || !SecTrustEvaluateWithError(trust, null)) {
            completion(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null); return@handleChallenge
        }

        val leafHash = SecTrustGetCertificateAtIndex(trust, 0)
            ?.let { spkiSha256Base64(it) }

        if (leafHash != null && leafHash in expected.spkiSha256) {
            completion(NSURLSessionAuthChallengeUseCredential, NSURLCredential.credentialForTrust(trust))
        } else {
            completion(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
        }
    } ?: return PinningResult(pins.map { it.host }.toSet())
    return PinningResult(emptySet())
}
// wasmJsMain — the honest actual. It applies nothing and says which hosts are unpinned.
actual fun HttpClientEngineConfig.applyPinning(pins: List<PinSet>): PinningResult =
    PinningResult(pins.map { it.host }.toSet())

And the assertion at the call site, which is what turns the report into a control:

// commonMain/kotlin/network/Client.kt
fun httpClient(engine: HttpClientEngine, platform: Platform): HttpClient {
    // Captured OUT of the engine block, because that block is where the typed engine config
    // exists and the assertion belongs to the caller rather than to the configuration.
    var result: PinningResult? = null

    val client = HttpClient(engine) {
        install(ContentNegotiation) { json() }
        engine { result = applyPinning(API_PINS) }
    }

    with(checkNotNull(result) { "applyPinning never ran — engine block not invoked" }) {
        when {
            isFullyPinned -> Unit

            // The browser genuinely cannot pin. That is ACCEPTED, once, in one place, with
            // the compensating control named — rather than being an absence nobody noticed.
            platform == Platform.Web ->
                logger.warn("pinning unavailable on web for $unpinnable; " +
                            "mitigated by HSTS + CSP + a short-lived session token")

            // Any other platform reporting an unpinnable host is a WIRING BUG — a wrong
            // engine type, a missing actual — and it fails the build's own startup check
            // rather than shipping as a silent downgrade.
            else -> error(
                "pinning was not applied for $unpinnable on $platform. " +
                "This platform CAN pin, so this is a configuration error, not a limitation."
            )
        }
    }
    return client
}

And the output it produced, re-running the proxy test on all four:

$ ./tools/pinning-check.sh --proxy mitmproxy --hosts api.example.com

  android (OkHttp)     refused    SSLPeerUnverifiedException: Certificate pinning failure!
  ios (Darwin)         refused    NSURLErrorCancelled (challenge cancelled)
  desktop (CIO)        refused    TLSException: pinned key not found in chain
  web (Js)             ALLOWED    pinning unavailable — expected, logged, accepted

  3 / 3 pinnable platforms enforce. 1 platform reported unpinnable, with a named mitigation.

How we proved it

That three of four engines were unpinned, measured with a proxy rather than inferred from the config:

                        reached the proxy    before    after
  android                                      no       no
  ios                                         YES       no
  desktop                                     YES       no
  web                                         YES      YES  (expected)

The proxy is the only instrument that distinguishes "pinning configured" from "pinning enforced", and it had only ever been pointed at Android.

That the pin set needed a backup, which the first fix lacked. The test is to present the second valid certificate from the load-balancer pool and confirm the client accepts it:

$ ./tools/pinning-check.sh --present-cert lb-secondary

  one pin  (leaf only)      REFUSED     ← a false positive against the real server
  two pins (leaf + backup)  accepted

That failure mode is worse than no pinning, because it is an outage rather than a vulnerability, and it arrives on a certificate renewal rather than on a deploy. The require in PinSet exists because of this run.

That replacing the system check with a hash comparison is wrong, which the first Darwin implementation did:

                                     expired cert    wrong hostname    proxied
  hash comparison only                  accepted        accepted       refused
  SecTrustEvaluateWithError + hash      refused         refused        refused

Pinning is an additional constraint. A hash-only check passes a certificate that is expired, or issued for a different host, as long as its key matches — which is a smaller set of attacks and a real regression on everything else TLS validates.

That the non-web error branch catches a wiring bug. The fixture is a module whose iOS actual is missing, so the common expect resolves to a stub returning everything unpinnable:

$ ./gradlew :app:iosSimulatorArm64Test --tests '*PinningWiring*'
FAILED: pinning was not applied for [api.example.com] on Ios.
        This platform CAN pin, so this is a configuration error, not a limitation.

What I cannot claim. The web mitigations — HSTS, CSP, short-lived tokens — are not equivalent to pinning and I am not going to pretend they are. They reduce the window and the blast radius of an intercepted session; they do not prevent interception by a CA the user's browser trusts. The honest position is that the web client has a weaker threat model than the mobile ones, written down in one place, rather than a uniform claim that is false on one target.

And a limit on the approach. PinningResult reports what the configuration could not apply. It cannot report a pin that was applied and is wrong — a typo'd hash pins against nothing and reports success, failing only when a real request is made. The proxy check is what covers that, and it has to run in CI against a real endpoint, which is the part of this that is still a scheduled job rather than a per-PR gate.

Three verification runs after the fix: the proxy refused on every pinnable platform, a single-pin set wrongly refusing the load balancer second valid certificate, and a hash-only check accepting an expired certificate the system evaluation would reject

What this generalizes to

A capability that exists on most platforms and is deliberately absent on one cannot be expressed as a shared API that returns nothing — the return type is where the asymmetry has to live. The general move is to make the unavailable case a value the caller must handle rather than a silence: report what could not be done, accept it explicitly where it is genuinely impossible with the compensating control named, and fail loudly where it is possible and merely unwired. The two look identical in a configuration file and they are a limitation and a bug respectively.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS