Files
Tarnax/appstore/website/app/Services/AppleVerifier.php
T
2026-11-04 16:47:55 +00:00

31 lines
2.8 KiB
PHP

<?php
namespace App\Services;
/** Verifies StoreKit 2 transaction JWS against a locally pinned Apple root. */
class AppleVerifier {
private function decode(string $data): string { $result=base64_decode(strtr($data,'-_','+/'),true);abort_unless($result!==false,422);return $result; }
private function pem(string $cert): string {return "-----BEGIN CERTIFICATE-----\n".chunk_split($cert,64,"\n")."-----END CERTIFICATE-----\n";}
public function verify(string $jws,string $accountToken): array {
$rootPath=config('tarnax.apple.root_certificate');abort_unless($rootPath && is_file($rootPath),503,'Apple verification is not configured.');
$parts=explode('.',$jws);abort_unless(count($parts)===3,422);
$header=json_decode($this->decode($parts[0]),true,512,JSON_THROW_ON_ERROR);
abort_unless(($header['alg']??'')==='ES256' && count($header['x5c']??[])>=2 && count($header['x5c'])<=3,422);
$certs=array_map(fn($c)=>$this->pem($c),$header['x5c']);$root=file_get_contents($rootPath);
foreach($certs as $index=>$cert){
$parsed=openssl_x509_parse($cert);abort_unless($parsed && $parsed['validFrom_time_t']<=time() && $parsed['validTo_time_t']>=time(),422);
$issuer=$certs[$index+1]??$root;
abort_unless(openssl_x509_verify($cert,openssl_pkey_get_public($issuer))===1,422,'Invalid Apple certificate chain.');
}
$leaf=openssl_x509_parse($certs[0]);abort_unless(isset($leaf['extensions']['1.2.840.113635.100.6.11.1']),422,'Not an Apple transaction signing certificate.');
$sig=$this->decode($parts[2]);abort_unless(strlen($sig)===64,422);
$der='';foreach([substr($sig,0,32),substr($sig,32)] as $integer){$integer=ltrim($integer,"\0");if($integer==='' || (ord($integer[0])&128)){$integer="\0".$integer;}$der.="\x02".chr(strlen($integer)).$integer;}
$der="\x30".chr(strlen($der)).$der;
abort_unless(openssl_verify($parts[0].'.'.$parts[1],$der,$certs[0],OPENSSL_ALGO_SHA256)===1,422,'Invalid transaction signature.');
$claims=json_decode($this->decode($parts[1]),true,512,JSON_THROW_ON_ERROR);
abort_unless(config('tarnax.apple.bundle_id') && config('tarnax.apple.product_id'),503);
abort_unless(($claims['bundleId']??'')===config('tarnax.apple.bundle_id') && ($claims['productId']??'')===config('tarnax.apple.product_id') && ($claims['environment']??'')===config('tarnax.apple.environment') && strtolower($claims['appAccountToken']??'')===strtolower($accountToken),422,'Transaction does not match this account or product.');
abort_if(isset($claims['revocationDate']) || (($claims['expiresDate']??PHP_INT_MAX)<time()*1000),403,'Purchase is no longer valid.');
abort_unless(isset($claims['transactionId'],$claims['signedDate']) && $claims['signedDate']<=time()*1000+300000 && $claims['signedDate']>=time()*1000-86400000,422,'A recent signed transaction is required.');
return $claims;
}
}