Verify Apple Certificate Chain and JWT signature

|

Overview

In this article, we will explore step by step how to verify Apple's certificate chain and validate the payload signature. Click here to go to the full code on GitHub.


Apple's servers will send a POST request with the JWS that we need to verify before trusting its content. It is not mandatory to do so, but to ensure that the request was sent by Apple and not by some malicious actor, it is best not to leave anything to chance.

What is a JWS and how does it work?

JWS (JSON Web Signature) is an open standard that allows JSON data to be securely signed. It is used to ensure the integrity and authenticity of the data, allowing the recipient to verify that the data has not been altered during transmission and that it actually comes from a trusted source, in our case, Apple.


A JWS consists of three parts separated by dots:

  • Header: Contains information about the type of signature and the algorithm used.
  • Payload: Contains the actual data, which can be any type of information (such as a JSON with request details).
  • Signature: It is the digital signature that ensures the integrity of the payload and confirms it actually comes from the claimed source (in this case, Apple).

Apple uses the ES256 algorithm, which stands for ECDSA (Elliptic Curve Digital Signature Algorithm) with the P-256 curve and the SHA-256 hashing function. This means that it creates a hash of the header and payload using the SHA-256 algorithm, which is then signed using a private key based on the P-256 curve. Anyone with the correct public key can verify that the JWS has not been tampered with and that it comes from a trusted source.

Hands on the code

When Apple sends the POST request, it includes a key called 'signedPayload', whose value is our JWS.


Let's make sure to prepare a POST endpoint in the routes file that expects a body containing the 'signedPayload' key:



Route::post('/route/path', [AppleNotificationController::class, 'handle']);
...
$validator = Validator::make($request->all(), [
    'signedPayload' => 'required|string',
]);
if ($validator->fails()) {
    Log::error('Validation failed', ['errors' => $validator->errors()]);
    return Responses::errorResponse('Malformed request');
}
...
        

Create these helper functions to decode the JWS with all its three parts: header, payload, and signature:


...
$decodedJWT = JWTReader::decodeJWT($validated['signedPayload']);
...
class JWTReader
{
    public static function base64UrlDecode($input)
    {
        $input = strtr($input, '-_', '+/');
        $padLength = 4 - (strlen($input) % 4);
        if ($padLength < 4) {
            $input .= str_repeat('=', $padLength);
        }
        $decoded = base64_decode($input, true);
        if ($decoded === false) {
            throw new Exception('Invalid base64URL encoding');
        }
        return $decoded;
    }
    public static function decodeJWT($jwt)
    {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) {
            throw new Exception('Invalid JWT format');
        }
        [$header, $payload, $signature] = $parts;
        $decodedHeader = json_decode(self::base64UrlDecode($header), true);
        $decodedPayload = json_decode(self::base64UrlDecode($payload), true);
        if (!$decodedHeader || !$decodedPayload) {
            throw new Exception('Invalid JSON in JWT');
        }
        return [
            'header' => $decodedHeader,
            'payload' => $decodedPayload,
            'signature' => $signature
        ];
    }
}

Once the JWS has been successfully decoded, we can proceed to verify that the header contains the x5c key, an array consisting of three strings that are the certificates involved in the process (Certificate Chain).


To verify that the Certificate Chain is valid, we need to ensure that the first certificate (Leaf Certificate) was signed by the second (Intermediate Certificate), that the second was signed by the last of the three (Root Certificate), and that the last one is indeed issued by the CA.


Now, let's create a function to fetch the Root Certificate from Apple or download it manually from here https://www.apple.com/certificateauthority/AppleRootCA-G3.cer and save it in your project.


private function fetchAppleRootCertificate()
{
    $certUrl = 'https://www.apple.com/certificateauthority/AppleRootCA-G3.cer';

    $ch = curl_init($certUrl);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
    $certData = curl_exec($ch);
    curl_close($ch);

    if ($certData === false) {
        throw new Exception('Failed to download Apple root certificate.');
    }

    file_put_contents($this->certFilePath, $certData);

    exec("openssl x509 -inform DER -in $this->certFilePath -out $this->pemFilePath", $output, $returnVar);

    if ($returnVar !== 0) {
        throw new Exception('Failed to convert DER to PEM format.');
    }

    $certificatePEM = $this->getRootCertificateFromStorage();

    /** REMOVING CERTIFICATES FROM DISK */
    unlink($this->certFilePath);
    unlink($this->pemFilePath); /// COMMENT IF YOU ARE NOT CACHING SOMEWHERE ELSE
    /** */

    /**
     * HERE I AM CACHING IN REDIS WITH EXPIRATION IN 7 DAYS
     */
    Redis::set('apple_root_certificate', $certificatePEM);
    Redis::expire('apple_root_certificate', 7 * 24 * 60 * 60);

    return $certificatePEM;
}

You are free to choose how you prefer: keep it on disk or cache it in memory with an expiration time to fetch it automatically from time to time.


Now that we have Apple's Root Certificate, we can proceed with the actual verification of the Certificate Chain:


private function getCachedAppleRootCertificate()
{
    return Redis::get('apple_root_certificate') ??  $this->fetchAppleRootCertificate();

    /** 
     * IF YOU ARE NOT CACHING IN MEMORY
     */
    // return $this->getRootCertificateFromStorage() ??  $this->fetchAppleRootCertificate();
}

private function getRootCertificateFromStorage()
{
    $tempPEM = file_get_contents($this->pemFilePath);
    if (!$tempPEM) {
        return null;
    }

    $certResource = openssl_x509_read($tempPEM);
    if (!$certResource) {
        return null;
    }

    return openssl_x509_export($certResource, $certificatePEM) ? $certificatePEM : null;
}

private function getPEMFromX5C($x5c)
{
    return "-----BEGIN CERTIFICATE-----\n" . chunk_split($x5c, 64, "\n") . "-----END CERTIFICATE-----\n";
}

private function verifyCertificateChain($decodedPayload)
{
    $appleRootCertPEM = $this->getCachedAppleRootCertificate();

    $leafCertPEM = $this->getPEMFromX5C($decodedPayload['header']['x5c'][0]);
    $intermediateCertPEM = $this->getPEMFromX5C($decodedPayload['header']['x5c'][1]);
    $rootCertPEM = $this->getPEMFromX5C($decodedPayload['header']['x5c'][2]);


    if (trim($rootCertPEM) !== trim($appleRootCertPEM)) {
        Log::error('Root certificate does not match Apple Root CA, cleaning cache and downloading fresh root cert');
        Redis::del('apple_root_certificate');

        /** UNCOMMENT IF NOT USING REDIS */
        // unlink($this->pemFilePath); 

        $appleRootCertPEM = $this->getCachedAppleRootCertificate();
        if (trim($rootCertPEM) !== trim($appleRootCertPEM)) {
            Log::error('Root certificate does not match Apple Root CA');
            return null;
        }
    }

    $leafCert = openssl_x509_read($leafCertPEM);
    $intermediateCert = openssl_x509_read($intermediateCertPEM);
    $rootCert = openssl_x509_read($rootCertPEM);

    if (!$leafCert || !$intermediateCert || !$rootCert) {
        Log::error('Failed to load certificates');
        return null;
    }

    if (!openssl_x509_verify($leafCert, $intermediateCert)) {
        Log::error('Leaf certificate is not signed by Intermediate certificate');
        return null;
    }

    if (!openssl_x509_verify($intermediateCert, $rootCert)) {
        Log::error('Intermediate certificate is not signed by Root certificate');
        return null;
    }

    return $leafCert;
}

At this point, we have the necessary code to verify the Certificate Chain. Let's add the code to check that the signature of our JWS is correct so that we can trust the information Apple has sent us:


private function extractCertificatePublicKey($leafCertPEM)
{
    $cert = openssl_x509_read($leafCertPEM);
    if (!$cert) {
        Log::error('Invalid leaf certificate, unable to read.');
        return false;
    }

    $publicKeyResource = openssl_pkey_get_public($cert);
    if (!$publicKeyResource) {
        Log::error('Failed to extract public key from leaf certificate');
        return false;
    }

    $keyDetails = openssl_pkey_get_details($publicKeyResource);
    if (!$keyDetails || !isset($keyDetails['key'])) {
        Log::error('Failed to retrieve public key details');
        return false;
    }
    return $keyDetails['key'];
}


private function verifyAppleSignature($decodedPayload, $leafCertPEM)
{
    $publicKey = $this->extractCertificatePublicKey($leafCertPEM);

    if (!$publicKey) {
        Log::error('Failed to extract public key from leaf certificate.');
        return false;
    }

    $decodedSignature = JWTReader::base64UrlDecode($decodedPayload['signature']);
    if (!$decodedSignature) {
        Log::error('Failed to decode base64 signature.');
        return false;
    }

    $signature = $this->convertSignatureToDER($decodedSignature);

    if (!$signature) {
        Log::error('Signature conversion failed.');
        return false;
    }

    $dataToVerify = $decodedPayload['header'] . '.' . $decodedPayload['payload'];

    $verificationResult = openssl_verify($dataToVerify, $signature, $publicKey, OPENSSL_ALGO_SHA256);

    if ($verificationResult === 1) {
        return true;
    } elseif ($verificationResult === 0) {
        Log::error('Apple signature verification failed.');
        return false;
    } else {
        Log::error('Error verifying Apple signature: ' . openssl_error_string());
        return false;
    }
}

private function convertSignatureToDER(string $signature): string
{
    if (strlen($signature) % 2 !== 0) {
        Log::error('Invalid signature length: ' . strlen($signature));
        return false;
    }

    $len = strlen($signature) / 2;
    $r = substr($signature, 0, $len);
    $s = substr($signature, $len);

    if (!$r || !$s) {
        Log::error('Invalid signature components (r or s missing)');
        return false;
    }


    $r = ltrim($r, "\x00");
    $s = ltrim($s, "\x00");

    if (strlen($r) > 0 && ord($r[0]) > 0x7f) {
        $r = "\x00" . $r;
    }
    if (strlen($s) > 0 && ord($s[0]) > 0x7f) {
        $s = "\x00" . $s;
    }

    return "\x30" . chr(strlen($r) + strlen($s) + 4) .
        "\x02" . chr(strlen($r)) . $r .
        "\x02" . chr(strlen($s)) . $s;
}

Now we have everything. Below is the complete workflow of the process:


public function handle(Request $request)
{
    $validator = Validator::make($request->all(), [
        'signedPayload' => 'required|string',
    ]);

    if ($validator->fails()) {
        Log::error('Validation failed', ['errors' => $validator->errors()]);
        return response()->json(['message' => 'Malformed request'], 422);
    }

    $validated = $validator->validated();

    try {
        $decodedNotifyJWS = JWTReader::decodeJWT($validated['signedPayload']);
    } catch (Exception $e) {
        Log::error('Notify JWS Decoding Failed: ' . $e->getMessage());
        return response()->json(['message' => 'Invalid JWT'], 422);
    }

    if (!isset($decodedNotifyJWS['header']) || !isset($decodedNotifyJWS['payload']) || !isset($decodedNotifyJWS['signature'])) {
        Log::error('Notify JWS not invalid');
        return response()->json(['message' => 'Invalid JWT'], 422);
    }

    try {
        $decodedPurchaseJWS = JWTReader::decodeJWT($decodedNotifyJWS['payload']['data']['signedTransactionInfo']);
    } catch (Exception $e) {
        Log::error('Purchase JWS Decoding Failed: ' . $e->getMessage());
        return response()->json(['message' => 'Invalid JWT'], 422);
    }

    if (!isset($decodedPurchaseJWS['header']) || !isset($decodedPurchaseJWS['payload']) || !isset($decodedPurchaseJWS['signature'])) {
        Log::error('Purchase JWS not invalid');
        return response()->json(['message' => 'Invalid JWT'], 422);
    }

    switch ($this->validatedSignedJWS($decodedNotifyJWS)) {
        case 1:
            Log::error('Notify Certificate chain verification failed');
            break;
        case 2:
            Log::error('Notify Signature verification failed');
            break;
        default:
            break;
    }

    switch ($this->validatedSignedJWS($decodedPurchaseJWS)) {
        case 1:
            Log::error('Purchase Certificate chain verification failed');
            break;
        case 2:
            Log::error('Purchase Signature verification failed');
            break;
        default:
            break;
    }

    $notifyData = $decodedNotifyJWS['payload'];

    $purchaseData = $decodedPurchaseJWS['payload'];

    /**
     * YOUR CODE HERE
     * 
     * READ AND HANDLE PURCHASE
     */
}

private function validatedSignedJWS($decodedPayload)
{
    $leafCertPEM = $this->verifyCertificateChain($decodedPayload);

    if ($leafCertPEM == null) return 1;

    if (!$this->verifyAppleSignature($decodedPayload, $leafCertPEM)) return 2;

    return 0;
}

Essentially, make sure that the POST request contains the 'signedPayload' key (the JWS) and pass it directly to the validateSignedPayload function.


Note:
In the validateSignedPayload function, I implemented the validation for both the 'outer' JWS (which contains the notification details) and the internal payload (which includes the purchase information). However, it is not strictly necessary to verify both, as the signature of the outer JWS automatically guarantees the integrity of the inner one as well.