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..."
]
}
I'm an experienced developer in a hundred other topics, but 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 so I'm adding here in case Gary (or anyone else) can help me resolve this issue:
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$publickey\n";
$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 key
Fatal 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-----
CodePudding user response:
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. This is the token signing public key. I think with this library you just omit the key parameter to decode to do this.
Form a public key object, then call decode, as in this Firebase example. You may need to add the surrounding begin / end lines, and there are no private keys involved.
$publicKey = <<<EOD
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8kGa1pSjbSYZVebtTRBLxBz5H
4i2p/llLCrEeQhta5kaQu/RnvuER4W8oDH3 3iuIYW4VQAzyqFpwuzjkDI 17t5t
0tyazyZ8JXw KgXTxldMPEL95 qVhgXvwtihXC1c5oGbRlEDvDF6Sa53rcFVsYJ4
ehde/zUxo6UvS7UrBQIDAQAB
-----END PUBLIC KEY-----
EOD;
- Finally you need to verify the trust chain in the x5c array against a whitelist of trusted issuers, which is Apple's root CA in your case. This prevents a malicious party from sending you a JWT with an untrusted x5c array. I believe this PHP answer addresses this point.
CodePudding user response:
Ok, I figured it out with the excellent help provided by Gary's answer, so please make sure to upvote his answer as well. 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.
list($headerb64, $bodyb64, $cryptob64) = explode('.', $jwt);
$headertext = JWT::urlsafeB64Decode($headerb64);
$header = JWT::jsonDecode($headertext);
$dercertificateb64 = $header->x5c[0];
$wrappedcertificatetext = trim(chunk_split($dercertificateb64, 64));
$certificate = <<<EOD
-----BEGIN CERTIFICATE-----
$wrappedcertificatetext
-----END CERTIFICATE-----
EOD;
print "cert:\n$certificate\n";
Firebase's php-jwt library will take an OpenSSLAsymmetricKey
object as the key data, and openssl_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:
$publickey = openssl_pkey_get_public($certificate);
$decoded = JWT::decode($jwt, new Key($publickey, $header->alg));