Files

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;
}
}