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