Skip to content

Commit

Permalink
Merge pull request #20 from MaartenStaa/windows-store-support
Browse files Browse the repository at this point in the history
Windows store support
  • Loading branch information
aporat committed Oct 15, 2015
2 parents d8cc68b + 8ba4806 commit 3a68caa
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 1 deletion.
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions src/ReceiptValidator/WindowsStore/CacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
namespace ReceiptValidator\WindowsStore;

interface CacheInterface
{
/**
* Retrieve an item from the cache by key. If the key is not found, null
* should be returned.
*
* @param string $key
* @return mixed
*/
public function get($key);

/**
* Store an item in the cache for a given number of minutes, where 0 minutes
* means forever.
*
* @param string $key
* @param mixed $value
* @param int $minutes
* @return void
*/
public function put($key, $value, $minutes);
}
122 changes: 122 additions & 0 deletions src/ReceiptValidator/WindowsStore/Validator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
namespace ReceiptValidator\WindowsStore;

use DOMDocument;
use Guzzle\Http\Client as GuzzleClient;
use ReceiptValidator\RunTimeException;
use RobRichards\XMLSecLibs\XMLSecEnc;
use RobRichards\XMLSecLibs\XMLSecurityDSig;

class Validator
{
protected $cache;

public function __construct(CacheInterface $cache = null)
{
$this->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;
}
}
118 changes: 118 additions & 0 deletions tests/WindowsStore/ValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

use ReceiptValidator\WindowsStore\CacheInterface;
use ReceiptValidator\WindowsStore\Validator;

/**
* @group library
*/
class WindowsValidatorTest extends PHPUnit_Framework_TestCase
{
/**
* @dataProvider receiptProvider
*/
public function testValidate($receipt)
{
$validator = new Validator;
$this->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(
'<Receipt Version="1.0" ReceiptDate="2012-08-30T23:10:05Z" '.
'CertificateId="b809e47cd0110a4db043b3f73e83acd917fe1336" '.
'ReceiptDeviceId="4e362949-acc3-fe3a-e71b-89893eb4f528">'.
'<AppReceipt Id="8ffa256d-eca8-712a-7cf8-cbf5522df24b" '.
'AppId="55428GreenlakeApps.CurrentAppSimulatorEventTest_z7q3q7z11crfr" '.
'PurchaseDate="2012-06-04T23:07:24Z" LicenseType="Full" />'.
'<ProductReceipt Id="6bbf4366-6fb2-8be8-7947-92fd5f683530" '.
'ProductId="Product1" PurchaseDate="2012-08-30T23:08:52Z" '.
'ExpirationDate="2012-09-02T23:08:49Z" ProductType="Durable" '.
'AppId="55428GreenlakeApps.CurrentAppSimulatorEventTest_z7q3q7z11crfr" />'.
'<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">'.
'<SignedInfo>'.
'<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />'.
'<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />'.
'<Reference URI="">'.
'<Transforms>'.
'<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />'.
'</Transforms>'.
'<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />'.
'<DigestValue>cdiU06eD8X/w1aGCHeaGCG9w/kWZ8I099rw4mmPpvdU=</DigestValue>'.
'</Reference>'.
'</SignedInfo>'.
'<SignatureValue>SjRIxS/2r2P6ZdgaR9bwUSa6ZItYYFpKLJZrnAa3zkMylbiWjh9oZGGng2p6/gtBHC2dSTZlLbqny'.
'sJjl7mQp/A3wKaIkzjyRXv3kxoVaSV0pkqiPt04cIfFTP0JZkE5QD/vYxiWjeyGp1dThEM2RV811sRWvmEs/hHhVxb32e'.
'8xCLtpALYx3a9lW51zRJJN0eNdPAvNoiCJlnogAoTToUQLHs72I1dECnSbeNPXiG7klpy5boKKMCZfnVXXkneWvVFtAA1'.
'h2sB7ll40LEHO4oYN6VzD+uKd76QOgGmsu9iGVyRvvmMtahvtL1/pxoxsTRedhKq6zrzCfT8qfh3C1w=='.
'</SignatureValue>'.
'</Signature>'.
'</Receipt>',
),
// Product receipt
array(
'<Receipt Version="1.0" ReceiptDate="2012-08-30T23:08:52Z" '.
'CertificateId="b809e47cd0110a4db043b3f73e83acd917fe1336" '.
'ReceiptDeviceId="4e362949-acc3-fe3a-e71b-89893eb4f528">'.
'<ProductReceipt Id="6bbf4366-6fb2-8be8-7947-92fd5f683530" '.
'ProductId="Product1" PurchaseDate="2012-08-30T23:08:52Z" '.
'ExpirationDate="2012-09-02T23:08:49Z" ProductType="Durable" '.
'AppId="55428GreenlakeApps.CurrentAppSimulatorEventTest_z7q3q7z11crfr" />'.
'<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">'.
'<SignedInfo>'.
'<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />'.
'<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />'.
'<Reference URI="">'.
'<Transforms>'.
'<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />'.
'</Transforms>'.
'<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />'.
'<DigestValue>Uvi8jkTYd3HtpMmAMpOm94fLeqmcQ2KCrV1XmSuY1xI=</DigestValue>'.
'</Reference>'.
'</SignedInfo>'.
'<SignatureValue>TT5fDET1X9nBk9/yKEJAjVASKjall3gw8u9N5Uizx4/Le9RtJtv+E9XSMjrOXK/TDicidIPLBjTbc'.
'ZylYZdGPkMvAIc3/1mdLMZYJc+EXG9IsE9L74LmJ0OqGH5WjGK/UexAXxVBWDtBbDI2JLOaBevYsyy+4hLOcTXDSUA4tX'.
'wPa2Bi+BRoUTdYE2mFW7ytOJNEs3jTiHrCK6JRvTyU9lGkNDMNx9loIr+mRks+BSf70KxPtE9XCpCvXyWa/Q1JaIyZI7l'.
'lCH45Dn4SKFn6L/JBw8G8xSTrZ3sBYBKOnUDbSCfc8ucQX97EyivSPURvTyImmjpsXDm2LBaEgAMADg=='.
'</SignatureValue>'.
'</Signature>'.
'</Receipt>'
),
);
}
}

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

0 comments on commit 3a68caa

Please sign in to comment.