new app store assets ready for relese
This commit is contained in:
@@ -1,30 +1,93 @@
|
||||
<?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;
|
||||
}
|
||||
class AppleVerifier
|
||||
{
|
||||
private function json(string $data): array
|
||||
{
|
||||
try {
|
||||
$result = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
abort(422, 'Invalid signed JSON.');
|
||||
}abort_unless(is_array($result), 422);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
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 envelope(string $jws): 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 = $this->json($this->decode($parts[0]));
|
||||
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.');
|
||||
}
|
||||
$untrusted = tempnam(sys_get_temp_dir(), 'tarnax-apple-');
|
||||
try {
|
||||
file_put_contents($untrusted, implode("\n", array_slice($certs, 1)));
|
||||
$trusted = openssl_x509_checkpurpose($certs[0], X509_PURPOSE_ANY, [$rootPath], $untrusted);
|
||||
abort_unless($trusted === true || $trusted === 1, 422, 'Invalid certificate constraints.');
|
||||
} finally {
|
||||
unlink($untrusted);
|
||||
}
|
||||
$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 = $this->json($this->decode($parts[1]));
|
||||
abort_unless(isset($claims['signedDate']) && $claims['signedDate'] <= time() * 1000 + 300000, 422);
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
public function transaction(string $jws): array
|
||||
{
|
||||
$claims = $this->envelope($jws);
|
||||
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') && isset($claims['transactionId']), 422, 'Transaction does not match this app.');
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
public function verify(string $jws, string $accountToken): array
|
||||
{
|
||||
$claims = $this->transaction($jws);
|
||||
abort_unless($accountToken !== '' && strtolower($claims['appAccountToken'] ?? '') === strtolower($accountToken), 422, 'Transaction does not match this account.');
|
||||
abort_if(isset($claims['revocationDate']) || (($claims['expiresDate'] ?? PHP_INT_MAX) < time() * 1000), 403, 'Purchase is no longer valid.');
|
||||
abort_unless($claims['signedDate'] >= time() * 1000 - 86400000,422,'A recent signed transaction is required.');
|
||||
|
||||
return $claims;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user