Skip to content
ecdsa.com

Ed25519 sign & verify · Kotlin

How to sign and verify Ed25519 signatures in Kotlin

Kotlin on the JVM signs and verifies Ed25519 signatures with the JDK's own java.security API (JDK 15 or later), with no extra dependency. KeyFactory.getInstance("Ed25519") loads PKCS#8 and SPKI keys, and Signature.getInstance("Ed25519") produces the deterministic 64-byte RFC 8032 signature.

Tested with Kotlin 2.4.21 (kotlinc, JVM) and OpenJDK 25.0.4.1 (Eclipse Temurin 25.0.4.1+1, SunEC provider).

Sign.kt — load a PKCS#8 key and sign message.txt
import java.io.File
import java.security.KeyFactory
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import java.util.HexFormat
fun main() {
val pem = File("key.pem").readText()
val pkcs8 = Base64.getMimeDecoder().decode(pem.replace(Regex("-----[^-]+-----"), ""))
val privateKey = KeyFactory.getInstance("Ed25519").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
val signature = Signature.getInstance("Ed25519").run {
initSign(privateKey)
update(File("message.txt").readBytes()) // the message itself: Ed25519 hashes internally
sign()
}
File("sig.bin").writeBytes(signature)
println("signature: ${HexFormat.of().formatHex(signature)}")
}
Verify.kt — verify with the SPKI public key
import java.io.File
import java.security.KeyFactory
import java.security.PublicKey
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
fun isValid(key: PublicKey, message: ByteArray, signature: ByteArray): Boolean =
Signature.getInstance("Ed25519").run {
initVerify(key)
update(message)
verify(signature) // false for a wrong signature, no exception
}
fun main() {
val pem = File("pub.pem").readText()
val spki = Base64.getMimeDecoder().decode(pem.replace(Regex("-----[^-]+-----"), ""))
val publicKey = KeyFactory.getInstance("Ed25519").generatePublic(X509EncodedKeySpec(spki))
val message = File("message.txt").readBytes()
val signature = File("sig.bin").readBytes()
println("valid: ${isValid(publicKey, message, signature)}")
println("tampered message valid: ${isValid(publicKey, "hello ed25520".toByteArray(), signature)}")
}

How it works

  1. The PEM body is base64 of the DER key: the regex strips the -----BEGIN …----- armour, and Base64.getMimeDecoder() tolerates the line breaks.
  2. Signature.getInstance("Ed25519").run { … } keeps initSign, update and sign together; the message goes in unhashed.
  3. verify returns false for a wrong signature — there is no exception to catch.
  4. The run compiles both files with kotlinc 2.4.21 into one jar and prints the same signature as every other recipe of this task.

Gotchas

  • There is no hash-prefixed algorithm: Signature.getInstance("SHA256withEd25519") throws NoSuchAlgorithmException.
  • KeyFactory.getInstance("EC") rejects Ed25519 keys with InvalidKeySpecException: java.security.InvalidKeyException: Invalid EC private key; Ed25519 has its own factory.
  • String.toByteArray() defaults to UTF-8 in Kotlin, while Java's getBytes() used the platform charset before JDK 18. When one side is older Java code, make the encoding explicit on both sides, or non-ASCII messages will not verify.
  • Raw 32-byte public keys (JWK x, libsodium) need the 12-byte SPKI header 302a300506032b6570032100 in front before X509EncodedKeySpec accepts them.

Related recipes