import java.security.KeyFactoryimport java.security.PrivateKeyimport java.security.Signatureimport java.security.spec.PKCS8EncodedKeySpecimport kotlin.io.encoding.Base64import kotlin.io.path.Pathimport kotlin.io.path.readBytesimport kotlin.io.path.readTextimport 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")} 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
readPrivateKeydecodes the PEM body withBase64.Mimeand hands it toPKCS8EncodedKeySpec; the curve comes from the key.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.- For a fixed-length
r‖ssignature useSHA256withECDSAinP1363Formatinstead; it is the inputtoLowSexpects. toLowScompares 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 twoByteArrays compares references, so it isfalseeven for identical contents. UsecontentEquals(andcontentHashCodefor map keys) whenever you compare signatures or digests.BigInteger.toByteArray()adds a0x00sign 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 intoFixedBytescorrupts 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.