9

How can I validate an in-app purchase JWS Representation from StoreKit2 on my backend in Node?

Its easy enough to decode the payload, but I can't find public keys that Apple uses to sign these JWS/JWTs anywhere. Any other time I've worked with JWTs, you simply used the node jsonwebtoken library and passed in the signers public key or shared secret key, either configured or fetched from a JWK.

I can easily decode the JWS using node-jose j.JWS.createVerify().verify(jwsString, {allowEmbeddedKey: true}).then(r => obj = r) which gives me an object like:

 {
  protected: [ 'alg', 'x5c' ],
  header: {
    alg: 'ES256',
    x5c: [
      'MIIEMDueU3...',
      'MII..., 
'MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0...'  
    ]
  },
  payload: <Buffer 7b 22 74 72 61 6e 73 61 63 74 69 6f 6e 49 64 22 3a 22 31 30 30 30 30 30 30 38 38 36 39 31 32 38 39 30 22 2c 22 6f 72 69 67 69 6e 61 6c 54 72 61 6e 73 ... 420 more bytes>,
  signature: <Buffer f8 85 65 79 a1 dc 74 dd 90 80 0a a4 08 85 30 e7 22 80 4c 20 66 09 0b 84 fc f4 e5 57 53 da d5 6f 13 c6 8f 56 e8 29 67 5c 95 a6 27 33 47 1e fe e9 6e 41 ... 14 more bytes>,
  key: JWKBaseKeyObject {
    keystore: JWKStore {},
    length: 256,
    kty: 'EC',
    kid: 'Prod ECC Mac App Store and iTunes Store Receipt Signing',
    use: '',
    alg: ''
  }
}

And its easy to JSON.parse the payload and get the data I want. But, how can i verify that its authentic using the certificate chain in the x5c field

Thank you!

max
  • 811
  • 6
  • 13

4 Answers4

8

Finally figured this out. It turns out that we needed a "hardcoded" certificate to check against.

Apple has the certificates needed on their website. You have download the root certificate (since that's the one signing the entire chain), but you can also get the intermediate one.

Once you download one you convert it to .pem:

 $ openssl x509 -inform der -in apple_root.cer -out apple_root.pem

then all you need to do is verify them against the ones in the JWS (the following is in PHP, but you should get the gist):

if (openssl_x509_verify($jws_root_cert, $downloaded_apple_root_cert) == 1){
    //valid
}

Hope this helps everyone else!

Merricat
  • 2,583
  • 1
  • 19
  • 27
  • This is it! Just needed that website. For the life of me could not find that site. Will code it up later to check. – max Nov 12 '21 at 19:37
  • @Merricat I'm a bit confused. What is $jws_root_cert ? Where can I get it? – Daron Tancharoen Nov 24 '21 at 10:49
  • @DaronTancharoen I'm referring to `$jws_root_cert` as the last certificate in the JWS's header `x5c` field (after decoding it). If you're having trouble, see [my original post](https://stackoverflow.com/q/69816264/8845253) – Merricat Nov 25 '21 at 16:49
  • Thank you for your answer. I have done it in Golang in this [sample](https://stackoverflow.com/a/69739376/3011380) – zangw Jan 26 '22 at 08:11
  • You are only validating the root certificate from the header. Now you need to validate the entire JWS – pableiros Jun 16 '22 at 21:18
  • @max did this end up working? There seems to be almost no information about how to do this with StoreKit2. – maxcountryman Jan 24 '23 at 02:46
  • @maxcountryman it looks like it would. Oliver's answer below is what worked for me. The key is figuring out that Apple host the certificates on their website at https://www.apple.com/certificateauthority/ which was mentioned in both answers. – max Jan 31 '23 at 13:28
7

It is quite challenging to piece this together from all the information, but here's how to do this in NodeJS. Note that the latest Node supports built-in crypto, which makes it much much easier. Here's my code with the necessary comments.

const jwt = require('jsonwebtoken');
const fs = require('fs');
const {X509Certificate} = require('crypto');

async function decode(signedInfo) {

    // MARK: - Creating certs using Node's new build-in crypto
    function generateCertificate(cert) {
        // MARK: - A simple function just like the PHP's chunk_split, used in generating pem. 
        function chunk_split(body, chunklen, end) {
            chunklen = parseInt(chunklen, 10) || 76;
            end = end || '\n';
            if (chunklen < 1) {return false;}
            return body.match(new RegExp(".{0," + chunklen + "}", "g")).join(end);
        }
        return new X509Certificate(`-----BEGIN CERTIFICATE-----\n${chunk_split(cert,64,'\n')}-----END CERTIFICATE-----`);
    }

    // MARK: - Removing the begin/end lines and all new lines/returns from pem file for comparison
    function getPemContent(path) {
        return fs.readFileSync(path)
            .toString()
            .replace('-----BEGIN CERTIFICATE-----', '')
            .replace('-----END CERTIFICATE-----', '')
            .replace(/[\n\r]+/g, '');
    }



    // MARK: - The signed info are in three parts as specified by Apple
    const parts = signedInfo.split('.');
    if (parts.length !== 3) {
        console.log('The data structure is wrong! Check it! ');
        return null;
    }
    // MARK: - All the information needed for verification is in the header
    const header = JSON.parse(Buffer.from(parts[0], "base64").toString());

    // MARK: - The chained certificates
    const certificates = header.x5c.map(cert => generateCertificate(cert));
    const chainLength = certificates.length;

    // MARK: - Leaf certificate is the last one
    const leafCert = header.x5c[chainLength-1];
    // MARK: - Download .cer file at https://www.apple.com/certificateauthority/. Convert to pem file with this command line: openssl x509 -inform der -in AppleRootCA-G3.cer -out AppleRootCA-G3.pem
    const AppleRootCA = getPemContent('AppleRootCA-G3.pem');
    // MARK: - The leaf cert should be the same as the Apple root cert
    const isLeafCertValid = AppleRootCA === leafCert;
    if (!isLeafCertValid) {
        console.log('Leaf cert not valid! ');
        return null;
    }

    // MARK: If there are more than one certificates in the chain, we need to verify them one by one 
    if (chainLength > 1) {
        for (var i=0; i < chainLength - 1; i++) {
            const isCertValid = certificates[i].verify(certificates[i+1].publicKey);
            if (!isCertValid) {
                console.log(`Cert ${i} not valid! `);
                return null;
            }
        }
    }

    return jwt.decode(signedInfo);
}

Good luck!

Oliver Zhang
  • 499
  • 6
  • 16
  • Thank you for such an easy to follow answer! Especially the comments about downloading / converting the cert. So essentially we can trust the jwsRepresentation if this method returns an object rather than `null`? – Trev14 Jun 03 '23 at 17:51
3

You need to validate the header and the payload with the sign like says in the WWDC videos:

https://developer.apple.com/videos/play/wwdc2022/10040/ https://developer.apple.com/videos/play/wwdc2021/10174/

enter image description here

But is more complicate than you think to do this if you don't have the knownledge about JWT because there is no documentation from Apple to do this, they only say to you "use your favorite cryptographic library to verify the data".

So after doing a lot of research, finally I found a solution using PHP 8.1 with Laravel.

First you need to install this library https://github.com/firebase/php-jwt:

composer require firebase/php-jwt

Then you need to implement the following method in order to validate the JWT from the transaction:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

...

public function validateJwt($jwt)
{
    $components = explode('.', $jwt);

    if (count($components) !== 3) {
        throw new \Exception('JWS string must contain 3 dot separated component.');
    }

    $header = base64_decode($components[0]);
    $headerJson = json_decode($header,true);

    $this->validateAppleRootCA($headerJson);

    $jwsParsed = (array) $this->decodeCertificate($jwt, $headerJson, 0);

    for ($i = 1; $i < count($headerJson) - 1; $i++) {
        $this->decodeCertificate($jwt, $headerJson, $i);
    }

    // If the signature and the jws is invalid, it will thrown an exception

    // If the signature and the jws is valid, it will create the $decoded object

    // You can use the $decoded object as an array if you need:

    $transactionId = $jwsParsed['transactionId'];
}

private function validateAppleRootCA($headerJson)
{
    $lastIndex = array_key_last($headerJson['x5c']);
    $certificate = $this->getCertificate($headerJson, $lastIndex);

    //  As Oliver Zhang says in their NodeJS example, download the .cer file at https://www.apple.com/certificateauthority/. Convert to pem file with this command line: openssl x509 -inform der -in AppleRootCA-G3.cer -out AppleRootCA-G3.pem
    // In Laravel, this location is at storage/keys/AppleRootCA-G3.pem
    $appleRootCA = file_get_contents(storage_path('keys/AppleRootCA-G3.pem'));

    if ($certificate != $appleRootCA) {
        throw new \Exception('jws invalid');
    }
}

private function getCertificate($headerJson, $certificateIndex)
{
    $certificate = '-----BEGIN CERTIFICATE-----'.PHP_EOL;
    $certificate .= chunk_split($headerJson['x5c'][$certificateIndex],64,PHP_EOL);
    $certificate .= '-----END CERTIFICATE-----'.PHP_EOL;

    return $certificate;
}

private function decodeCertificate($jwt, $headerJson, $certificateIndex)
{
    $certificate = $this->getCertificate($headerJson, 0);

    $cert_object = openssl_x509_read($certificate);
    $pkey_object = openssl_pkey_get_public($cert_object);
    $pkey_array = openssl_pkey_get_details($pkey_object);
    $publicKey = $pkey_array['key'];
    $jwsParsed = null;

    try {
        $jwsDecoded = JWT::decode($jwt, new Key($publicKey, 'ES256'));
        $jwsParsed = (array) $jwsDecoded;
    } catch (SignatureInvalidException $e) {
        throw new \Exception('signature invalid');
    }

    return $jwsParsed;
}

To call the function, you need to pass the jwt from the transaction:

$jwt = 'eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUl...';
validateJwt($jwt);
pableiros
  • 14,932
  • 12
  • 99
  • 105
  • 2
    Your code made my day, I can't understand how Apple has that trash documentation. Only one thing, with the v2 your way to access the transactionId is not the right way. You need to add something like this $decodedTransactionInfo = JWT::Decode($decoded->data->signedTransactionInfo, new Key($publicKey, $headerJson['alg'])); and then you will have access to the transactionId and the rest of the information – Adrián Pastoriza Jun 28 '22 at 17:40
  • 1
    And you should change the use to use Firebase\JWT\JWT; use Firebase\JWT\Key; – Adrián Pastoriza Jun 28 '22 at 17:52
  • How could you make sure the public key in the header is from Apple? Do you need to verify with Apple Root CA? – benck Aug 17 '22 at 02:11
  • This simply decodes the JWT but doesn't validate it against Apple's certificate. Oliver Zhang's solution below is the correct one, I believe. – max Sep 01 '22 at 13:23
  • @max I added the Apple's certificate validation like Oliver Zhangs Node JS example does – pableiros Sep 01 '22 at 15:08
  • @benck I added the Apple's certificate validation – pableiros Sep 01 '22 at 15:09
  • if anyone is using StoreKit Testing in Xcode - just know that there is a unique "StoreKit Testing in Xcode" root certificate that you need to use to validate against, the AppleRootCA-G3 will fail. – Andre Sugai Mar 09 '23 at 04:15
1

The JWS x5c header parameter contains the entire certificate chain used to sign and validate the JWS. There is no need to fetch any other certificates or keys.

The RFC specifies that the certificate corresponding to the public key that was used to sign the JWS must be the first certificate.

You can extract the public key from this certificate and use it to verify the JWS signature. There is some guidance on this in this answer

One of the great improvements in StoreKit2 is that you are no longer required to use a server to validate in app purchase transactions securely.

Apple's WWDC 2021 session on StoreKit2 describes the content of the JWS and also shows how to validate on device that the JWS was actually generated for that device.

But, what if you do want to validate the transaction on a server? Since the x5c claim contains the certificate chain, an attacker could sign a forged JWS with their own certificate and include that certificate in the x5c claim.

The answer is that you have your app send the original transaction id to your server along with any other information you need, such as the user's account identifier. Your server can then request the corresponding JWS from Apple and validate the signature of the returned JWS.

As the JWS was fetched from Apple by your server code it can be sure that it is not a spoofed JWS.

If possible, include an appAccountToken in your purchase request and either determine the expected token value based on the user's authentication to your server or (less effective) have your app supply the token when it supplies the original transaction id. You can then verify the token value in the JWS matches the expected value. This makes it harder for an attacker to replay some other purchase event.

Paulw11
  • 108,386
  • 14
  • 159
  • 186
  • 1
    What if someone send you a false Server To Server Notification (S2S) and sign the JWS with his own certificate? The chain would be valid and you would trust the content of this S2S. So I think there is a missing part in your explanation: you should know at least 1 of the certificates in the chain BEFORE validating the chain, otherwise anybody could send you forget receipts. And that's what Max is asking for: where do you get this certificate from Apple? – romainsalles Oct 09 '21 at 08:49
  • This is why you actually get validation on the device now. With the old store kit you needed to perform validation on a server. With storekit2 the validation is performed before the transaction is delivered to your app and you don't really need t perform validation on a server. – Paulw11 Oct 09 '21 at 11:49
  • If you want to perform validation on your server then you should not send the signed transaction from the app to your server. You should send only the original transaction Id. You server can the request the transaction history JWS from Apple. Since the response came from Apple the x5c claim is trustworthy. – Paulw11 Oct 09 '21 at 12:06
  • I didn't understood things like that. I mean, it's only trustworthy if no one goes between you and Apple (for the same reasons I explained before). – romainsalles Oct 09 '21 at 23:31
  • For an attacker to place themselves between your server and Apple would be quite an undertaking in itself. And then they would need to coordinate interception of traffic from your app to your server in order to capture the appAccountToken. And, in fact, the app account token doesn't even need to appear on the wire if the server can compute the same token that the app passed to apple during the purchase. And, of course, your server can validate the certificate presented by Apple's server when you request the JWS, preventing MITM. – Paulw11 Oct 10 '21 at 03:21
  • Yes, I do agree that the risk is minimum. But if we do want to "validate the certificate presented by Apple's server when [we] request the JWS" to avoid the MITM, we need the root certificate to do so, right? And if it's the case, where do we get it? I think that was the initial question. But once again, a do agree with your evaluation of the risks and thx for your answers :) – romainsalles Oct 10 '21 at 22:25
  • The short answer is I don't believe that Apple have made it available from an external source, although I haven't looked at the entire chain that is included in a StoreKit2 JWS, so perhaps it chains back to the Apple worldwide developer root – Paulw11 Oct 10 '21 at 23:49
  • @romainsalles hit the nail on the head. Anyone could create a JWT that says whatever they want and sign it with a key that would make it valid. In order to validate the JWT that the app sends to the server you need the public key that corresponds to the private key that it was signed with. Note that when Apple sends a JWT for a Sign in With Apple flow, they publish JWKs which give you the keys to validate JWTs against. Unless Apple publishes those keys, there's no way to validate that JWT is real. – max Oct 19 '21 at 14:34
  • @Paulw11 100% agree that its basically impossible for an attacker to MITM themselves between my server an apple. Considering that Apple's APIs are SSLed, I'll say its impossible. Maybe the NSA can do it, not worried about that threat level. – max Oct 19 '21 at 14:36
  • You never know @max... you never know... – romainsalles Oct 19 '21 at 15:10
  • @max, yes, but you don't need to send the JWT from your app to the server to validate it with StoreKit2. You can validate it on device. If you do want to validate on a server, your server can request its own copy directly from Apple. You can also use app attestation to verify that the token or other message came from your app and your app hasn't been modified. – Paulw11 Oct 19 '21 at 19:59
  • @Paulw11 validating on the device is basically meaningless if you're doing anything on the backend since you can't trust that the request coming from the device was generated by your app or someone malicious. – max Oct 19 '21 at 20:16
  • @Paulw11 requesting your own copy doesn't work because the https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history transaction history doesn't include consumable purchases. – max Oct 19 '21 at 20:17
  • @Paulw11 do you have any code for verifying that the app attestation in the JWT is somehow signed by a root certificate authority, like an SSL cert? that would solve the problem – max Oct 19 '21 at 20:17
  • No, those are two separate things. The StoreKit2 JWT is self contained. The certificate chain is in the x5c header. Your concern seems to be that your app sends the JWT to your server to validate it and that that could be spoofed. My answer is 1) Don't do that. You don't require server validation on StoreKit2. 2) If you do want to to do that, send the transaction id to your server and have the server request its own JWT directly from Apple making spoofing it almost impossible and 3) You can use App Attestation from the DeviceCheck ios Framework to validate messages came from your app – Paulw11 Oct 19 '21 at 20:28
  • Also, if your user has an account in your back end, make sure you are using the `appAccountToken` in your purchase request. Your server can then validate that the app account token in the JWT it gets from Apple matches what is expected. This helps prevents replay attacks – Paulw11 Oct 19 '21 at 20:30
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/238336/discussion-between-paulw11-and-max). – Paulw11 Oct 19 '21 at 20:37
  • @Paulw11 Hey, I just found this thread, but I don't understand how to resolve the issue max brought up. How are we supposed to verify that the JWS came, in fact, from Apple? Note that I'm specifically talking about server-to-server, which happens, for example, when a user purchases a subscription from the settings (not the app). I don't understand why they don't just implement the same method they have with Sign In with Apple, using the JWKs and kid – Merricat Oct 30 '21 at 23:41