diff --git a/composer.json b/composer.json index fb7c591..aeed845 100755 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ "require" : { "php" : ">=5.3", "guzzle/guzzle" : "~3.8", - "google/apiclient": "~1.1" + "google/apiclient": "~1.1", + "robrichards/xmlseclibs": "^2.0" }, "require-dev" : { "phpunit/phpunit" : "3.7.*@stable", diff --git a/src/ReceiptValidator/WindowsStore/CacheInterface.php b/src/ReceiptValidator/WindowsStore/CacheInterface.php new file mode 100644 index 0000000..38b80bc --- /dev/null +++ b/src/ReceiptValidator/WindowsStore/CacheInterface.php @@ -0,0 +1,25 @@ +cache = $cache; + } + + /** + * Validate the given receipt. + * + * @param string $receipt + * @return bool + * @throws RunTimeException + */ + public function validate($receipt) + { + // Load the receipt that needs to verified as an XML document. + $dom = new \DOMDocument; + if (@$dom->loadXML($receipt) === false) { + throw new RunTimeException('Invalid XML'); + } + + // The certificateId attribute is present in the document root, retrieve it. + $certificateId = $dom->documentElement->getAttribute('CertificateId'); + if (empty($certificateId)) { + throw new RunTimeException('Missing CertificateId in receipt'); + } + + // Retrieve the certificate from the official site. + $certificate = $this->retrieveCertificate($certificateId); + + return $this->validateXml($dom, $certificate); + } + + /** + * Load the certificate with the given ID. + * + * @param string $certificateId + * @return resource + */ + protected function retrieveCertificate($certificateId) + { + // Retrieve from cache if a cache handler has been set. + $cacheKey = 'store-receipt-validate.windowsstore.'.$certificateId; + $certificate = $this->cache !== null ? $this->cache->get($cacheKey) : null; + + if ($certificate === null) { + $maxCertificateSize = 10000; + + // We are attempting to retrieve the following url. The getAppReceiptAsync website at + // http://msdn.microsoft.com/en-us/library/windows/apps/windows.applicationmodel.store.currentapp.getappreceiptasync.aspx + // lists the following format for the certificate url. + $certificateUrl = 'https://go.microsoft.com/fwlink/?LinkId=246509&cid=' . $certificateId; + + // Make an HTTP GET request for the certificate. + $client = new GuzzleClient($certificateUrl); + $response = $client->get()->send(); + + // Retrieve the certificate out of the response. + $certificate = $response->getBody(true); + + // Write back to cache. + if ($this->cache !== null) { + $this->cache->put($cacheKey, $certificate, 3600); + } + } + + return openssl_x509_read($certificate); + } + + /** + * Validate the receipt contained in the given XML element using the + * certificate provided. + * + * @param DOMDocument $dom + * @param resource $certificate + * @return bool + */ + protected function validateXml(DOMDocument $dom, $certificate) + { + $secDsig = new XMLSecurityDSig; + + // Locate the signature in the receipt XML. + $dsig = $secDsig->locateSignature($dom); + if ($dsig === null) { + throw new RunTimeException('Cannot locate receipt signature'); + } + + $secDsig->canonicalizeSignedInfo(); + $secDsig->idKeys = array('wsu:Id'); + $secDsig->idNS = array( + 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd', + ); + + if (!$secDsig->validateReference()) { + throw new RunTimeException('Reference validation failed'); + } + + $key = $secDsig->locateKey(); + if ($key === null) { + throw new RunTimeException('Could not locate key in receipt'); + } + + $keyInfo = XMLSecEnc::staticLocateKeyInfo($key, $dsig); + if (!$keyInfo->key) { + $key->loadKey($certificate); + } + + return $secDsig->verify($key) == 1; + } +} diff --git a/tests/WindowsStore/ValidatorTest.php b/tests/WindowsStore/ValidatorTest.php new file mode 100644 index 0000000..d6f4d8b --- /dev/null +++ b/tests/WindowsStore/ValidatorTest.php @@ -0,0 +1,118 @@ +assertTrue($validator->validate($receipt), 'Receipt should validate successfully'); + } + + /** + * @dataProvider receiptProvider + */ + public function testValidateWithCache($receipt) + { + $validator = new Validator(new DummyCache); + $this->assertTrue($validator->validate($receipt), 'Receipt should validate successfully'); + } + + public function testValidateFails() + { + $this->setExpectedException('ReceiptValidator\RunTimeException', 'Invalid XML'); + + $validator = new Validator; + $validator->validate('foo bar'); + } + + public function receiptProvider() + { + return array( + // App receipt + array( + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + 'cdiU06eD8X/w1aGCHeaGCG9w/kWZ8I099rw4mmPpvdU='. + ''. + ''. + 'SjRIxS/2r2P6ZdgaR9bwUSa6ZItYYFpKLJZrnAa3zkMylbiWjh9oZGGng2p6/gtBHC2dSTZlLbqny'. + 'sJjl7mQp/A3wKaIkzjyRXv3kxoVaSV0pkqiPt04cIfFTP0JZkE5QD/vYxiWjeyGp1dThEM2RV811sRWvmEs/hHhVxb32e'. + '8xCLtpALYx3a9lW51zRJJN0eNdPAvNoiCJlnogAoTToUQLHs72I1dECnSbeNPXiG7klpy5boKKMCZfnVXXkneWvVFtAA1'. + 'h2sB7ll40LEHO4oYN6VzD+uKd76QOgGmsu9iGVyRvvmMtahvtL1/pxoxsTRedhKq6zrzCfT8qfh3C1w=='. + ''. + ''. + '', + ), + // Product receipt + array( + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + ''. + 'Uvi8jkTYd3HtpMmAMpOm94fLeqmcQ2KCrV1XmSuY1xI='. + ''. + ''. + 'TT5fDET1X9nBk9/yKEJAjVASKjall3gw8u9N5Uizx4/Le9RtJtv+E9XSMjrOXK/TDicidIPLBjTbc'. + 'ZylYZdGPkMvAIc3/1mdLMZYJc+EXG9IsE9L74LmJ0OqGH5WjGK/UexAXxVBWDtBbDI2JLOaBevYsyy+4hLOcTXDSUA4tX'. + 'wPa2Bi+BRoUTdYE2mFW7ytOJNEs3jTiHrCK6JRvTyU9lGkNDMNx9loIr+mRks+BSf70KxPtE9XCpCvXyWa/Q1JaIyZI7l'. + 'lCH45Dn4SKFn6L/JBw8G8xSTrZ3sBYBKOnUDbSCfc8ucQX97EyivSPURvTyImmjpsXDm2LBaEgAMADg=='. + ''. + ''. + '' + ), + ); + } +} + +class DummyCache implements CacheInterface +{ + protected $cache = array(); + + public function get($key) + { + return isset($this->cache[$key]) ? $this->cache[$key] : null; + } + + public function put($key, $value, $minutes) + { + $this->cache[$key] = $value; + } +}