import java.nio.file.attribute.PosixFilePermissionsimport java.security.KeyPairimport java.security.KeyPairGeneratorimport java.security.spec.ECGenParameterSpecimport kotlin.io.encoding.Base64import kotlin.io.path.Pathimport kotlin.io.path.createFileimport kotlin.io.path.writeText fun pem(label: String, der: ByteArray): String = "-----BEGIN $label-----\n${Base64.encode(der).chunked(64).joinToString("\n")}\n-----END $label-----\n" fun generateP256(): KeyPair = KeyPairGenerator.getInstance("EC").run { initialize(ECGenParameterSpec("secp256r1")) // P-256; "P-256" itself is not accepted here generateKeyPair() } fun main() { val pair = generateP256() // encoded forms: PKCS#8 for the private key, SPKI for the public key Path("key.pem") .createFile(PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))) .writeText(pem("PRIVATE KEY", pair.private.encoded)) Path("pub.pem").writeText(pem("PUBLIC KEY", pair.public.encoded)) print(pem("PUBLIC KEY", pair.public.encoded))} // build.gradle.kts — only needed for curves the JDK no longer ships, such as secp256k1dependencies { implementation("org.bouncycastle:bcprov-jdk18on:1.86")} import org.bouncycastle.jce.provider.BouncyCastleProvider // The JDK removed secp256k1 in JDK 16; Bouncy Castle still generates itval bitcoinCurveKey: KeyPair = KeyPairGenerator.getInstance("EC", BouncyCastleProvider()).run { initialize(ECGenParameterSpec("secp256k1")) generateKeyPair()} How it works
KeyPairGenerator.getInstance("EC")initialized withECGenParameterSpec("secp256r1")creates a P-256 pair from the JDK's own SunEC provider.pair.private.encodedis PKCS#8 DER andpair.public.encodedis SPKI DER — the bodies ofBEGIN PRIVATE KEYandBEGIN PUBLIC KEY. Thepemhelper base64-encodes them and wraps the text at 64 columns withchunked(64).Path.createFilewith a POSIX permission attribute creates key.pem as owner-only (mode 600) before any key material is written, and fails rather than overwrite an existing key.- For secp256k1, pass a
BouncyCastleProviderinstance togetInstance: only that generator uses Bouncy Castle, and the encoded keys are the same standard PKCS#8 and SPKI structures OpenSSL reads.
Gotchas
Base64.Pemfrom the Kotlin standard library wraps at 64 columns but ends the lines with CRLF. Combined with LF header lines it produces mixed line endings — OpenSSL still parses the file, but byte comparisons and strict parsers do not.Base64.encode(der).chunked(64)keeps everything LF.- The JDK only knows the SEC name and its aliases:
ECGenParameterSpec("P-256")and"prime256v1"throwUnknown curve name;"secp256r1"works. ECGenParameterSpec("secp256k1")on the default provider throwsCurve not supported: secp256k1 (1.3.132.0.10)since JDK 16 — the run above shows it. Registering Bouncy Castle globally withSecurity.addProvideralso works, but changes provider selection for every library in the process.- On Android, keys meant to stay on the device belong in the Android Keystore (
KeyPairGenerator.getInstance("EC", "AndroidKeyStore")with aKeyGenParameterSpec); such private keys are not exportable, so the PEM step does not apply to them.