Verifica della certificate chain Apple e della firma JWS
Come verificare la catena di certificati e la firma delle App Store Server Notifications, per garantire autenticità e integrità degli acquisti in-app.
Panoramica
In questo articolo vediamo passo dopo passo come verificare la catena di certificati di Apple e convalidare la firma del payload. Il codice completo è disponibile su GitHub.
I server di Apple inviano una richiesta POST con un JWS da verificare prima di fidarsi del suo contenuto. Non è obbligatorio farlo, ma per essere certi che la richiesta arrivi davvero da Apple e non da un malintenzionato è meglio non lasciare nulla al caso.
Che cos'è un JWS e come funziona?
Il JWS (JSON Web Signature) è uno standard aperto per firmare in modo sicuro dati JSON. Garantisce integrità e autenticità: chi riceve la richiesta può verificare che i dati non siano stati alterati durante il trasferimento e che provengano da una fonte attendibile, nel nostro caso Apple.
Un JWS è composto da tre parti separate da punti:
- Header: informazioni sul tipo di firma e sull'algoritmo utilizzato.
- Payload: i dati veri e propri, di qualsiasi tipo (per esempio un JSON con i dettagli della richiesta).
- Signature: la firma digitale che garantisce l'integrità del payload e che provenga davvero da chi dichiara di averlo inviato (in questo caso Apple).
Apple usa l'algoritmo ES256, cioè ECDSA (Elliptic Curve Digital Signature Algorithm) con la curva P-256 e la funzione di hash SHA-256: calcola un hash di header e payload con SHA-256 e lo firma con una chiave privata basata sulla curva P-256. Chiunque possieda la chiave pubblica corretta può verificare che il JWS non sia stato manomesso e che provenga da una fonte attendibile.
Mani sul codice
Quando Apple invia la richiesta POST, il body contiene la chiave signedPayload, il cui valore è il nostro JWS.
Prepariamo quindi un endpoint POST nel file delle rotte, che si aspetti un body con la chiave signedPayload:
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');
}
// ...
Creiamo le funzioni ausiliarie per decodificare il JWS in tutte e tre le sue parti (header, payload e signature) e per ricodificarle quando servirà verificare la firma:
$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 base64UrlEncode($input)
{
return rtrim(strtr(base64_encode(json_encode($input, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)), '+/', '-_'), '=');
}
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,
];
}
}
Una volta decodificato il JWS, verifichiamo che l'header contenga la chiave x5c: un array di tre stringhe, i certificati coinvolti nel processo (Certificate Chain).
Perché la Certificate Chain sia valida dobbiamo accertarci che il primo certificato (Leaf Certificate) sia firmato dal secondo (Intermediate Certificate), che il secondo sia firmato dal terzo (Root Certificate) e che quest'ultimo sia effettivamente emesso dalla CA di Apple.
Prepariamo quindi una funzione che scarichi il Root Certificate da Apple, oppure scarichiamolo a mano da https://www.apple.com/certificateauthority/AppleRootCA-G3.cer e salviamolo nel progetto.
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;
}
Come conservarlo è a scelta: su disco, oppure in cache con una scadenza, così da riscaricarlo automaticamente di tanto in tanto.
Ora che abbiamo il Root Certificate di Apple possiamo passare alla verifica vera e propria della 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;
}
A questo punto abbiamo il codice per verificare la Certificate Chain. Aggiungiamo quello che controlla che la signature del JWS sia corretta, così da poterci fidare delle informazioni inviate da Apple.
La firma è calcolata sulle parti di header e payload così come sono arrivate, codificate in base64url: per questo le ricodifichiamo con base64UrlEncode prima di verificarla.
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 = JWTReader::base64UrlEncode($decodedPayload['header']) . '.' . JWTReader::base64UrlEncode($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;
}
Ora abbiamo tutto. Ecco il flusso completo:
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;
}
In sostanza: assicurati che la POST contenga la chiave signedPayload (il JWS) e lascia che handle la decodifichi e la passi a validatedSignedJWS.