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:
- The key must be a P-256 key, meaning EC Algorithm (ECDSA) with SHA-256.
- The header of the token must be as follow:
{
"alg": "ES256",
"typ": "JWT"
}
- and the
payloadmust contain an id and theiatas 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:
- How to generate a P-256
KeyPairand store it in theAndroid Key Store: https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec#example:-nist-p-256-ec-key-pair-for-signingverification-using-ecdsa - How to sign (and verfiy signature): https://developer.android.com/privacy-and-security/cryptography
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:
- I tried to verify the signature using the same instance of
Signatureand using the public key. The result istrue. - 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):
- the token content is encoded in Base64 without padding, safe for URL and without line breaks (no wrap) while
- the PEM representation of the public key is encoded with default flag.
- I tried to use the
getKey(...)on theKeyStoreto retrieve thePrivateKeywhich also works but the signature verification still fails on jwt.io, token.dev, etc. - 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.
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:
the signature part is manually converted from ASN.1/DER to the IEEE P1363 format (s. here), the following signed JWT results:
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-ECDSAinstead ofSHA256withECDSAfor 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,
SHA256withECDSAcan 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:with the following dependency in the gradle build script:
Test: