androidjwtdigital-signatureandroid-keystoreecdsa

Signing JWT using ES256 on Android


I am trying to generate a JWT and to sign it with a private key, on Android and I am failing to do so because online decoders such as jwt.io or token.dev say that they could not verify the signature.


The requirements are as follow:

{
  "alg": "ES256",
  "typ": "JWT"
}
{
  "id": "7986f03e-a4b8-4033-87b9-af8ae0468a0b", // it's a UUID and this one is an example
  "iat": 1702390385 // timestamp in seconds from UTC
}

I have read the following documentations on Android:


This is the code I am coming up with:

1) Generate P-256 Key Pair:

// Initialize KeyPairGenerator for ECDSA 256
val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE)

keyPairGenerator.initialize(
    KeyGenParameterSpec.Builder(
        keyStoreAlias,
        KeyProperties.PURPOSE_SIGN,
    )
        .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
        .setDigests(KeyProperties.DIGEST_SHA256)
        .build()
)

// Generate the key pair (it will be stored automatically in the Android key store)
keyPairGenerator.generateKeyPair()

2) Create Header & Payload then encode in Base64

val encodedHeader = makeJsonAndEncodeToString(
    "alg" to "ES256",
    "typ" to "JWT",
)
val encodedPayload = makeJsonAndEncodeToString(
    "id" to "27d09c71-bd67-4523-97bb-12e933e9acbb",
    "iat" to 1702462854,
)

private fun makeJsonAndEncodeToString(vararg data: Pair<String, Any>): String {
    return Base64.encodeToString(
        JSONObject(data.toMap()).toString().toByteArray(),
        Base64.NO_PADDING or Base64.URL_SAFE or Base64.NO_WRAP,
    )
}

3) Sign encoded header & payload then encode in Base64

val signatureInput = "$encodedHeader.$encodedPayload"

val keyStore = loadKeyStore() // helper function to load the Android key store

// Retrieve Private Key
val entry: KeyStore.Entry = keyStore.getEntry(keyStoreAlias, null)
if (entry !is KeyStore.PrivateKeyEntry) {
    throw IllegalStateException("No such private key under the alias <$keyStoreAlias>")
}

// Setup signature & sign input
val ecdsaSign = Signature.getInstance("SHA256withECDSA")
ecdsaSign.initSign(entry.privateKey)
ecdsaSign.update(signatureInput.toByteArray()) // defaults to UTF-8

val signedContent = ecdsaSign.sign()
val encodedSignature = Base64.encodeToString(
    signedContent, 
    Base64.NO_PADDING or Base64.URL_SAFE or Base64.NO_WRAP,
)

// JWT complete
val token: String = "$encodedHeader.$encodedPayload.$encodedSignature"

4) Get PEM representation of public key

val keyStore = loadKeyStore()
val publicKey = keyStore.getCertificate(keyStoreAlias)?.publicKey ?: return null
val encoded = Base64.encodeToString(publicKey.encoded, Base64.DEFAULT)

return "-----BEGIN PUBLIC KEY-----\n$encoded-----END PUBLIC KEY-----"

Using this logic, this the final result I get:

eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjI3ZDA5YzcxLWJkNjctNDUyMy05N2JiLTEyZTkzM2U5YWNiYiIsImlhdCI6MTcwMjQ2Mjg1NH0.MEQCIGvk2N-3BDf13FAhAywXe7okYH4DygaViBWk6z6wnrvdAiBgJzHegGu8e9YSC9QiKqHvJxjyCRAX53tmmC_LaaFdRQ

Jwt.io reports that de decoding was successful but not the signature verification. I made sure to provide the PEM representation of my public key.

What am I doing wrong here ?


What I did to investigate & debug:

  1. I tried to verify the signature using the same instance of Signature and using the public key. The result is true.
  2. I made sure to use the correct flags when encoding in Base64 since I am using the Android Base64. The one from Java.util is not available due to our min SDK being set to 24 (Android 7):
  1. I tried to use the getKey(...) on the KeyStore to retrieve the PrivateKey which also works but the signature verification still fails on jwt.io, token.dev, etc.
  2. I verified that the PEM representation is also correct. This is my public key:
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa5M6OOsgL/+aR4jACkQxnjfRoJgw9fdsYR5ugxC6tBJIZA1oFRpjkc1TdQlbnE7BC6DEbaJPjH3jP0Lcnt2KWQ==
-----END PUBLIC KEY-----

I was able to validate it with online tools such as: https://report-uri.com/home/pem_decoder. It reports:

Public Key Data
Key Algorithm: ECDSA prime256v1
Key Size: 256 bits
Raw Data
Array
(
    [bits] => 256
    [key] => -----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEa5M6OOsgL/+aR4jACkQxnjfRoJgw
9fdsYR5ugxC6tBJIZA1oFRpjkc1TdQlbnE7BC6DEbaJPjH3jP0Lcnt2KWQ==
-----END PUBLIC KEY-----
    [ec] => Array
        (
            [curve_name] => prime256v1
            [curve_oid] => 1.2.840.10045.3.1.7
            [x] => 6b933a38eb202fff9a4788c00a44319e37d1a09830f5f76c611e6e8310bab412
            [y] => 48640d68151a6391cd5375095b9c4ec10ba0c46da24f8c7de33f42dc9edd8a59
        )

    [type] => 3
)

Thanks for the help.


Solution

  • You are using the wrong signature format, namely the ASN.1/DER encoding of the ECDSA signature. For JWT, the IEEE P1363 format (r|s) is required, see RFC 7518, sec 3.4.


    The following test shows that the wrong format is the cause of the problem: If in your signature:

    eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjI3ZDA5YzcxLWJkNjctNDUyMy05N2JiLTEyZTkzM2U5YWNiYiIsImlhdCI6MTcwMjQ2Mjg1NH0.MEQCIGvk2N-3BDf13FAhAywXe7okYH4DygaViBWk6z6wnrvdAiBgJzHegGu8e9YSC9QiKqHvJxjyCRAX53tmmC_LaaFdRQ
    

    the signature part is manually converted from ASN.1/DER to the IEEE P1363 format (s. here), the following signed JWT results:

    eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjI3ZDA5YzcxLWJkNjctNDUyMy05N2JiLTEyZTkzM2U5YWNiYiIsImlhdCI6MTcwMjQ2Mjg1NH0.a-TY37cEN_XcUCEDLBd7uiRgfgPKBpWIFaTrPrCeu91gJzHegGu8e9YSC9QiKqHvJxjyCRAX53tmmC_LaaFdRQ
    

    which can be successfully verified on jwt.io with the public key you posted. This shows that the signature itself is correct and the problem is only caused by the format.


    To fix the code, apply SHA256withPlain-ECDSA instead of SHA256withECDSA for a signature in IEEE P1363 format (if necessary using BouncyCastle).


    Edit: The conversion from ASN.1/DER format to P1363 format can also be implemented by yourself with an ASN.1/DER parser, e.g. with hierynomus/asn-one.
    Then, SHA256withECDSA can still be used (so that BouncyCastle is not necessary). The ASN.1/DER signature is simply converted to the P1363 format afterwards. This could be implemented e.g. as follows:

    import com.hierynomus.asn1.ASN1InputStream
    import com.hierynomus.asn1.encodingrules.der.DERDecoder
    import com.hierynomus.asn1.types.constructed.ASN1Sequence
    ...
    fun toP1363(derSignature: ByteArray, size: Int) : ByteArray {
        val stream = ASN1InputStream(DERDecoder(), derSignature)
        val sequence = stream.readObject<ASN1Sequence>() 
        val r = (sequence.get(0).value as BigInteger).toString(16).padStart(size, '0')
        val s = (sequence.get(1).value as BigInteger).toString(16).padStart(size, '0')
        return (r + s).hexStringToByteArray()
    }
    fun String.hexStringToByteArray() = this.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
    

    with the following dependency in the gradle build script:

    implementation 'com.hierynomus:asn-one:0.6.0'
    

    Test:

    val signedContent = Base64.decode("MEQCIGvk2N-3BDf13FAhAywXe7okYH4DygaViBWk6z6wnrvdAiBgJzHegGu8e9YSC9QiKqHvJxjyCRAX53tmmC_LaaFdRQ", Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING)
    val encodedSignature = Base64.encodeToString(
        toP1363(signedContent, 64),
        Base64.NO_PADDING or Base64.URL_SAFE or Base64.NO_WRAP,
    )
    println(encodedSignature) // a-TY37cEN_XcUCEDLBd7uiRgfgPKBpWIFaTrPrCeu91gJzHegGu8e9YSC9QiKqHvJxjyCRAX53tmmC_LaaFdRQ