androidkotlinprivate-keyecdsasecp256k1

How to sign a data using ECDSA private key with the P-256 (secp256k1) curve in Android Kotlin?


I wanted to sign a hex data using ECDSA private key with the P-256 (secp256k1) curve. But most of the methods in Android use PKCS#8 encoded private key for signature generation. How to convert an ECDSA private key with the P-256 (secp256k1) curve to a PKCS#8 encoded in Android Kotlin?

Current Private key format : -----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIKUpeefDapsFwkh3nvxEtDkkh3eA......-----END EC PRIVATE KEY-----

Required Private key format : -----BEGIN PRIVATE KEY-----\nMHQCAQEEIKUpeefDapsFwkh3nvxEtDkkh3eA......-----END PRIVATE KEY-----

I cannot use Bouncy Castle as my project's targetSdkVersion is 33. I also tried the following method to parse the private key and signature generation but was getting "java.security.spec.InvalidKeySpecException: com.android.org.conscrypt.OpenSSLX509CertificateFactory$ParsingException: Error parsing private key"

fun parseECPrivateKey(pem: String): PrivateKey {
    // Remove the header and footer from the PEM string
    val privateKeyPEM = pem
        .replace("-----BEGIN EC PRIVATE KEY-----", "")
        .replace("-----END EC PRIVATE KEY-----", "")
        .replace("\\s".toRegex(), "")

    // Decode the Base64 encoded string
    val encoded = Base64.getDecoder().decode(privateKeyPEM)

    // Use KeyFactory to convert the PKCS8 encoded key into a PrivateKey object
    val keyFactory = KeyFactory.getInstance("EC")
    val keySpec = PKCS8EncodedKeySpec(encoded)
    return keyFactory.generatePrivate(keySpec)
}

fun signData(privateKey: PrivateKey, dataHex: String): ByteArray {
    val data = hexStringToByteArray(dataHex)
    val signature = Signature.getInstance("SHA256withECDSA")
    signature.initSign(privateKey)
    signature.update(data)
    return signature.sign()
}

fun hexStringToByteArray(hex: String): ByteArray {
    val len = hex.length
    val data = ByteArray(len / 2)
    for (i in 0 until len step 2) {
        data[i / 2] = ((Character.digit(hex[i], 16) shl 4)
                + Character.digit(hex[i + 1], 16)).toByte()
    }
    return data
}

fun main() {
    val privateKeyPem = """
        -----BEGIN EC PRIVATE KEY-----
        MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0m4yLz+sdzZtBG9Q
        HQ9++wcfq1O4hOWgSBMb/A6eijyhRANCAAQeB0fBl2D7HZOKVBjpPiU2jabzNxQU
        ZYrJ+MSA3LpzZxmRk2JaFHNujjkJghQT19HHjg3Fnkb8Y9oIhB9neXBI
        -----END EC PRIVATE KEY-----
    """.trimIndent()

    val dataHex = "48656c6c6f2c20576f726c6421" // Example data

    try {
        val privateKey = parseECPrivateKey(privateKeyPem)
        val hash = hexStringToByteArray(dataHex)
        val signature = signData(privateKey, hash)

        println("Signature: ${Base64.getEncoder().encodeToString(signature)}")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

Solution

  • The import of a PEM encoded key in SEC1 format can be accomplished with BouncyCastle. To do this, the required BouncyCastle dependencies must first be referenced in app/gradle:

    implementation("org.bouncycastle:bcpkix-jdk15to18:1.78.1")
    implementation("org.bouncycastle:bcprov-jdk15to18:1.78.1")
    

    Remove the pre-installed BC version and import the current one:

    import org.bouncycastle.jce.provider.BouncyCastleProvider
    ...
    Security.removeProvider("BC")
    Security.addProvider(BouncyCastleProvider())
    

    Then a PEM encoded EC key in SEC1 format can be imported as follows:

    import org.bouncycastle.openssl.PEMKeyPair
    import org.bouncycastle.openssl.PEMParser
    import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
    ...
    val sec1Pem =   """
                    -----BEGIN EC PRIVATE KEY-----
                    MHcCAQEEIK1vV4iLOPym9KvJJU5hd6CMEp+DTt8QI7NPBdJSf+VDoAoGCCqGSM49
                    AwEHoUQDQgAEMpHT+HNKM7zjhx0jZDHyzQlkbLV0xk0H/TFo6gfT23ish58blPNh
                    YrFI51Q/czvkAwCtLZz/6s1n/M8aA9L1Vg==
                    -----END EC PRIVATE KEY-----
                    """.trimIndent()
    val pemParser = PEMParser(StringReader(sec1Pem))
    val pemKeyPair = pemParser.readObject() as PEMKeyPair
    val privateKey = JcaPEMKeyConverter().getKeyPair(pemKeyPair).private
    

    The privateKey generated in this way can be passed directly to your signData() method.


    Edit:

    The key posted in the comment to this answer is a SEC1 formatted key for curve secp256k1:

    -----BEGIN EC PRIVATE KEY-----
    MHQCAQEEIKUpeefDapsFwkh3nvxEtDkkh3eAuP5ufcoTYIi9UxWooAcGBSuBBAAK
    oUQDQgAEqHKJEruS1urTo8gED6xqGxeIiiGE0Yeapj4k0uAXuOtWn9EIhfAfo4gK
    0KiKmMgG3LC3T8Ry/09KU3tLmxLzsg==
    -----END EC PRIVATE KEY-----
    

    as can be seen most easily when the key is loaded into an ASN.1 parser, e.g. here.

    As already stated in the comments of the question, P-256 (aka secp256r1 aka prime256v1) and secp256k1 are two different curves!
    The key import described in this answer imports keys in SEC1 format for any curves (including P-256 and secp256k1).
    However, this does not mean that the provider used for signing must support all these curves.
    In contrast to P-256, secp256k1 is apparently not supported by the default provider, presumably AndroidOpenSSL 1.0 (at least on my machine): Using secp256k1 results in an exception in initSign().

    This problem can be quickly solved by using the BouncyCastle provider, which supports a wider range of curves and which you are already using anyway because of the key import.
    All you have to do is add the provider as the second parameter when instantiating the signature instance in the signData() method:

    val signature = Signature.getInstance("SHA256withECDSA", "BC")
    

    With this change, the (referenced) BC provider is used and signing with the secp256k1 curve works.


    Also note that the PEM encoded key posted in your question is not valid: The body contains a key in PKCS#8 format, while the header/footer belong to a key in SEC1 format.

    The PEM encoded key in PKCS#8 format with fixed header/footer is:

    -----BEGIN PRIVATE KEY-----
    MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg0m4yLz+sdzZtBG9Q
    HQ9++wcfq1O4hOWgSBMb/A6eijyhRANCAAQeB0fBl2D7HZOKVBjpPiU2jabzNxQU
    ZYrJ+MSA3LpzZxmRk2JaFHNujjkJghQT19HHjg3Fnkb8Y9oIhB9neXBI
    -----END PRIVATE KEY-----
    

    or the same key in SEC1 format, PEM encoded:

    -----BEGIN EC PRIVATE KEY-----
    MHcCAQEEINJuMi8/rHc2bQRvUB0PfvsHH6tTuITloEgTG/wOnoo8oAoGCCqGSM49
    AwEHoUQDQgAEHgdHwZdg+x2TilQY6T4lNo2m8zcUFGWKyfjEgNy6c2cZkZNiWhRz
    bo45CYIUE9fRx44NxZ5G/GPaCIQfZ3lwSA==
    -----END EC PRIVATE KEY-----
    

    Furthermore, this is a key for curve P-256 (aka secp256r1 aka prime256v1), see here.

    Since you programmatically remove the inconsistent header/footer and the line breaks and Base64 decode the key (i.e. convert it into a valid DER encoded key in PKCS#8 format), the import with parseECPrivateKey() or PKCS8EncodedKeySpec works and also the signing with the default provider that supports P-256 (I can confirm this by a test on my machine).
    However, as soon as you apply a real SEC1 key, the import fails (as PKCS8EncodedKeySpec cannot process SEC1 keys, but requires a PKCS#8 key). Similarly, as soon as you use the secp256k1 curve, signing with the default provider fails.