Skip to content
ecdsa.com

Sign a message · Kotlin

How to sign a message with ECDSA in Kotlin

To sign a message with ECDSA in Kotlin on the JVM, use the JDK's java.security.Signature: load a PKCS#8 key, pick SHA256withECDSA, sign — the result is ASN.1 DER that OpenSSL verifies as-is. The second snippet adds what the JDK leaves out: normalizing a raw signature to low-S for Bitcoin-style verifiers that reject the other half of valid signatures.

Tested with Kotlin 2.4.21 (Kotlin/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1) and OpenSSL 3.0.13 (openssl dgst cross-check).

Main.kt — sign with SHA-256, DER output
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.path.Path
import kotlin.io.path.readBytes
import kotlin.io.path.readText
import kotlin.io.path.writeBytes
fun readPrivateKey(pem: String): PrivateKey {
// "BEGIN PRIVATE KEY" is PKCS#8; the JDK cannot read "BEGIN EC PRIVATE KEY"
val pkcs8 = Base64.Mime.decode(pem.replace(Regex("-----[^-]+-----"), ""))
return KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
}
fun main() {
val privateKey = readPrivateKey(Path("key.pem").readText())
val der = Signature.getInstance("SHA256withECDSA").run {
initSign(privateKey) // random nonce from the default SecureRandom
update(Path("message.txt").readBytes())
sign() // ASN.1 DER
}
Path("sig.der").writeBytes(der)
println("DER signature: ${der.size} bytes")
}
LowS.kt — normalize a raw r‖s signature to low-S
import java.math.BigInteger
private val P256_ORDER = BigInteger("ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551", 16)
/** Raw r‖s with s replaced by n − s when s > n/2: the low-S form Bitcoin-style verifiers demand. */
fun ByteArray.toLowS(): ByteArray {
require(size == 64) { "expected a 64-byte P-256 r‖s signature" }
val s = BigInteger(1, copyOfRange(32, 64))
if (s <= P256_ORDER.shiftRight(1)) return this
return copyOfRange(0, 32) + (P256_ORDER - s).toFixedBytes(32)
}
private fun BigInteger.toFixedBytes(size: Int): ByteArray {
val bytes = toByteArray() // big-endian two's complement: maybe a 0x00 sign byte, maybe short
return if (bytes.size >= size) bytes.copyOfRange(bytes.size - size, bytes.size)
else ByteArray(size - bytes.size) + bytes
}

How it works

  1. readPrivateKey decodes the PEM body with Base64.Mime and hands it to PKCS8EncodedKeySpec; the curve comes from the key.
  2. Signature.getInstance("SHA256withECDSA").run { initSign; update; sign() } hashes the message and signs in one expression. The output is DER: 70 to 72 bytes on P-256, depending on the values of r and s.
  3. For a fixed-length r‖s signature use SHA256withECDSAinP1363Format instead; it is the input toLowS expects.
  4. toLowS compares s with n/2, the half order of P-256, and replaces it with n − s when it is larger. Both forms verify under standard ECDSA; the run above normalizes 100 fresh signatures and verifies every one.

Gotchas

  • Every call produces different bytes, because the JDK draws a random nonce. Test by verifying, never by comparing with a stored signature.
  • == on two ByteArrays compares references, so it is false even for identical contents. Use contentEquals (and contentHashCode for map keys) whenever you compare signatures or digests.
  • BigInteger.toByteArray() adds a 0x00 sign byte when the top bit is set and returns fewer bytes for small values — copying it into a 32-byte slot without the fixed-width step in toFixedBytes corrupts about one signature in two.
  • Low-S is a requirement of specific verifiers (Bitcoin, some Ethereum libraries), not of ECDSA. Normalize right before handing the signature to such a consumer, and keep the curve order in the code matched to the curve: the constant here is for P-256.

Related recipes