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 response()->json(['message' => 'Malformed request'], 422);
}
// ...
Creiamo le funzioni ausiliarie per decodificare il JWS nelle sue tre parti (header, payload e signature):
class JWTReader
{
public static function base64UrlDecode(string $input): string
{
$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 UnexpectedValueException('Invalid base64URL encoding');
}
return $decoded;
}
public static function base64UrlEncode(string $input): string
{
return rtrim(strtr(base64_encode($input), '+/', '-_'), '=');
}
/**
* Splits a compact JWS and decodes its three parts.
*
* `signingInput` is the text the signature covers: header and payload
* exactly as they arrived, still base64url-encoded. It must not be rebuilt
* from the decoded arrays, because encoding the same JSON again does not
* always give back the same bytes.
*
* @return array{header: array, payload: array, signature: string, signingInput: string}
*/
public static function decodeJWT(string $jwt): array
{
$parts = explode('.', $jwt);
if (count($parts) !== 3) {
throw new UnexpectedValueException('Invalid JWT format');
}
[$header, $payload, $signature] = $parts;
$decodedHeader = json_decode(self::base64UrlDecode($header), true);
$decodedPayload = json_decode(self::base64UrlDecode($payload), true);
if (!is_array($decodedHeader) || !is_array($decodedPayload)) {
throw new UnexpectedValueException('Invalid JSON in JWT');
}
return [
'header' => $decodedHeader,
'payload' => $decodedPayload,
'signature' => self::base64UrlDecode($signature),
'signingInput' => $header . '.' . $payload,
];
}
}
Oltre alle tre parti, decodeJWT restituisce signingInput: header e payload così come sono arrivati, ancora codificati in base64url e uniti da un punto. È su quel testo che Apple calcola la firma, quindi va tenuto da parte. Ricostruirlo dal JSON già decodificato non funziona sempre: codificando di nuovo lo stesso JSON possono cambiare gli spazi, le barre con l'escape o gli oggetti vuoti, e la firma non corrisponderebbe più.
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) e il secondo dal terzo (Root Certificate);
- il Root Certificate sia davvero quello di Apple;
- nessuno dei tre sia scaduto;
- Leaf e Intermediate siano quelli che Apple usa per l'App Store.
L'ultimo punto è facile da dimenticare. Sotto lo stesso Root Certificate Apple emette certificati anche per altri usi, compresi quelli che rilascia agli sviluppatori: senza questo controllo, una catena costruita con uno di quelli risulterebbe valida. I certificati dell'App Store si riconoscono da due estensioni, identificate dal loro OID: 1.2.840.113635.100.6.11.1 sul Leaf e 1.2.840.113635.100.6.2.1 sull'Intermediate.
Per riconoscere il Root Certificate di Apple non serve scaricarlo: basta la sua impronta SHA-256, che è pubblica e resta la stessa finché Apple non cambia certificato (quello attuale scade nel 2039). La teniamo in una costante e la confrontiamo con l'impronta del terzo certificato della catena:
/**
* SHA-256 fingerprint of "Apple Root CA - G3", the certificate every
* App Store chain must end with. To check it yourself:
*
* curl -s https://www.apple.com/certificateauthority/AppleRootCA-G3.cer \
* | openssl x509 -inform DER -noout -fingerprint -sha256
*/
public const APPLE_ROOT_CA_G3_SHA256 = '63343abfb89a6a03ebb57e9b3f5fa7be7c4f5c756f3017b3a8c488c3653e9179';
È l'unico valore fisso di tutto il codice. Leaf e Intermediate, che Apple rinnova di tanto in tanto, viaggiano dentro ogni notifica e non vanno salvati da nessuna parte: si riconoscono da chi li ha firmati e dalle due estensioni, che restano le stesse a ogni rinnovo. Il Root invece è il punto da cui parte la fiducia e deve essere noto in anticipo: se potesse cambiare da solo non garantirebbe nulla. Se un giorno Apple ne adotterà uno nuovo, basterà aggiungere la sua impronta alla configurazione, senza toccare il codice.
Ora possiamo passare alla verifica vera e propria della Certificate Chain:
private function verifyCertificateChain(mixed $x5c, int $at): OpenSSLCertificate
{
if (!is_array($x5c) || count($x5c) !== 3) {
throw new UnexpectedValueException('x5c must contain three certificates');
}
[$leafCert, $intermediateCert, $rootCert] = array_map(function ($certificate) {
// @: on text that is not a certificate PHP also raises a warning,
// which Laravel would turn into a server error.
$cert = is_string($certificate) ? @openssl_x509_read($this->getPEMFromX5C($certificate)) : false;
if (!$cert) {
throw new UnexpectedValueException('Failed to load a certificate of the chain');
}
return $cert;
}, array_values($x5c));
if (!in_array(openssl_x509_fingerprint($rootCert, 'sha256'), $this->trustedRootFingerprints, true)) {
throw new UnexpectedValueException('Root certificate is not the Apple Root CA');
}
// openssl_x509_verify returns 1, 0 or -1 (on error): only 1 is a pass.
if (openssl_x509_verify($leafCert, $intermediateCert) !== 1) {
throw new UnexpectedValueException('Leaf certificate is not signed by the intermediate certificate');
}
if (openssl_x509_verify($intermediateCert, $rootCert) !== 1) {
throw new UnexpectedValueException('Intermediate certificate is not signed by the root certificate');
}
$leaf = openssl_x509_parse($leafCert);
$intermediate = openssl_x509_parse($intermediateCert);
$root = openssl_x509_parse($rootCert);
foreach ([$leaf, $intermediate, $root] as $certificate) {
if (!$certificate || $at < $certificate['validFrom_time_t'] || $at > $certificate['validTo_time_t']) {
throw new UnexpectedValueException('A certificate of the chain is expired or not yet valid');
}
}
if (!isset($leaf['extensions'][self::LEAF_OID])) {
throw new UnexpectedValueException('Leaf certificate was not issued for the App Store');
}
if (
!isset($intermediate['extensions'][self::INTERMEDIATE_OID])
|| !str_contains($intermediate['extensions']['basicConstraints'] ?? '', 'CA:TRUE')
) {
throw new UnexpectedValueException('Intermediate certificate is not an Apple certificate authority');
}
return $leafCert;
}
private function getPEMFromX5C(string $x5c): string
{
return "-----BEGIN CERTIFICATE-----\n" . chunk_split($x5c, 64, "\n") . "-----END CERTIFICATE-----\n";
}
Attenzione a openssl_x509_verify: restituisce 1 se la firma è valida, 0 se non lo è e -1 in caso di errore. Va confrontato con 1, perché !-1 è falso e un errore passerebbe per una firma valida.
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 va verificata su signingInput, con la chiave pubblica del Leaf Certificate. In un JWS la firma ES256 è fatta di due interi da 32 byte, r e s, scritti uno dopo l'altro, mentre OpenSSL li vuole in una sequenza ASN.1 DER: per questo prima la convertiamo.
private function verifySignature(string $signingInput, string $signature, OpenSSLCertificate $leafCert): void
{
$publicKey = openssl_pkey_get_public($leafCert);
$keyDetails = $publicKey ? openssl_pkey_get_details($publicKey) : false;
if (
!$keyDetails
|| $keyDetails['type'] !== OPENSSL_KEYTYPE_EC
|| ($keyDetails['ec']['curve_name'] ?? null) !== 'prime256v1'
) {
throw new UnexpectedValueException('Leaf certificate does not hold a P-256 public key');
}
$verificationResult = openssl_verify(
$signingInput,
$this->convertSignatureToDER($signature),
$publicKey,
OPENSSL_ALGO_SHA256
);
if ($verificationResult !== 1) {
throw new UnexpectedValueException('Signature verification failed');
}
}
private function convertSignatureToDER(string $signature): string
{
if (strlen($signature) !== 64) {
throw new UnexpectedValueException('Invalid signature length: ' . strlen($signature));
}
$integers = '';
foreach (str_split($signature, 32) as $integer) {
$integer = ltrim($integer, "\x00");
// A DER integer is signed: a leading zero keeps it positive.
if ($integer === '' || ord($integer[0]) > 0x7f) {
$integer = "\x00" . $integer;
}
$integers .= "\x02" . chr(strlen($integer)) . $integer;
}
return "\x30" . chr(strlen($integers)) . $integers;
}
Mettiamo insieme i pezzi in un solo metodo, che restituisce il payload solo se tutte le verifiche passano e altrimenti lancia un'eccezione:
public function verify(string $jws, ?int $at = null): array
{
$jwt = JWTReader::decodeJWT($jws);
if (($jwt['header']['alg'] ?? null) !== 'ES256') {
throw new UnexpectedValueException('Algorithm is not ES256');
}
$leafCert = $this->verifyCertificateChain($jwt['header']['x5c'] ?? null, $at ?? time());
$this->verifySignature($jwt['signingInput'], $jwt['signature'], $leafCert);
return $jwt['payload'];
}
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);
}
$signedPayload = $validator->validated()['signedPayload'];
// Trusts Apple Root CA - G3. Should Apple ever move the App Store to
// another root, set its fingerprint in services.apple.root_ca_sha256
// instead of changing the code.
$verifier = new AppleJWSVerifier(
(array) (config('services.apple.root_ca_sha256') ?: AppleJWSVerifier::APPLE_ROOT_CA_G3_SHA256)
);
try {
$notifyData = $verifier->verify($signedPayload);
// What the notification is about. Which of these is present
// depends on the notification type.
$details = $notifyData['data']
?? $notifyData['summary']
?? $notifyData['externalPurchaseToken']
?? $notifyData['appData']
?? [];
// Not every notification carries a transaction: a TEST one has none.
$purchaseData = isset($details['signedTransactionInfo'])
? $verifier->verify($details['signedTransactionInfo'])
: null;
// Only for auto-renewable subscriptions.
$renewalData = isset($details['signedRenewalInfo'])
? $verifier->verify($details['signedRenewalInfo'])
: null;
} catch (UnexpectedValueException $e) {
Log::error('Apple notification rejected: ' . $e->getMessage());
return response()->json(['message' => 'Invalid signed payload'], 422);
}
// The signature only proves that Apple wrote the notification, not
// that it is about this app: Apple signs the notifications of every
// app with the same certificates.
if (($details['bundleId'] ?? null) !== config('services.apple.bundle_id')) {
Log::error('Apple notification rejected: it is for another app', ['bundleId' => $details['bundleId'] ?? null]);
return response()->json(['message' => 'Invalid signed payload'], 422);
}
if ($this->isSandbox($details) && app()->environment('production')) {
return $this->forwardToDevServer($signedPayload);
}
/**
* YOUR CODE HERE
*
* READ AND HANDLE PURCHASE
*
* Apple sends the same notification again when it gets no answer:
* $notifyData['notificationUUID'] tells you if you already handled it.
*/
return response()->json(['message' => 'Notification processed successfully'], 200);
}
Se una verifica fallisce la richiesta si ferma lì, con un errore: registrare il problema nei log e poi proseguire renderebbe inutile tutto il resto.
C'è poi un controllo che la firma non può fare al posto nostro. Apple firma con gli stessi certificati le notifiche di tutte le app: una notifica autentica, ma destinata a un'altra app, supererebbe ogni verifica. Per questo handle confronta il bundleId con quello della nostra app, che sta in config/services.php:
'apple' => [
'bundle_id' => env('APPLE_BUNDLE_ID'),
'sandbox_forward_url' => env('APPLE_SANDBOX_FORWARD_URL'),
'root_ca_sha256' => array_filter(explode(',', (string) env('APPLE_ROOT_CA_SHA256'))),
],
In sostanza: assicurati che la POST contenga la chiave signedPayload (il JWS) e lascia che handle la passi a verify.