94 lines
4.3 KiB
PHP
94 lines
4.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
/** Verifies StoreKit 2 transaction JWS against a locally pinned Apple root. */
|
|
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;
|
|
}
|
|
}
|