Skip to content

Digital Signature — Kotlin source

Sign messages with Ed25519 or ECDSA and verify signatures. Prove authorship without revealing your private key.

This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Digital Signature - Ed25519 / ECDSA P-256 message signing and verification
// via the JVM's java.security. 100% local: keys, messages and signatures
// never leave the process.
//
// Language: Kotlin 1.9+ (JVM 15+ for Ed25519), standard library only.
// Ported from src/lib/digital-signature.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference (which
// drives the browser's Web Crypto): same key encodings, same signature
// layout, interoperable byte for byte.
//
// Key encoding: keys are exchanged as hex of the standard DER structures -
// SPKI (SubjectPublicKeyInfo) for public keys, PKCS8 for private keys - so
// they interoperate with OpenSSL, SSH, JOSE tooling and any other Web Crypto
// implementation. Signatures are hex of the raw signature bytes: 64 bytes for
// both algorithms (Ed25519 R||S; ECDSA P-256 in IEEE P1363 r||s layout, NOT
// ASN.1 DER). ECDSA signs the SHA-256 digest of the message.
//
// The JVM's Signature.getInstance("ECDSA") emits and consumes the ASN.1 DER
// form, so this port converts DER <-> P1363 on the way in and out - that is
// the only place the two runtimes differ.

import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.spec.ECGenParameterSpec
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec

enum class SignAlgorithm { ED25519, ECDSA_P256 }

data class KeyPair(
    /** Hex-encoded SPKI (SubjectPublicKeyInfo) public key. Safe to share. */
    val publicKey: String,
    /** Hex-encoded PKCS8 private key. Keep it secret - it IS the identity. */
    val privateKey: String,
)

private val HEX = Regex("^(?:[0-9a-fA-F]{2})+$")

private fun label(algorithm: SignAlgorithm): String =
    if (algorithm == SignAlgorithm.ED25519) "Ed25519" else "ECDSA P-256"

/** KeyGen algorithm name for the requested algorithm. */
private fun keyGenAlgorithm(algorithm: SignAlgorithm): String =
    if (algorithm == SignAlgorithm.ED25519) "Ed25519" else "ECDSA"

/** Sign/verify transformation (ECDSA must pin the hash it signs with). */
private fun signTransformation(algorithm: SignAlgorithm): String =
    if (algorithm == SignAlgorithm.ED25519) "Ed25519" else "SHA256withECDSA"

private fun hexToBytes(hex: String, what: String): ByteArray {
    if (hex.isEmpty() || !HEX.matches(hex)) {
        throw IllegalArgumentException("$what must be a non-empty hex string (pairs of 0-9 / a-f digits).")
    }
    return ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }
}

private fun toHex(bytes: ByteArray): String =
    bytes.joinToString("") { "%02x".format(it) }

// ---------------------------------------------------------------------------
// ECDSA signature layout conversion: ASN.1 DER (SEQUENCE of two INTEGERs)
// <-> IEEE P1363 (fixed-width 32-byte r || 32-byte s).
// ---------------------------------------------------------------------------

/** DER INTEGER payload -> left-padded fixed-width big-endian. */
private fun derIntToFixed(payload: ByteArray, width: Int): ByteArray {
    // payload is minimal big-endian, possibly shorter than width.
    val out = ByteArray(width)
    val take = minOf(payload.size, width)
    System.arraycopy(payload, payload.size - take, out, width - take, take)
    return out
}

/** Convert a DER-encoded ECDSA signature into the 64-byte P1363 r||s form. */
private fun derToP1363(der: ByteArray): ByteArray {
    var i = 0
    fun readLength(): Int {
        val first = der[i++].toInt() and 0xFF
        return if (first and 0x80 == 0) first else {
            val n = first and 0x7F
            var len = 0
            repeat(n) { len = (len shl 8) or (der[i++].toInt() and 0xFF) }
            len
        }
    }
    require(der[i++].toInt() == 0x30) { "Not a DER SEQUENCE" } // SEQUENCE
    readLength() // total length (unused)
    require(der[i++].toInt() == 0x02) { "Expected DER INTEGER r" }
    val rLen = readLength()
    val r = derIntToFixed(der.copyOfRange(i, i + rLen), 32); i += rLen
    require(der[i++].toInt() == 0x02) { "Expected DER INTEGER s" }
    val sLen = readLength()
    val s = derIntToFixed(der.copyOfRange(i, i + sLen), 32); i += sLen
    return r + s
}

/** Convert a 64-byte P1363 r||s signature into the DER form the JVM wants. */
private fun p1363ToDer(raw: ByteArray): ByteArray {
    fun trimLeadingZeros(b: ByteArray): ByteArray {
        var start = 0
        while (start < b.size - 1 && b[start].toInt() == 0) start++
        val v = b.copyOfRange(start, b.size)
        // DER INTEGERs are signed: prepend a zero byte when the high bit is set.
        return if (v[0].toInt() and 0x80 != 0) byteArrayOf(0) + v else v
    }
    fun derInt(b: ByteArray): ByteArray {
        val payload = trimLeadingZeros(b)
        val len = if (payload.size < 0x80) byteArrayOf(payload.size.toByte())
        else byteArrayOf(0x81.toByte(), payload.size.toByte())
        return byteArrayOf(0x02) + len + payload
    }
    val r = derInt(raw.copyOfRange(0, 32))
    val s = derInt(raw.copyOfRange(32, 64))
    val body = r + s
    val seqLen = if (body.size < 0x80) byteArrayOf(body.size.toByte())
    else byteArrayOf(0x81.toByte(), body.size.toByte())
    return byteArrayOf(0x30) + seqLen + body
}

// ---------------------------------------------------------------------------
// Public API (mirrors the TS reference one-for-one)
// ---------------------------------------------------------------------------

/** Generate a fresh signing key pair, exported as hex SPKI / PKCS8 keys. */
fun generateKeyPair(algorithm: SignAlgorithm): KeyPair {
    val generator = KeyPairGenerator.getInstance(keyGenAlgorithm(algorithm))
    if (algorithm == SignAlgorithm.ECDSA_P256) {
        generator.initialize(ECGenParameterSpec("secp256r1"))
    }
    val pair = try {
        generator.generateKeyPair()
    } catch (e: Exception) {
        throw IllegalArgumentException(
            if (algorithm == SignAlgorithm.ED25519)
                "Ed25519 is not supported by this JVM (needs Java 15+). Switch to ECDSA P-256 or update the runtime."
            else "ECDSA P-256 key generation failed.",
            e,
        )
    }
    return KeyPair(toHex(pair.public.encoded), toHex(pair.private.encoded))
}

private fun importPrivateKey(privateKeyHex: String, algorithm: SignAlgorithm) =
    try {
        KeyFactory.getInstance(keyGenAlgorithm(algorithm))
            .generatePrivate(PKCS8EncodedKeySpec(hexToBytes(privateKeyHex, "Private key")))
    } catch (_: Exception) {
        throw IllegalArgumentException("Invalid private key for ${label(algorithm)} (expected hex PKCS8).")
    }

private fun importPublicKey(publicKeyHex: String, algorithm: SignAlgorithm) =
    try {
        KeyFactory.getInstance(keyGenAlgorithm(algorithm))
            .generatePublic(X509EncodedKeySpec(hexToBytes(publicKeyHex, "Public key")))
    } catch (_: Exception) {
        throw IllegalArgumentException("Invalid public key for ${label(algorithm)} (expected hex SPKI).")
    }

/** Sign `message` with a hex PKCS8 private key. Returns the hex signature. */
fun signMessage(message: String, privateKeyHex: String, algorithm: SignAlgorithm): String {
    if (message.isEmpty()) {
        throw IllegalArgumentException("Message must not be empty.")
    }
    val key = importPrivateKey(privateKeyHex, algorithm)
    val signer = Signature.getInstance(signTransformation(algorithm))
    signer.initSign(key)
    signer.update(message.toByteArray(Charsets.UTF_8))
    val raw = signer.sign()
    return toHex(if (algorithm == SignAlgorithm.ED25519) raw else derToP1363(raw))
}

/**
 * Verify `signatureHex` against `message` with a hex SPKI public key.
 * Returns false when the signature, message or key simply do not match;
 * throws only for malformed input (non-hex / wrong key type).
 */
fun verifySignature(message: String, signatureHex: String, publicKeyHex: String, algorithm: SignAlgorithm): Boolean {
    val signature = hexToBytes(signatureHex, "Signature")
    val key = importPublicKey(publicKeyHex, algorithm)
    val verifier = Signature.getInstance(signTransformation(algorithm))
    verifier.initVerify(key)
    verifier.update(message.toByteArray(Charsets.UTF_8))
    return if (algorithm == SignAlgorithm.ED25519) {
        verifier.verify(signature)
    } else {
        verifier.verify(p1363ToDer(signature))
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →