Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/HttpClient/Plugin/RequestSignature.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class RequestSignature implements Plugin
{
use Plugin\VersionBridgePlugin;

const SIGNATURE_VERSION = '2';

/** @var string */
private $key;
/** @var string */
Expand Down Expand Up @@ -48,6 +50,8 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca
'key' => $this->key,
'timestamp' => $this->getTimestamp(),
'cnonce' => $this->getNonce(),
'version' => self::SIGNATURE_VERSION,
'query' => $this->normalizeQueryString($request->getUri()->getQuery()),
];

$content = (string) $request->getBody();
Expand All @@ -56,10 +60,11 @@ protected function doHandleRequest(RequestInterface $request, callable $next, ca
}

$request = $request->withHeader('Authorization', sprintf(
'PACKAGIST-HMAC-SHA256 Key=%s, Timestamp=%s, Cnonce=%s, Signature=%s',
'PACKAGIST-HMAC-SHA256 Key=%s, Timestamp=%s, Cnonce=%s, Version=%s, Signature=%s',
$params['key'],
$params['timestamp'],
$params['cnonce'],
$params['version'],
$this->generateSignature($request, $params)
));

Expand Down Expand Up @@ -94,4 +99,21 @@ private function normalizeParameters(array $params)

return http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}

/**
* @param string $queryString
* @return string
*/
private function normalizeQueryString($queryString)
{
if ($queryString === '') {
return '';
}

$queryParams = [];
parse_str($queryString, $queryParams);
uksort($queryParams, 'strcmp');

return http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
}
}
90 changes: 89 additions & 1 deletion tests/HttpClient/Plugin/RequestSignatureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace PrivatePackagist\ApiClient\HttpClient\Plugin;

use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\RequestInterface;

class RequestSignatureTest extends PluginTestCase
{
Expand Down Expand Up @@ -39,7 +40,7 @@ public function testPrefixRequestPath()
'POST',
'/packages/?foo=bar',
[
'Authorization' => ["PACKAGIST-HMAC-SHA256 Key={$this->key}, Timestamp={$this->timestamp}, Cnonce={$this->nonce}, Signature=a6wxBLYrmz4Mwmv/TKBZR5WHFcSCRbsny2frobJMt24="],
'Authorization' => ["PACKAGIST-HMAC-SHA256 Key={$this->key}, Timestamp={$this->timestamp}, Cnonce={$this->nonce}, Version=2, Signature=rzwvwGS17Qcmk8UqTefJCHCV188x1/e1iBWG2pB4z1M="],
],
json_encode(['foo' => 'bar'])
);
Expand All @@ -49,6 +50,93 @@ public function testPrefixRequestPath()
$this->assertEquals($expected->getHeaders(), $promise->wait(true)->getHeaders());
}

public function testSignatureCoversQueryString()
{
$requestA = new Request('GET', '/packages/?page=1&limit=10');
$requestB = new Request('GET', '/packages/?page=2&limit=10');

$signatureA = $this->extractSignature($this->plugin->handleRequest($requestA, $this->next, $this->first)->wait(true));
$signatureB = $this->extractSignature($this->plugin->handleRequest($requestB, $this->next, $this->first)->wait(true));

$this->assertNotSame($signatureA, $signatureB);
}

public function testSignatureIgnoresQueryParamOrder()
{
$requestA = new Request('GET', '/packages/?page=1&limit=10');
$requestB = new Request('GET', '/packages/?limit=10&page=1');

$signatureA = $this->extractSignature($this->plugin->handleRequest($requestA, $this->next, $this->first)->wait(true));
$signatureB = $this->extractSignature($this->plugin->handleRequest($requestB, $this->next, $this->first)->wait(true));

$this->assertSame($signatureA, $signatureB);
}

public function testQueryParamWithAuthFieldNameDoesNotShadowAuthIdentity()
{
$request = new Request('GET', '/packages/?key=evil&version=1');

$header = $this->plugin->handleRequest($request, $this->next, $this->first)->wait(true)->getHeader('Authorization')[0];

$this->assertStringContainsString("Key={$this->key}", $header);
$this->assertStringContainsString('Version=2', $header);
}

public function testEmptyQueryStringMatchesNoQueryString()
{
$withoutQuery = new Request('GET', '/packages/');
$withEmptyQuery = new Request('GET', '/packages/?');

$signatureA = $this->extractSignature($this->plugin->handleRequest($withoutQuery, $this->next, $this->first)->wait(true));
$signatureB = $this->extractSignature($this->plugin->handleRequest($withEmptyQuery, $this->next, $this->first)->wait(true));

$this->assertSame($signatureA, $signatureB);
}

public function testEmptyValueQueryParamIsSigned()
{
$withoutParam = new Request('GET', '/packages/');
$withEmptyValue = new Request('GET', '/packages/?foo=');
$withValuelessParam = new Request('GET', '/packages/?foo');

$baseline = $this->extractSignature($this->plugin->handleRequest($withoutParam, $this->next, $this->first)->wait(true));
$emptyValue = $this->extractSignature($this->plugin->handleRequest($withEmptyValue, $this->next, $this->first)->wait(true));
$valueless = $this->extractSignature($this->plugin->handleRequest($withValuelessParam, $this->next, $this->first)->wait(true));

$this->assertNotSame($baseline, $emptyValue);
$this->assertSame($emptyValue, $valueless);
}

public function testUrlEncodingIsCanonical()
{
$percent = new Request('GET', '/packages/?foo=hello%20world');
$plus = new Request('GET', '/packages/?foo=hello+world');

$signaturePercent = $this->extractSignature($this->plugin->handleRequest($percent, $this->next, $this->first)->wait(true));
$signaturePlus = $this->extractSignature($this->plugin->handleRequest($plus, $this->next, $this->first)->wait(true));

$this->assertSame($signaturePercent, $signaturePlus);
}

public function testV2GetDeterministicSignature()
{
$request = new Request('GET', 'https://localhost/api/packages/?limit=1&page=2');
$expected = "PACKAGIST-HMAC-SHA256 Key={$this->key}, Timestamp={$this->timestamp}, Cnonce={$this->nonce}, Version=2, Signature=";

$actual = $this->plugin->handleRequest($request, $this->next, $this->first)->wait(true)->getHeader('Authorization')[0];

$this->assertStringStartsWith($expected, $actual);
$this->assertStringEndsWith('Signature=RLb/mPCONIcfPp3+Ink+jtxNU6VKyeasf7Zdd7kNO+A=', $actual);
}

private function extractSignature(RequestInterface $request)
{
$header = $request->getHeader('Authorization')[0];
preg_match('/Signature=([^,]+)/', $header, $matches);

return $matches[1];
}

public function testPrefixRequestPathSmoke()
{
$request = new Request('POST', '/packages/?foo=bar', [], json_encode(['foo' => 'bar']));
Expand Down