Skip to content
ecdsa.com

Export public key PEM · Kotlin

How to export a public key as PEM in Kotlin

To export a public key as PEM in Kotlin when you hold the ECDSA private key, read it with Bouncy Castle's PEMParser, compute the public point and write it as SPKI with JcaPEMWriter. The JDK alone cannot derive a public key from a private one, and Bouncy Castle also reads the legacy BEGIN EC PRIVATE KEY files the JDK rejects; a second snippet prints the same key as a JWK.

Tested with Bouncy Castle (bcpkix-jdk18on) 1.86 (Kotlin 2.4.21/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1) and OpenSSL 3.0.13 (openssl pkey cross-check).

build.gradle.kts — Bouncy Castle PKIX (PEM reading and writing)
// build.gradle.kts — Bouncy Castle's PEM and ASN.1 classes (bcpkix pulls in bcprov)
dependencies {
implementation("org.bouncycastle:bcpkix-jdk18on:1.86")
}
Main.kt — private key PEM (PKCS#8 or SEC 1) in, public key PEM out
import java.io.StringWriter
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.bufferedReader
import kotlin.io.path.writeText
import org.bouncycastle.asn1.ASN1ObjectIdentifier
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo
import org.bouncycastle.asn1.sec.ECPrivateKey
import org.bouncycastle.asn1.x509.AlgorithmIdentifier
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.asn1.x9.ECNamedCurveTable
import org.bouncycastle.asn1.x9.X9ObjectIdentifiers
import org.bouncycastle.openssl.PEMKeyPair
import org.bouncycastle.openssl.PEMParser
import org.bouncycastle.openssl.jcajce.JcaPEMWriter
/** The SPKI of an EC private key in either PEM flavour: Q = d·G on the key's named curve. */
fun publicKeyOf(privateKeyPem: Path): SubjectPublicKeyInfo {
val info = PEMParser(privateKeyPem.bufferedReader()).use { parser ->
when (val pem = parser.readObject()) {
is PrivateKeyInfo -> pem // "BEGIN PRIVATE KEY" (PKCS#8)
is PEMKeyPair -> pem.privateKeyInfo // "BEGIN EC PRIVATE KEY" (SEC 1)
else -> error("not an unencrypted private key: ${pem?.javaClass?.simpleName}")
}
}
val curveOid = ASN1ObjectIdentifier.getInstance(info.privateKeyAlgorithm.parameters)
val curve = ECNamedCurveTable.getByOID(curveOid) ?: error("unsupported curve $curveOid")
val q = curve.g.multiply(ECPrivateKey.getInstance(info.parsePrivateKey()).key).normalize()
return SubjectPublicKeyInfo(AlgorithmIdentifier(X9ObjectIdentifiers.id_ecPublicKey, curveOid), q.getEncoded(false))
}
fun toPem(spki: SubjectPublicKeyInfo): String =
StringWriter().also { out -> JcaPEMWriter(out).use { it.writeObject(spki) } }.toString()
fun main() {
val pem = toPem(publicKeyOf(Path("key.pem")))
Path("pub.pem").writeText(pem)
print(pem) // -----BEGIN PUBLIC KEY-----
}
the same public key as a JWK
import kotlin.io.encoding.Base64
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
private val base64Url = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
/** The same public key as a JWK (RFC 7518): the uncompressed point 0x04 ‖ x ‖ y, split. */
fun SubjectPublicKeyInfo.toJwk(): String {
val point = publicKeyData.bytes
require(point[0] == 0x04.toByte()) { "expected an uncompressed point" }
val crv = when (algorithm.parameters.toString()) {
"1.2.840.10045.3.1.7" -> "P-256"
"1.3.132.0.34" -> "P-384"
"1.3.132.0.35" -> "P-521"
else -> error("no JWK curve name for ${algorithm.parameters}")
}
val half = (point.size - 1) / 2
return """{"kty":"EC","crv":"$crv","x":"${base64Url.encode(point, 1, 1 + half)}","y":"${base64Url.encode(point, 1 + half)}"}"""
}

How it works

  1. PEMParser.readObject() returns a PrivateKeyInfo for BEGIN PRIVATE KEY and a PEMKeyPair for BEGIN EC PRIVATE KEY; the when expression turns both into the same PrivateKeyInfo.
  2. The curve OID sits in the key's algorithm parameters. ECNamedCurveTable.getByOID supplies that curve's generator G, and multiply(d).normalize() computes the public point Q = d·G.
  3. SubjectPublicKeyInfo with id-ecPublicKey and the curve OID is exactly the structure OpenSSL writes; JcaPEMWriter labels it PUBLIC KEY and wraps the base64 at 64 columns. The run checks the result byte for byte against openssl pkey -pubout, for both input formats.
  4. For JOSE, split the uncompressed point 0x04 ‖ x ‖ y into halves and base64url-encode them without padding: that is the x and y of the JWK.

Gotchas

  • PEMParser.readObject() returns PEMEncryptedKeyPair or PKCS8EncryptedPrivateKeyInfo for password-protected keys; the else branch reports them instead of failing later with a confusing cast error. Decrypt them with JceOpenSSLPKCS8DecryptorProviderBuilder first.
  • getEncoded(false) writes the uncompressed point. true would produce a 33-byte compressed point — still a valid SPKI, but different bytes from what OpenSSL and the other stacks write, so fingerprints and byte comparisons of the same key would no longer match.
  • Hand-built PEM strings often go wrong on line endings and line length; JcaPEMWriter uses 64 columns and the platform line separator, so on Windows the file has CRLF endings.
  • JWK x and y must keep their full width (32 bytes on P-256) including leading zero bytes. Slicing the point, as here, keeps them; going through BigInteger.toByteArray() does not.

Related recipes