I’m trying to use Apple’s App Store Server API to fetch transaction info about in-app purchases made in my iOS apps. Their server api uses JWTs to transmit/sign the data. I’m able to successfully fetch the data from Apple which contains an array of signed transaction JWTs:
{
"status":0,
"signedTransactions":[
"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWF..."
]
}
So far, so good. But when I try to decode the signed transaction JWTs using Firebase’s php-jwt library, I get fatal errors. I tried the example code from Firebase’s php-jwt library first:
$signedTransactionJWT = $response['signedTransactions'][0];
$privateKeyText = file_get_contents('/private/key/from/appstoreconnect.p8');
$decodedTransactionPayload = JWT::decode($signedTransactionJWT, new Key($privateKeyText, 'ES256'));
but that gave me:
openssl_verify(): supplied key param cannot be coerced into a public key
A bunch of web searching about Apple’s public keys later, I tried using the auth keys published on Apple’s website:
$signedTransactionJWT = $response['signedTransactions'][0];
$appleKeysText = file_get_contents('/file/downloaded/from https://appleid.apple.com/auth/keys');
$jwks = json_decode($appleKeysText, true);
$keyset = JWK::parseKeySet($jwks);
$decodedTransactionPayload = JWT::decode($signedTransactionJWT, $keyset);
…but it horks with the following error:
Fatal error: Uncaught UnexpectedValueException: "kid" empty, unable to lookup correct key
I looked through the JWT::decode() method, and it’s looking for a key id ("kid") in the header of the signed transaction JWT, but Apple doesn’t provide a "kid" in the header of the signed transaction JWT. The structure of the header looks like this:
{
"alg": "ES256",
"x5c": [
"MIIEMDCCA7agAwIBAgIQaPoPldvpSoEH0lBrjDPv9jAKBggqhkjOPQQDAzB1M...",
"MIIDFjCCApygAwIBAgIUIsGhRwp0c2nvU4YSycafPTjzbNcwCgYIKoZIzj0EA...",
"MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEA..."
]
}
This is my first time working with JWTs, so I’m doing my best to understand the various interacting pieces here. According to the WWDC videos about Apple’s App Store Server API, the "x5c" part of the header is supposed to be used to be able to validate the transaction without any other external web calls. So, I feel like I shouldn’t need to fetch those JWT keys from https://appleid.apple.com/auth/keys. The idea, as I understood it, is that the signature is supposed to be self-contained.
How can I properly decode the JWTs from Apple so that I can verify the payload using Firebase’s php-jwt library?
Update
According to Gary’s answer, I need to use the first item in the x5c array as the public key. He provided some very helpful links and examples. Hopefully this will lead me to the right answer, but I’m still having a problem:
list($headerb64, $bodyb64, $cryptob64) = explode('.', $jwt);
$headertext = JWT::urlsafeB64Decode($headerb64);
$header = JWT::jsonDecode($headertext);
$keytext = $header->x5c[0];
$wrappedkeytext = trim(chunk_split($keytext, 64));
$publickey = <<<EOD
-----BEGIN PUBLIC KEY-----
$wrappedkeytext
-----END PUBLIC KEY-----
EOD;
print "public key:n$publickeyn";
$decoded = JWT::decode($jwt, new Key($publickey, $header->alg));
As instructed, I decoded the header, grabbed the first item, turned it into a public key formatted string, and then tried to use that to decode the $jwt
, but I got this error:
Warning: openssl_verify(): supplied key param cannot be coerced into a
public keyFatal error: Uncaught DomainException: OpenSSL error:
error:0909006C:PEM routines:get_name:no start line
I printed out the public key string, so that I could make sure I was formatting it right. It looks right to me, but I’m very new to this, so I might missing some subtle problem. At first, I tried it with the content all on one line, but got the above error. Then I split it into lines of 64 characters long since I found some documentation saying that these text blocks should be limited to 64 characters in length. But I still got the same error message.
-----BEGIN PUBLIC KEY-----
MIIEMDCCA7agAwIBAgIQaPoPldvpSoEH0lBrjDPv9jAKBggqhkjOPQQDAzB1MUQw
QgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0
aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxl
IEluYy4xCzAJBgNVBAYTAlVTMB4XDTIxMDgyNTAyNTAzNFoXDTIzMDkyNDAyNTAz
M1owgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5l
cyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lk
ZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYD
VQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOoTcaPcpeipNL9eQ06t
Cu7pUcwdCXdN8vGqaUjd58Z8tLxiUC0dBeA+euMYggh1/5iAk+FMxUFmA2a1r4aC
Z8SjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig9
7bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2Nl
cnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2Nz
cC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggEN
BgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRo
aXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBv
ZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRp
b25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9u
IHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFw
cGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFCOCmMBq//1L
5imvVmqX1oCYeqrMMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAK
BggqhkjOPQQDAwNoADBlAjEAl4JB9GJHixP2nuibyU1k3wri5psGIxPME05sFKq7
hQuzvbeyBu82FozzxmbzpogoAjBLSFl0dZWIYl2ejPV+Di5fBnKPu8mymBQtoE/H
2bES0qAs8bNueU3CBjjh1lwnDsI=
-----END PUBLIC KEY-----
2
Answers
I figured it out with the excellent help provided by Gary's answer. I was only able to figure it out because he not only provided an example of what he meant, but he linked to the actual standards and some detailed reading helped me figure out where things went wrong.
The first item in the
x5c
array isn't the public key, but it's the certificate that holds the public key. So, when I tried to put that data into the-----BEGIN PUBLIC KEY-----
and-----END PUBLIC KEY-----
blocks, it wouldn't work.The certs in the x5c array are DER certs, but openssl wants PEM certs when it does verification. As far as I can tell, converting a DER cert to a PEM cert just involves taking the DER data, base64 encoding it, limiting it to 64 characters wide per line, then wrapping it in
-----BEGIN CERTIFICATE-----
and-----END CERTIFICATE-----
. But the DER data in the x5c array is already base64 encoded, so we can skip that step.Firebase's php-jwt library will take an
OpenSSLAsymmetricKey
object as the key data, andopenssl_pkey_get_public()
will return that type of object. You can pass the PEM certificate string into that function and it'll parse and extract the public key:Apple are providing self-contained JWTs as explained in this section of RFC7515. To verify the JWT these are the steps:
Decode the JWT without verifying it, to read the first value in the x5c array, which is a base64 encoded DER certificate containing the token signing public key. I think with this library you can just call the single parameter version of decode, without a key object.
Next form a public key object, then call decode with two parameters, as in this Firebase example. You may need to add the surrounding BEGIN / END lines, and you will not need to use any private keys. Some libraries may have particular requirements around DER / PEM formats, or require the encoded cert to be expressed on a single line.