// build.gradle.kts — Bouncy Castle's PEM and ASN.1 classes (bcpkix pulls in bcprov)dependencies { implementation("org.bouncycastle:bcpkix-jdk18on:1.86")} import java.io.StringWriterimport java.nio.file.Pathimport kotlin.io.path.Pathimport kotlin.io.path.bufferedReaderimport kotlin.io.path.writeTextimport org.bouncycastle.asn1.ASN1ObjectIdentifierimport org.bouncycastle.asn1.pkcs.PrivateKeyInfoimport org.bouncycastle.asn1.sec.ECPrivateKeyimport org.bouncycastle.asn1.x509.AlgorithmIdentifierimport org.bouncycastle.asn1.x509.SubjectPublicKeyInfoimport org.bouncycastle.asn1.x9.ECNamedCurveTableimport org.bouncycastle.asn1.x9.X9ObjectIdentifiersimport org.bouncycastle.openssl.PEMKeyPairimport org.bouncycastle.openssl.PEMParserimport 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-----} import kotlin.io.encoding.Base64import 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
PEMParser.readObject()returns aPrivateKeyInfoforBEGIN PRIVATE KEYand aPEMKeyPairforBEGIN EC PRIVATE KEY; thewhenexpression turns both into the samePrivateKeyInfo.- The curve OID sits in the key's algorithm parameters.
ECNamedCurveTable.getByOIDsupplies that curve's generator G, andmultiply(d).normalize()computes the public point Q = d·G. SubjectPublicKeyInfowithid-ecPublicKeyand the curve OID is exactly the structure OpenSSL writes;JcaPEMWriterlabels itPUBLIC KEYand wraps the base64 at 64 columns. The run checks the result byte for byte againstopenssl pkey -pubout, for both input formats.- For JOSE, split the uncompressed point
0x04 ‖ x ‖ yinto halves and base64url-encode them without padding: that is thexandyof the JWK.
Gotchas
PEMParser.readObject()returnsPEMEncryptedKeyPairorPKCS8EncryptedPrivateKeyInfofor password-protected keys; theelsebranch reports them instead of failing later with a confusing cast error. Decrypt them withJceOpenSSLPKCS8DecryptorProviderBuilderfirst.getEncoded(false)writes the uncompressed point.truewould 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;
JcaPEMWriteruses 64 columns and the platform line separator, so on Windows the file has CRLF endings. - JWK
xandymust keep their full width (32 bytes on P-256) including leading zero bytes. Slicing the point, as here, keeps them; going throughBigInteger.toByteArray()does not.