import java.security.KeyFactoryimport java.security.MessageDigestimport java.security.PublicKeyimport java.security.spec.X509EncodedKeySpecimport kotlin.io.encoding.Base64import kotlin.io.path.Pathimport 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)} import java.math.BigIntegerimport java.security.MessageDigestimport java.security.interfaces.ECPublicKeyimport 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
readPublicKeyparses the PEM first, so a damaged or mislabelled file throws instead of producing a fingerprint of noise.key.encodedis the canonical SPKI DER, identical toopenssl pkey -pubin -outform DER; the run compares both digests.toHexString(HexFormat { bytes.byteSeparator = ":" })reproduces thedgst -clayout;Base64.encode(digest)gives thesha256/…value OkHttp and Android's network security config pin.- The JWK thumbprint hashes a different input: the JSON members
crv,kty,x,yin 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
kidvalues 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 ascoordinate()does: without the trim about 3 keys in 4 get a wrong thumbprint, without the pad about 1 in 256.- OkHttp's
CertificatePinnermatches 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:…fromssh-keygen -lf) hash the SSH encoding of the key and never match the SPKI value.