Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

KTOR-6759 Darwin: Throw specific exception for SSL Pinning failure #4408

Merged
merged 3 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ktor-client/ktor-client-darwin/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

plugins {
Expand All @@ -12,6 +12,7 @@ kotlin {
darwinMain {
dependencies {
api(project(":ktor-client:ktor-client-core"))
api(project(":ktor-network:ktor-network-tls"))
}
}
darwinTest {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.client.engine.darwin
Expand All @@ -8,7 +8,6 @@ import io.ktor.client.call.*
import io.ktor.client.engine.*
import io.ktor.http.content.*
import io.ktor.utils.io.*
import io.ktor.utils.io.errors.*
import kotlinx.cinterop.*
import kotlinx.coroutines.*
import kotlinx.io.IOException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,16 @@ public class KtorNSURLSessionDelegate(
didReceiveChallenge: NSURLAuthenticationChallenge,
completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit
) {
val handler = challengeHandler
if (handler != null) {
handler(session, task, didReceiveChallenge, completionHandler)
} else {
val handler = challengeHandler ?: run {
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, didReceiveChallenge.proposedCredential)
return
}

try {
handler(session, task, didReceiveChallenge, completionHandler)
} catch (cause: Throwable) {
taskHandlers[task]?.saveFailure(cause)
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
/*
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.client.engine.darwin.certificates

import io.ktor.client.engine.darwin.*
import io.ktor.network.tls.*
import io.ktor.util.logging.*
import kotlinx.cinterop.*
import platform.CoreCrypto.*
import platform.CoreFoundation.*
import platform.CoreCrypto.CC_SHA1
import platform.CoreCrypto.CC_SHA1_DIGEST_LENGTH
import platform.CoreCrypto.CC_SHA256
import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH
import platform.CoreFoundation.CFDictionaryGetValue
import platform.CoreFoundation.CFStringCreateWithCString
import platform.CoreFoundation.kCFStringEncodingUTF8
import platform.Foundation.*
import platform.Security.*

private val LOG = KtorSimpleLogger("io.ktor.client.engine.darwin.certificates.CertificatePinner")

/**
* Constrains which certificates are trusted. Pinning certificates defends against attacks on
* certificate authorities. It also prevents connections through man-in-the-middle certificate
Expand Down Expand Up @@ -114,36 +123,36 @@ public data class CertificatePinner(
private val validateTrust: Boolean
) : ChallengeHandler {

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

@OptIn(ExperimentalForeignApi::class)
private fun applyPinning(challenge: NSURLAuthenticationChallenge): Boolean {
val hostname = challenge.protectionSpace.host
val matchingPins = findMatchingPins(hostname)

if (matchingPins.isEmpty()) {
println("CertificatePinner: No pins found for host")
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
return
LOG.trace { "No pins found for host" }
return false
}

if (challenge.protectionSpace.authenticationMethod !=
NSURLAuthenticationMethodServerTrust
) {
println("CertificatePinner: Authentication method not suitable for pinning")
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
return
if (challenge.protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust) {
LOG.trace { "Authentication method not suitable for pinning" }
return false
}

val trust = challenge.protectionSpace.serverTrust
if (trust == null) {
println("CertificatePinner: Server trust is not available")
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
return
}
?: throw TlsPeerUnverifiedException("Server trust is not available")

if (validateTrust) {
val hostCFString = CFStringCreateWithCString(null, hostname, kCFStringEncodingUTF8)
Expand All @@ -152,11 +161,7 @@ public data class CertificatePinner(
SecTrustSetPolicies(trust, policy)
}
}
if (!trust.trustIsValid()) {
println("CertificatePinner: Server trust is invalid")
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
return
}
if (!trust.trustIsValid()) throw TlsPeerUnverifiedException("Server trust is invalid")
}

val certCount = SecTrustGetCertificateCount(trust)
Expand All @@ -165,19 +170,14 @@ public data class CertificatePinner(
}

if (certificates.size != certCount.toInt()) {
println("CertificatePinner: Unknown certificates")
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
return
throw TlsPeerUnverifiedException("Unknown certificates")
}

val result = hasOnePinnedCertificate(certificates)
if (result) {
completionHandler(NSURLSessionAuthChallengeUseCredential, challenge.proposedCredential)
} else {
if (!hasOnePinnedCertificate(certificates)) {
val message = buildErrorMessage(certificates, hostname)
println(message)
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
throw TlsPeerUnverifiedException(message)
}
return true
}

/**
Expand All @@ -201,17 +201,16 @@ public data class CertificatePinner(

pin.hash == sha256
}

CertificatesInfo.HASH_ALGORITHM_SHA_1 -> {
if (sha1 == null) {
sha1 = publicKey.toSha1String()
}

pin.hash == sha1
}
else -> {
println("CertificatePinner: Unsupported hashAlgorithm: ${pin.hashAlgorithm}")
false
}

else -> throw IllegalArgumentException("Unsupported hashAlgorithm: ${pin.hashAlgorithm}")
}
}
}
Expand Down Expand Up @@ -248,15 +247,8 @@ public data class CertificatePinner(
* Returns list of matching certificates' pins for the hostname. Returns an empty list if the
* hostname does not have pinned certificates.
*/
internal fun findMatchingPins(hostname: String): List<PinnedCertificate> {
var result: List<PinnedCertificate> = emptyList()
for (pin in pinnedCertificates) {
if (pin.matches(hostname)) {
if (result.isEmpty()) result = mutableListOf()
(result as MutableList<PinnedCertificate>).add(pin)
}
}
return result
private fun findMatchingPins(hostname: String): List<PinnedCertificate> {
return pinnedCertificates.filter { it.matches(hostname) }
}

/**
Expand Down Expand Up @@ -307,7 +299,7 @@ public data class CertificatePinner(
CFBridgingRelease(publicKeyAttributes)

if (!checkValidKeyType(publicKeyType, publicKeySize)) {
println("CertificatePinner: Public Key not supported type or size")
LOG.trace { "Public Key not supported type or size" }
return null
}

Expand Down Expand Up @@ -404,7 +396,6 @@ public data class CertificatePinner(
/**
* Pins certificates for `pattern`.
*
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.engine.darwin.certificates.CertificatePinner.Builder.add)
*
* @param pattern lower-case host name or wildcard pattern such as `*.example.com`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2022 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.client.engine.darwin.internal
Expand All @@ -26,6 +26,9 @@ internal class DarwinTaskHandler(
private val requestTime: GMTDate = GMTDate()
private val bodyChunks = Channel<ByteArray>(Channel.UNLIMITED)

private var pendingFailure: Throwable? = null
get() = field?.also { field = null }

private val body: ByteReadChannel = GlobalScope.writer(callContext) {
try {
bodyChunks.consumeEach {
Expand All @@ -52,9 +55,13 @@ internal class DarwinTaskHandler(
}
}

fun saveFailure(cause: Throwable) {
pendingFailure = cause
}

fun complete(task: NSURLSessionTask, didCompleteWithError: NSError?) {
if (didCompleteWithError != null) {
val exception = handleNSError(requestData, didCompleteWithError)
val exception = pendingFailure ?: handleNSError(requestData, didCompleteWithError)
bodyChunks.close(exception)
response.completeExceptionally(exception)
return
Expand Down
20 changes: 16 additions & 4 deletions ktor-client/ktor-client-darwin/darwin/test/DarwinEngineTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import platform.Foundation.*
import platform.Foundation.NSHTTPCookieStorage.Companion.sharedHTTPCookieStorage
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.test.*
import kotlin.time.Duration.Companion.seconds

class DarwinEngineTest : ClientEngineTest<DarwinClientEngineConfig>(Darwin) {
Expand Down Expand Up @@ -263,6 +260,21 @@ class DarwinEngineTest : ClientEngineTest<DarwinClientEngineConfig>(Darwin) {
}
}

@OptIn(UnsafeNumber::class)
@Test
fun testRethrowExceptionThrownDuringCustomChallenge() = runBlocking {
val challengeException = Exception("Challenge failed")

val client = HttpClient(Darwin) {
engine {
handleChallenge { _, _, _, _ -> throw challengeException }
}
}

val thrownException = assertFails { client.get(TEST_SERVER_TLS) }
assertSame(thrownException, challengeException, "Expected exception to be rethrown")
}

private fun stringToNSUrlString(value: String): String {
return Url(value).toNSUrl().absoluteString!!
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import kotlin.time.Duration.Companion.minutes
*/
const val TEST_SERVER: String = "http://127.0.0.1:8080"

/**
* Web url with TLS for tests.
*/
const val TEST_SERVER_TLS: String = "https://127.0.0.1:8089"

/**
* Websocket server url for tests.
*
Expand Down
2 changes: 1 addition & 1 deletion ktor-network/ktor-network-tls/api/ktor-network-tls.api
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ public final class io/ktor/network/tls/TLSConfigBuilderKt {
public static final fun takeFrom (Lio/ktor/network/tls/TLSConfigBuilder;Lio/ktor/network/tls/TLSConfigBuilder;)V
}

public final class io/ktor/network/tls/TLSException : java/io/IOException {
public final class io/ktor/network/tls/TLSException : javax/net/ssl/SSLException {
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
}
Expand Down
11 changes: 10 additions & 1 deletion ktor-network/ktor-network-tls/api/ktor-network-tls.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,19 @@ final class io.ktor.network.tls/TLSConfigBuilder { // io.ktor.network.tls/TLSCon
final fun build(): io.ktor.network.tls/TLSConfig // io.ktor.network.tls/TLSConfigBuilder.build|build(){}[0]
}

final class io.ktor.network.tls/TLSException : kotlinx.io/IOException { // io.ktor.network.tls/TLSException|null[0]
final class io.ktor.network.tls/TLSException : io.ktor.network.tls/TlsException { // io.ktor.network.tls/TLSException|null[0]
constructor <init>(kotlin/String, kotlin/Throwable? = ...) // io.ktor.network.tls/TLSException.<init>|<init>(kotlin.String;kotlin.Throwable?){}[0]
}

final class io.ktor.network.tls/TlsPeerUnverifiedException : io.ktor.network.tls/TlsException { // io.ktor.network.tls/TlsPeerUnverifiedException|null[0]
constructor <init>(kotlin/String) // io.ktor.network.tls/TlsPeerUnverifiedException.<init>|<init>(kotlin.String){}[0]
}

open class io.ktor.network.tls/TlsException : kotlinx.io/IOException { // io.ktor.network.tls/TlsException|null[0]
constructor <init>(kotlin/String) // io.ktor.network.tls/TlsException.<init>|<init>(kotlin.String){}[0]
constructor <init>(kotlin/String, kotlin/Throwable?) // io.ktor.network.tls/TlsException.<init>|<init>(kotlin.String;kotlin.Throwable?){}[0]
}

final object io.ktor.network.tls/CIOCipherSuites { // io.ktor.network.tls/CIOCipherSuites|null[0]
final val ECDHE_ECDSA_AES128_SHA256 // io.ktor.network.tls/CIOCipherSuites.ECDHE_ECDSA_AES128_SHA256|{}ECDHE_ECDSA_AES128_SHA256[0]
final fun <get-ECDHE_ECDSA_AES128_SHA256>(): io.ktor.network.tls/CipherSuite // io.ktor.network.tls/CIOCipherSuites.ECDHE_ECDSA_AES128_SHA256.<get-ECDHE_ECDSA_AES128_SHA256>|<get-ECDHE_ECDSA_AES128_SHA256>(){}[0]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.network.tls

import io.ktor.network.tls.extensions.*
import kotlinx.io.*
import kotlinx.io.IOException

/**
* TLS secret key exchange type.
Expand Down Expand Up @@ -173,4 +173,27 @@ public object CIOCipherSuites {

internal expect fun CipherSuite.isSupported(): Boolean

public class TLSException(message: String, cause: Throwable? = null) : IOException(message, cause)
@Deprecated("Use TlsException instead")
public class TLSException(message: String, cause: Throwable? = null) : TlsException(message, cause)

/**
* Represents an exception specific to TLS (Transport Layer Security) operations.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.network.tls.TlsException)
*/
public expect open class TlsException : IOException {
public constructor(message: String)
public constructor(message: String, cause: Throwable?)
}

/**
* Indicates that TLS peer can't be verified.
*
* This exception typically occurs when the identity of the remote peer can't be authenticated
* or verified during a TLS handshake. For example, because of mismatched certificates or missing trust anchors.
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.network.tls.TlsPeerUnverifiedException)
*
* @param message A message detailing the reason for the verification failure.
*/
public expect class TlsPeerUnverifiedException(message: String) : TlsException
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.network.tls
Expand All @@ -12,3 +12,6 @@ internal actual fun CipherSuite.isSupported(): Boolean = when (platformVersion.m
"1.6.0" -> platformVersion.minor >= 181 || keyStrength <= 128
else -> true
}

public actual typealias TlsException = javax.net.ssl.SSLException
public actual typealias TlsPeerUnverifiedException = javax.net.ssl.SSLPeerUnverifiedException
Loading