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.
{
"alg": "ES256",
"typ": "JWT"
}
payload
must contain an id and the iat
as follow:{
"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:
KeyPair
and store it in the Android Key Store
: https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#example:-nist-p-256-ec-key-pair-for-signingverification-using-ecdsaThis is the code I am coming up with:
// 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()
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,
)
}
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"
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:
Signature
and using the public key. The result is true
.getKey(...)
on the KeyStore
to retrieve the PrivateKey
which also works but the signature verification still fails on jwt.io, token.dev, etc.-----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.
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