Skip to content
ecdsa.com

ECDH key agreement · Kotlin

How to derive a shared key with ECDH and HKDF in Kotlin

Kotlin on the JVM derives a shared key with ECDH through the JDK's javax.crypto API: KeyAgreement computes the X25519 or P-256 shared secret, and the KDF API of JDK 25 provides HKDF without extra libraries. The recipe uses the RFC 7748 and RFC 5903 test keys, so the shared secrets match the published values.

Tested with Kotlin 2.4.21 (kotlinc, JVM) and OpenJDK 25.0.4.1 (Eclipse Temurin 25.0.4.1+1, javax.crypto.KDF).

Ecdh.kt — X25519 and P-256 agreement from PEM files, then HKDF-SHA256
import java.io.File
import java.security.KeyFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import java.util.HexFormat
import javax.crypto.KDF
import javax.crypto.KeyAgreement
import javax.crypto.spec.HKDFParameterSpec
fun pemBody(file: String): ByteArray =
Base64.getMimeDecoder().decode(File(file).readText().replace(Regex("-----[^-]+-----"), ""))
/** keyType "XDH" + agreement "XDH" for X25519; "EC" + "ECDH" for P-256. */
fun agree(keyType: String, agreement: String, privateKeyFile: String, peerPublicKeyFile: String): ByteArray {
val factory = KeyFactory.getInstance(keyType)
return KeyAgreement.getInstance(agreement).run {
init(factory.generatePrivate(PKCS8EncodedKeySpec(pemBody(privateKeyFile))))
doPhase(factory.generatePublic(X509EncodedKeySpec(pemBody(peerPublicKeyFile))), true)
generateSecret() // the raw shared secret
}
}
/** HKDF-SHA256 through javax.crypto.KDF (JDK 25+); no salt means an all-zero salt. */
fun deriveKey(sharedSecret: ByteArray): ByteArray =
KDF.getInstance("HKDF-SHA256").deriveData(
HKDFParameterSpec.ofExtract().addIKM(sharedSecret).thenExpand("AES-256-GCM key v1".toByteArray(), 32),
)
fun main() {
val hex = HexFormat.of()
for ((curve, types, files) in listOf(
Triple("X25519", "XDH" to "XDH", "alice-x25519.pem" to "bob-x25519.pub.pem"),
Triple("P-256", "EC" to "ECDH", "alice-p256.pem" to "bob-p256.pub.pem"),
)) {
val secret = agree(types.first, types.second, files.first, files.second)
println("${curve} shared secret: ${hex.formatHex(secret)}")
println("${curve} derived key: ${hex.formatHex(deriveKey(secret))}")
}
}

How it works

  1. pemBody strips the PEM armour and decodes the base64 DER with the MIME decoder, which tolerates line breaks.
  2. X25519 keys use the "XDH" key factory and agreement; P-256 keys use "EC" and "ECDH". The run block keeps init, doPhase and generateSecret together.
  3. KDF.getInstance("HKDF-SHA256").deriveData(HKDFParameterSpec.ofExtract().addIKM(secret).thenExpand(info, 32)) derives the key; with no salt added, the RFC 5869 default applies.
  4. The run compiles with kotlinc 2.4.21 and prints the same derived keys as every other recipe of this task.

Gotchas

  • javax.crypto.KDF needs JDK 25 at runtime, whatever Kotlin's jvmTarget says. On Android or older JDKs, use Bouncy Castle's HKDFBytesGenerator or an HMAC-based extract-and-expand.
  • generateSecret("AES"), common in older Java examples, throws NoSuchAlgorithmException: Unsupported secret key algorithm: AES on current JDKs. Call generateSecret() and derive the key with HKDF, so both sides apply the same purpose-bound derivation.
  • A low-order X25519 peer key makes doPhase throw InvalidKeyException: Point has small order; treat it as a rejected peer.
  • "AES-256-GCM key v1".toByteArray() is UTF-8 in Kotlin. The info string must be byte-identical on both sides — non-ASCII text in it is a classic source of mismatched keys.

Related recipes