Skip to content
ecdsa.com

Key fingerprint · Kotlin

How to compute a public key fingerprint in Kotlin

To compute a public key fingerprint in Kotlin, make one MessageDigest call over PublicKey.encoded — the key's DER-encoded SubjectPublicKeyInfo — and format it with the standard library's HexFormat or Base64. The recipe prints the colon-separated SHA-256 OpenSSL shows and the base64 pin OkHttp's CertificatePinner uses, then computes the RFC 7638 JWK thumbprint that JOSE systems use as a key id.

Tested with Kotlin 2.4.21 (Kotlin/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1) and OpenSSL 3.0.13 (openssl dgst cross-check).

Main.kt — SHA-256 over SPKI DER, as hex and as a base64 pin
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.PublicKey
import java.security.spec.X509EncodedKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readText
fun readPublicKey(pem: String): PublicKey =
KeyFactory.getInstance("EC").generatePublic(
X509EncodedKeySpec(Base64.Mime.decode(pem.replace(Regex("-----[^-]+-----"), ""))),
)
fun main() {
val key = readPublicKey(Path("pub.pem").readText()) // parsing first rejects files that are not keys
val digest = MessageDigest.getInstance("SHA-256").digest(key.encoded) // SPKI DER
println(digest.toHexString(HexFormat { bytes.byteSeparator = ":" })) // OpenSSL's dgst -c layout
println(Base64.encode(digest)) // pin-sha256 (RFC 7469, OkHttp CertificatePinner)
}
the RFC 7638 JWK thumbprint of the same key
import java.math.BigInteger
import java.security.MessageDigest
import java.security.interfaces.ECPublicKey
import kotlin.io.encoding.Base64
/** RFC 7638 JWK thumbprint (often used as "kid"): SHA-256 over the canonical JWK of a P-256 key. */
fun ECPublicKey.jwkThumbprint(): String {
val b64url = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
fun coordinate(value: BigInteger): String {
val bytes = value.toByteArray().takeLast(32).toByteArray() // drop a 0x00 sign byte
return b64url.encode(ByteArray(32 - bytes.size) + bytes) // keep the full 32-byte width
}
// members in lexicographic order, no whitespace: the input RFC 7638 prescribes
val canonical = """{"crv":"P-256","kty":"EC","x":"${coordinate(w.affineX)}","y":"${coordinate(w.affineY)}"}"""
return b64url.encode(MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray()))
}

How it works

  1. readPublicKey parses the PEM first, so a damaged or mislabelled file throws instead of producing a fingerprint of noise.
  2. key.encoded is the canonical SPKI DER, identical to openssl pkey -pubin -outform DER; the run compares both digests.
  3. toHexString(HexFormat { bytes.byteSeparator = ":" }) reproduces the dgst -c layout; Base64.encode(digest) gives the sha256/… value OkHttp and Android's network security config pin.
  4. The JWK thumbprint hashes a different input: the JSON members crv, kty, x, y in lexicographic order with no whitespace, then base64url without padding.

Gotchas

  • The SPKI fingerprint and the JWK thumbprint are both "SHA-256 of the key" and never equal each other. Know which one a system expects before comparing: certificate pinning uses SPKI, JOSE kid values often use RFC 7638.
  • BigInteger.toByteArray() adds a sign byte and drops leading zeros. A JWK coordinate must be exactly 32 bytes on P-256, so trim and pad as coordinate() does: without the trim about 3 keys in 4 get a wrong thumbprint, without the pad about 1 in 256.
  • OkHttp's CertificatePinner matches base64 SHA-256 of the SPKI of any certificate in the chain. Pin a backup key too, or a routine key rotation locks out every installed app.
  • OpenSSH fingerprints (SHA256:… from ssh-keygen -lf) hash the SSH encoding of the key and never match the SPKI value.

Related recipes