Skip to content
ecdsa.com

ML-DSA-65 sign/verify · Kotlin

How to sign and verify with ML-DSA-65 in Kotlin

To sign and verify with ML-DSA-65 in Kotlin, use the JDK's built-in ML-DSA (JDK 24 and later): KeyPairGenerator.getInstance("ML-DSA-65") and Signature.getInstance("ML-DSA"), wrapped here in two small functions. This recipe also reads OpenSSL 3.5 keys from PEM, verifies OpenSSL's signature and signs with OpenSSL's seed-only private key.

Tested with Kotlin 2.4.21 (Kotlin/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1).

Main.kt — sign, verify, and read SPKI and PKCS#8 files from OpenSSL
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.PrivateKey
import java.security.PublicKey
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import kotlin.io.path.Path
import kotlin.io.path.readText
fun mlDsaSign(key: PrivateKey, message: ByteArray): ByteArray =
Signature.getInstance("ML-DSA").run { initSign(key); update(message); sign() }
fun mlDsaVerify(key: PublicKey, message: ByteArray, signature: ByteArray): Boolean =
Signature.getInstance("ML-DSA").run { initVerify(key); update(message); verify(signature) }
private fun pemBody(path: String) = Base64.getMimeDecoder().decode(Path(path).readText().replace(Regex("-----[^-]+-----"), ""))
fun readPublicKey(path: String): PublicKey = KeyFactory.getInstance("ML-DSA").generatePublic(X509EncodedKeySpec(pemBody(path)))
fun readPrivateKey(path: String): PrivateKey = KeyFactory.getInstance("ML-DSA").generatePrivate(PKCS8EncodedKeySpec(pemBody(path)))
fun main() {
val keys = KeyPairGenerator.getInstance("ML-DSA-65").generateKeyPair()
val message = "hello, post-quantum world".encodeToByteArray()
val signature = mlDsaSign(keys.private, message)
println("signature ${signature.size} bytes, verified: ${mlDsaVerify(keys.public, message, signature)}")
println("tampered message: ${mlDsaVerify(keys.public, message + '!'.code.toByte(), signature)}")
// Keys and signatures from OpenSSL 3.5: SPKI public key, PKCS#8 seed
val opensslKey = readPublicKey("openssl-pub.pem")
val opensslSignature = Base64.getMimeDecoder().decode(Path("openssl-sig.b64").readText())
println("OpenSSL signature verifies: ${mlDsaVerify(opensslKey, message, opensslSignature)}")
val seeded = readPrivateKey("seed.pem")
println("seed.pem signs for OpenSSL's public key: ${mlDsaVerify(opensslKey, message, mlDsaSign(seeded, message))}")
}

How it works

  1. mlDsaSign and mlDsaVerify wrap Signature.getInstance("ML-DSA"); the signature is 3309 bytes.
  2. readPublicKey decodes a BEGIN PUBLIC KEY file with the MIME base64 decoder and X509EncodedKeySpec; readPrivateKey does the same with PKCS8EncodedKeySpec.
  3. The run verifies OpenSSL's signature and signs with OpenSSL's seed-only key file, whose signature OpenSSL's public key accepts.
  4. Running on JDK 25 inside the Gradle image, no dependency is involved: the code uses only java.security.

Gotchas

  • The algorithm name decides which keys are accepted: Signature.getInstance("ML-DSA") signs with an ML-DSA-44 key (2420-byte signature), while "ML-DSA-65" rejects it with InvalidKeyException.
  • The JDK provider has no context-string parameter, so it cannot verify signatures made with a context.
  • The JDK reads seed-only PKCS#8 files; keep keys from OpenSSL in that form (openssl pkey -provparam ml-dsa.output_formats=seed-only) so that every language in the project can read them.

Related recipes