Skip to content
ecdsa.com

Verify a certificate chain · Kotlin

How to verify a certificate chain against your own trust store in Kotlin

To verify a certificate chain against your own trust store in Kotlin, build an X509TrustManager from a KeyStore that holds only your root and call checkServerTrusted(chain, authType) — the same check the JDK's HTTPS clients make. It returns when the chain is trusted and throws CertificateException otherwise. This recipe checks a leaf → intermediate → root chain from a test CA and three chains that must fail.

Tested with Kotlin 2.4.21 (Kotlin/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1).

Main.kt — the JDK trust manager with a custom trust store
import java.security.KeyStore
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlin.io.path.Path
import kotlin.io.path.inputStream
fun load(path: String): X509Certificate =
Path(path).inputStream().use { CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate }
/** The JDK's TLS trust manager, trusting only the given roots — the same check an HTTPS client makes. */
fun trustManagerFor(vararg roots: X509Certificate): X509TrustManager {
val store = KeyStore.getInstance("PKCS12").apply {
load(null, null)
roots.forEachIndexed { i, root -> setCertificateEntry("root-$i", root) }
}
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { init(store) }
return factory.trustManagers.filterIsInstance<X509TrustManager>().single()
}
fun main() {
val intermediate = load("intermediate.pem")
val trusted = trustManagerFor(load("root.pem"))
// chain: as a server sends it, leaf first; "ECDHE_ECDSA" is the key exchange the check assumes
trusted.checkServerTrusted(arrayOf(load("leaf.pem"), intermediate), "ECDHE_ECDSA")
println("valid: api.example.com -> Issuing CA 1 -> Root CA")
val cases = listOf(
Triple("impostor root", arrayOf(load("leaf.pem"), intermediate), trustManagerFor(load("impostor-root.pem"))),
Triple("expired leaf", arrayOf(load("expired.pem"), intermediate), trusted),
Triple("missing intermediate", arrayOf(load("leaf.pem")), trusted),
)
for ((label, chain, manager) in cases) {
try {
manager.checkServerTrusted(chain, "ECDHE_ECDSA")
} catch (e: CertificateException) {
println("$label: ${e.message}")
}
}
}

How it works

  1. trustManagerFor(root) puts the root into an empty PKCS12 KeyStore and initialises a TrustManagerFactory with it, so no system root is trusted.
  2. checkServerTrusted(arrayOf(leaf, intermediate), "ECDHE_ECDSA") takes the chain as a server sends it, leaf first; the auth type is the key exchange the check assumes, and it decides which key usages are acceptable.
  3. The trust manager builds a path (PKIX), checks signatures, validity and constraints, and returns silently when everything holds.
  4. For client certificates the same object offers checkClientTrusted.

Gotchas

  • The trust manager searches for a path, so a missing intermediate fails at path building: PKIX path building failed: … unable to find valid certification path to requested target — the most common Java TLS error.
  • A look-alike root fails one step later, at validation: PKIX path validation failed: … Path does not chain with any of the trust anchors; the expired leaf gives … validity check failed.
  • This checks the chain, not the host name: endpoint identification happens in the TLS layer (SSLParameters.endpointIdentificationAlgorithm = "HTTPS"), not in checkServerTrusted.

Related recipes