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