Skip to content
ecdsa.com

ML-KEM-768 encapsulation · Kotlin

How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Kotlin

To encapsulate and decapsulate a shared secret with ML-KEM-768 in Kotlin, use the JDK's javax.crypto.KEM (JDK 24 and later) through two small functions: encapsulate(publicKey) returns the secret and the ciphertext, decapsulate(privateKey, ciphertext) returns the secret on the other side. This recipe also reads OpenSSL 3.5 keys from PEM and decapsulates a ciphertext OpenSSL made.

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

Main.kt — encapsulate, decapsulate, and keys from OpenSSL
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.PublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import javax.crypto.KEM
import javax.crypto.SecretKey
import kotlin.io.path.Path
import kotlin.io.path.readText
/** Sender side: a fresh shared secret and the ciphertext to send with it. */
fun encapsulate(encapsulationKey: PublicKey): Pair<SecretKey, ByteArray> =
KEM.getInstance("ML-KEM").newEncapsulator(encapsulationKey).encapsulate().let { it.key() to it.encapsulation() }
/** Receiver side: the shared secret inside a ciphertext. */
fun decapsulate(decapsulationKey: PrivateKey, ciphertext: ByteArray): SecretKey =
KEM.getInstance("ML-KEM").newDecapsulator(decapsulationKey).decapsulate(ciphertext)
private fun pemBody(path: String) = Base64.getMimeDecoder().decode(Path(path).readText().replace(Regex("-----[^-]+-----"), ""))
fun readEncapsulationKey(path: String): PublicKey = KeyFactory.getInstance("ML-KEM").generatePublic(X509EncodedKeySpec(pemBody(path)))
fun readDecapsulationKey(path: String): PrivateKey = KeyFactory.getInstance("ML-KEM").generatePrivate(PKCS8EncodedKeySpec(pemBody(path)))
fun main() {
val keys = KeyPairGenerator.getInstance("ML-KEM-768").generateKeyPair()
val (senderKey, ciphertext) = encapsulate(keys.public)
val receiverKey = decapsulate(keys.private, ciphertext)
println("ciphertext ${ciphertext.size} bytes, shared secret ${senderKey.encoded.size} bytes, " +
"agreed: ${MessageDigest.isEqual(senderKey.encoded, receiverKey.encoded)}")
// Keys and a ciphertext from OpenSSL 3.5: SPKI public key, PKCS#8 seed
val opensslCiphertext = Base64.getMimeDecoder().decode(Path("openssl-ct.b64").readText())
val secret = decapsulate(readDecapsulationKey("seed.pem"), opensslCiphertext)
println("secret from OpenSSL's ciphertext: ${secret.encoded.toHexString()}")
val (_, toOpenSsl) = encapsulate(readEncapsulationKey("openssl-pub.pem"))
println("ciphertext for OpenSSL's key: ${toOpenSsl.size} bytes")
}

How it works

  1. encapsulate wraps KEM.getInstance("ML-KEM").newEncapsulator(key).encapsulate() and returns the pair (SecretKey, ByteArray): a 32-byte secret and a 1088-byte ciphertext.
  2. decapsulate wraps newDecapsulator(key).decapsulate(ciphertext) and returns the same secret.
  3. readEncapsulationKey and readDecapsulationKey decode OpenSSL's BEGIN PUBLIC KEY and seed-only BEGIN PRIVATE KEY files with X509EncodedKeySpec and PKCS8EncodedKeySpec.
  4. The run decapsulates OpenSSL's ciphertext to the shared secret dee35810…30f9 and encapsulates to OpenSSL's key.

Gotchas

  • KEM.getInstance("ML-KEM") serves every parameter set: an ML-KEM-512 key produces a 768-byte ciphertext under the same name. If your protocol fixes ML-KEM-768, check the key's parameter set, or the ciphertext length, yourself.
  • Keys of other algorithms are refused with InvalidKeyException, and an ML-KEM-768 ciphertext for an ML-KEM-512 key with DecapsulateException — but a tampered ciphertext of the right length silently yields a different secret.
  • The JDK's KEM API is in javax.crypto, not java.security: KEM, KEM.Encapsulated and DecapsulateException all live there.

Related recipes