| 1 |
<?php |
| 2 |
|
| 3 |
namespace Dudlewebs\WPMCS\s3\Aws\Signature; |
| 4 |
|
| 5 |
use Dudlewebs\WPMCS\s3\Aws\Credentials\CredentialsInterface; |
| 6 |
use Dudlewebs\WPMCS\s3\AWS\CRT\Auth\Signable; |
| 7 |
use Dudlewebs\WPMCS\s3\AWS\CRT\Auth\SignatureType; |
| 8 |
use Dudlewebs\WPMCS\s3\AWS\CRT\Auth\Signing; |
| 9 |
use Dudlewebs\WPMCS\s3\AWS\CRT\Auth\SigningAlgorithm; |
| 10 |
use Dudlewebs\WPMCS\s3\AWS\CRT\Auth\SigningConfigAWS; |
| 11 |
use Dudlewebs\WPMCS\s3\AWS\CRT\Auth\StaticCredentialsProvider; |
| 12 |
use Dudlewebs\WPMCS\s3\AWS\CRT\HTTP\Request; |
| 13 |
use Dudlewebs\WPMCS\s3\Aws\Exception\CommonRuntimeException; |
| 14 |
use Dudlewebs\WPMCS\s3\Aws\Exception\CouldNotCreateChecksumException; |
| 15 |
use Dudlewebs\WPMCS\s3\GuzzleHttp\Psr7; |
| 16 |
use Dudlewebs\WPMCS\s3\Psr\Http\Message\RequestInterface; |
| 17 |
/** |
| 18 |
* Signature Version 4 |
| 19 |
* @link http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html |
| 20 |
*/ |
| 21 |
class SignatureV4 implements SignatureInterface |
| 22 |
{ |
| 23 |
use SignatureTrait; |
| 24 |
const ISO8601_BASIC = 'Ymd\\THis\\Z'; |
| 25 |
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'; |
| 26 |
const AMZ_CONTENT_SHA256_HEADER = 'X-Amz-Content-Sha256'; |
| 27 |
/** @var string */ |
| 28 |
private $service; |
| 29 |
/** @var string */ |
| 30 |
protected $region; |
| 31 |
/** @var bool */ |
| 32 |
private $unsigned; |
| 33 |
/** @var bool */ |
| 34 |
private $useV4a; |
| 35 |
/** |
| 36 |
* The following headers are not signed because signing these headers |
| 37 |
* would potentially cause a signature mismatch when sending a request |
| 38 |
* through a proxy or if modified at the HTTP client level. |
| 39 |
* |
| 40 |
* @return array |
| 41 |
*/ |
| 42 |
protected function getHeaderBlacklist() |
| 43 |
{ |
| 44 |
return ['cache-control' => \true, 'content-type' => \true, 'content-length' => \true, 'expect' => \true, 'max-forwards' => \true, 'pragma' => \true, 'range' => \true, 'te' => \true, 'if-match' => \true, 'if-none-match' => \true, 'if-modified-since' => \true, 'if-unmodified-since' => \true, 'if-range' => \true, 'accept' => \true, 'authorization' => \true, 'proxy-authorization' => \true, 'from' => \true, 'referer' => \true, 'user-agent' => \true, 'X-Amz-User-Agent' => \true, 'x-amzn-trace-id' => \true, 'aws-sdk-invocation-id' => \true, 'aws-sdk-retry' => \true]; |
| 45 |
} |
| 46 |
/** |
| 47 |
* @param string $service Service name to use when signing |
| 48 |
* @param string $region Region name to use when signing |
| 49 |
* @param array $options Array of configuration options used when signing |
| 50 |
* - unsigned-body: Flag to make request have unsigned payload. |
| 51 |
* Unsigned body is used primarily for streaming requests. |
| 52 |
*/ |
| 53 |
public function __construct($service, $region, array $options = []) |
| 54 |
{ |
| 55 |
$this->service = $service; |
| 56 |
$this->region = $region; |
| 57 |
$this->unsigned = isset($options['unsigned-body']) ? $options['unsigned-body'] : \false; |
| 58 |
$this->useV4a = isset($options['use_v4a']) && $options['use_v4a'] === \true; |
| 59 |
} |
| 60 |
/** |
| 61 |
* {@inheritdoc} |
| 62 |
*/ |
| 63 |
public function signRequest(RequestInterface $request, CredentialsInterface $credentials, $signingService = null) |
| 64 |
{ |
| 65 |
$ldt = \gmdate(self::ISO8601_BASIC); |
| 66 |
$sdt = \substr($ldt, 0, 8); |
| 67 |
$parsed = $this->parseRequest($request); |
| 68 |
$parsed['headers']['X-Amz-Date'] = [$ldt]; |
| 69 |
if ($token = $credentials->getSecurityToken()) { |
| 70 |
$parsed['headers']['X-Amz-Security-Token'] = [$token]; |
| 71 |
} |
| 72 |
$service = isset($signingService) ? $signingService : $this->service; |
| 73 |
if ($this->useV4a) { |
| 74 |
return $this->signWithV4a($credentials, $request, $service); |
| 75 |
} |
| 76 |
$cs = $this->createScope($sdt, $this->region, $service); |
| 77 |
$payload = $this->getPayload($request); |
| 78 |
if ($payload == self::UNSIGNED_PAYLOAD) { |
| 79 |
$parsed['headers'][self::AMZ_CONTENT_SHA256_HEADER] = [$payload]; |
| 80 |
} |
| 81 |
$context = $this->createContext($parsed, $payload); |
| 82 |
$toSign = $this->createStringToSign($ldt, $cs, $context['creq']); |
| 83 |
$signingKey = $this->getSigningKey($sdt, $this->region, $service, $credentials->getSecretKey()); |
| 84 |
$signature = \hash_hmac('sha256', $toSign, $signingKey); |
| 85 |
$parsed['headers']['Authorization'] = ["AWS4-HMAC-SHA256 " . "Credential={$credentials->getAccessKeyId()}/{$cs}, " . "SignedHeaders={$context['headers']}, Signature={$signature}"]; |
| 86 |
return $this->buildRequest($parsed); |
| 87 |
} |
| 88 |
/** |
| 89 |
* Get the headers that were used to pre-sign the request. |
| 90 |
* Used for the X-Amz-SignedHeaders header. |
| 91 |
* |
| 92 |
* @param array $headers |
| 93 |
* @return array |
| 94 |
*/ |
| 95 |
private function getPresignHeaders(array $headers) |
| 96 |
{ |
| 97 |
$presignHeaders = []; |
| 98 |
$blacklist = $this->getHeaderBlacklist(); |
| 99 |
foreach ($headers as $name => $value) { |
| 100 |
$lName = \strtolower($name); |
| 101 |
if (!isset($blacklist[$lName]) && $name !== self::AMZ_CONTENT_SHA256_HEADER) { |
| 102 |
$presignHeaders[] = $lName; |
| 103 |
} |
| 104 |
} |
| 105 |
return $presignHeaders; |
| 106 |
} |
| 107 |
/** |
| 108 |
* {@inheritdoc} |
| 109 |
*/ |
| 110 |
public function presign(RequestInterface $request, CredentialsInterface $credentials, $expires, array $options = []) |
| 111 |
{ |
| 112 |
$startTimestamp = isset($options['start_time']) ? $this->convertToTimestamp($options['start_time'], null) : \time(); |
| 113 |
$expiresTimestamp = $this->convertToTimestamp($expires, $startTimestamp); |
| 114 |
$parsed = $this->createPresignedRequest($request, $credentials); |
| 115 |
$payload = $this->getPresignedPayload($request); |
| 116 |
$httpDate = \gmdate(self::ISO8601_BASIC, $startTimestamp); |
| 117 |
$shortDate = \substr($httpDate, 0, 8); |
| 118 |
$scope = $this->createScope($shortDate, $this->region, $this->service); |
| 119 |
$credential = $credentials->getAccessKeyId() . '/' . $scope; |
| 120 |
if ($credentials->getSecurityToken()) { |
| 121 |
unset($parsed['headers']['X-Amz-Security-Token']); |
| 122 |
} |
| 123 |
$parsed['query']['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256'; |
| 124 |
$parsed['query']['X-Amz-Credential'] = $credential; |
| 125 |
$parsed['query']['X-Amz-Date'] = \gmdate('Ymd\\THis\\Z', $startTimestamp); |
| 126 |
$parsed['query']['X-Amz-SignedHeaders'] = \implode(';', $this->getPresignHeaders($parsed['headers'])); |
| 127 |
$parsed['query']['X-Amz-Expires'] = $this->convertExpires($expiresTimestamp, $startTimestamp); |
| 128 |
$context = $this->createContext($parsed, $payload); |
| 129 |
$stringToSign = $this->createStringToSign($httpDate, $scope, $context['creq']); |
| 130 |
$key = $this->getSigningKey($shortDate, $this->region, $this->service, $credentials->getSecretKey()); |
| 131 |
$parsed['query']['X-Amz-Signature'] = \hash_hmac('sha256', $stringToSign, $key); |
| 132 |
return $this->buildRequest($parsed); |
| 133 |
} |
| 134 |
/** |
| 135 |
* Converts a POST request to a GET request by moving POST fields into the |
| 136 |
* query string. |
| 137 |
* |
| 138 |
* Useful for pre-signing query protocol requests. |
| 139 |
* |
| 140 |
* @param RequestInterface $request Request to clone |
| 141 |
* |
| 142 |
* @return RequestInterface |
| 143 |
* @throws \InvalidArgumentException if the method is not POST |
| 144 |
*/ |
| 145 |
public static function convertPostToGet(RequestInterface $request, $additionalQueryParams = "") |
| 146 |
{ |
| 147 |
if ($request->getMethod() !== 'POST') { |
| 148 |
throw new \InvalidArgumentException('Expected a POST request but ' . 'received a ' . $request->getMethod() . ' request.'); |
| 149 |
} |
| 150 |
$sr = $request->withMethod('GET')->withBody(Psr7\Utils::streamFor(''))->withoutHeader('Content-Type')->withoutHeader('Content-Length'); |
| 151 |
// Move POST fields to the query if they are present |
| 152 |
if ($request->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') { |
| 153 |
$body = (string) $request->getBody() . $additionalQueryParams; |
| 154 |
$sr = $sr->withUri($sr->getUri()->withQuery($body)); |
| 155 |
} |
| 156 |
return $sr; |
| 157 |
} |
| 158 |
protected function getPayload(RequestInterface $request) |
| 159 |
{ |
| 160 |
if ($this->unsigned && $request->getUri()->getScheme() == 'https') { |
| 161 |
return self::UNSIGNED_PAYLOAD; |
| 162 |
} |
| 163 |
// Calculate the request signature payload |
| 164 |
if ($request->hasHeader(self::AMZ_CONTENT_SHA256_HEADER)) { |
| 165 |
// Handle streaming operations (e.g. Glacier.UploadArchive) |
| 166 |
return $request->getHeaderLine(self::AMZ_CONTENT_SHA256_HEADER); |
| 167 |
} |
| 168 |
if (!$request->getBody()->isSeekable()) { |
| 169 |
throw new CouldNotCreateChecksumException('sha256'); |
| 170 |
} |
| 171 |
try { |
| 172 |
return Psr7\Utils::hash($request->getBody(), 'sha256'); |
| 173 |
} catch (\Exception $e) { |
| 174 |
throw new CouldNotCreateChecksumException('sha256', $e); |
| 175 |
} |
| 176 |
} |
| 177 |
protected function getPresignedPayload(RequestInterface $request) |
| 178 |
{ |
| 179 |
return $this->getPayload($request); |
| 180 |
} |
| 181 |
protected function createCanonicalizedPath($path) |
| 182 |
{ |
| 183 |
$doubleEncoded = \rawurlencode(\ltrim($path, '/')); |
| 184 |
return '/' . \str_replace('%2F', '/', $doubleEncoded); |
| 185 |
} |
| 186 |
private function createStringToSign($longDate, $credentialScope, $creq) |
| 187 |
{ |
| 188 |
$hash = \hash('sha256', $creq); |
| 189 |
return "AWS4-HMAC-SHA256\n{$longDate}\n{$credentialScope}\n{$hash}"; |
| 190 |
} |
| 191 |
private function createPresignedRequest(RequestInterface $request, CredentialsInterface $credentials) |
| 192 |
{ |
| 193 |
$parsedRequest = $this->parseRequest($request); |
| 194 |
// Make sure to handle temporary credentials |
| 195 |
if ($token = $credentials->getSecurityToken()) { |
| 196 |
$parsedRequest['headers']['X-Amz-Security-Token'] = [$token]; |
| 197 |
} |
| 198 |
return $this->moveHeadersToQuery($parsedRequest); |
| 199 |
} |
| 200 |
/** |
| 201 |
* @param array $parsedRequest |
| 202 |
* @param string $payload Hash of the request payload |
| 203 |
* @return array Returns an array of context information |
| 204 |
*/ |
| 205 |
private function createContext(array $parsedRequest, $payload) |
| 206 |
{ |
| 207 |
$blacklist = $this->getHeaderBlacklist(); |
| 208 |
// Normalize the path as required by SigV4 |
| 209 |
$canon = $parsedRequest['method'] . "\n" . $this->createCanonicalizedPath($parsedRequest['path']) . "\n" . $this->getCanonicalizedQuery($parsedRequest['query']) . "\n"; |
| 210 |
// Case-insensitively aggregate all of the headers. |
| 211 |
$aggregate = []; |
| 212 |
foreach ($parsedRequest['headers'] as $key => $values) { |
| 213 |
$key = \strtolower($key); |
| 214 |
if (!isset($blacklist[$key])) { |
| 215 |
foreach ($values as $v) { |
| 216 |
$aggregate[$key][] = $v; |
| 217 |
} |
| 218 |
} |
| 219 |
} |
| 220 |
\ksort($aggregate); |
| 221 |
$canonHeaders = []; |
| 222 |
foreach ($aggregate as $k => $v) { |
| 223 |
if (\count($v) > 0) { |
| 224 |
\sort($v); |
| 225 |
} |
| 226 |
$canonHeaders[] = $k . ':' . \preg_replace('/\\s+/', ' ', \implode(',', $v)); |
| 227 |
} |
| 228 |
$signedHeadersString = \implode(';', \array_keys($aggregate)); |
| 229 |
$canon .= \implode("\n", $canonHeaders) . "\n\n" . $signedHeadersString . "\n" . $payload; |
| 230 |
return ['creq' => $canon, 'headers' => $signedHeadersString]; |
| 231 |
} |
| 232 |
private function getCanonicalizedQuery(array $query) |
| 233 |
{ |
| 234 |
unset($query['X-Amz-Signature']); |
| 235 |
if (!$query) { |
| 236 |
return ''; |
| 237 |
} |
| 238 |
$qs = ''; |
| 239 |
\ksort($query); |
| 240 |
foreach ($query as $k => $v) { |
| 241 |
if (!\is_array($v)) { |
| 242 |
$qs .= \rawurlencode($k) . '=' . \rawurlencode($v !== null ? $v : '') . '&'; |
| 243 |
} else { |
| 244 |
\sort($v); |
| 245 |
foreach ($v as $value) { |
| 246 |
$qs .= \rawurlencode($k) . '=' . \rawurlencode($value !== null ? $value : '') . '&'; |
| 247 |
} |
| 248 |
} |
| 249 |
} |
| 250 |
return \substr($qs, 0, -1); |
| 251 |
} |
| 252 |
private function convertToTimestamp($dateValue, $relativeTimeBase = null) |
| 253 |
{ |
| 254 |
if ($dateValue instanceof \DateTimeInterface) { |
| 255 |
$timestamp = $dateValue->getTimestamp(); |
| 256 |
} elseif (!\is_numeric($dateValue)) { |
| 257 |
$timestamp = \strtotime($dateValue, $relativeTimeBase === null ? \time() : $relativeTimeBase); |
| 258 |
} else { |
| 259 |
$timestamp = $dateValue; |
| 260 |
} |
| 261 |
return $timestamp; |
| 262 |
} |
| 263 |
private function convertExpires($expiresTimestamp, $startTimestamp) |
| 264 |
{ |
| 265 |
$duration = $expiresTimestamp - $startTimestamp; |
| 266 |
// Ensure that the duration of the signature is not longer than a week |
| 267 |
if ($duration > 604800) { |
| 268 |
throw new \InvalidArgumentException('The expiration date of a ' . 'signature version 4 presigned URL must be less than one ' . 'week'); |
| 269 |
} |
| 270 |
return $duration; |
| 271 |
} |
| 272 |
private function moveHeadersToQuery(array $parsedRequest) |
| 273 |
{ |
| 274 |
//x-amz-user-agent shouldn't be put in a query param |
| 275 |
unset($parsedRequest['headers']['X-Amz-User-Agent']); |
| 276 |
foreach ($parsedRequest['headers'] as $name => $header) { |
| 277 |
$lname = \strtolower($name); |
| 278 |
if (\substr($lname, 0, 5) == 'x-amz') { |
| 279 |
$parsedRequest['query'][$name] = $header; |
| 280 |
} |
| 281 |
$blacklist = $this->getHeaderBlacklist(); |
| 282 |
if (isset($blacklist[$lname]) || $lname === \strtolower(self::AMZ_CONTENT_SHA256_HEADER)) { |
| 283 |
unset($parsedRequest['headers'][$name]); |
| 284 |
} |
| 285 |
} |
| 286 |
return $parsedRequest; |
| 287 |
} |
| 288 |
private function parseRequest(RequestInterface $request) |
| 289 |
{ |
| 290 |
// Clean up any previously set headers. |
| 291 |
/** @var RequestInterface $request */ |
| 292 |
$request = $request->withoutHeader('X-Amz-Date')->withoutHeader('Date')->withoutHeader('Authorization'); |
| 293 |
$uri = $request->getUri(); |
| 294 |
return ['method' => $request->getMethod(), 'path' => $uri->getPath(), 'query' => Psr7\Query::parse($uri->getQuery()), 'uri' => $uri, 'headers' => $request->getHeaders(), 'body' => $request->getBody(), 'version' => $request->getProtocolVersion()]; |
| 295 |
} |
| 296 |
private function buildRequest(array $req) |
| 297 |
{ |
| 298 |
if ($req['query']) { |
| 299 |
$req['uri'] = $req['uri']->withQuery(Psr7\Query::build($req['query'])); |
| 300 |
} |
| 301 |
return new Psr7\Request($req['method'], $req['uri'], $req['headers'], $req['body'], $req['version']); |
| 302 |
} |
| 303 |
/** |
| 304 |
* @param CredentialsInterface $credentials |
| 305 |
* @param RequestInterface $request |
| 306 |
* @param $signingService |
| 307 |
* @return RequestInterface |
| 308 |
*/ |
| 309 |
protected function signWithV4a(CredentialsInterface $credentials, RequestInterface $request, $signingService) |
| 310 |
{ |
| 311 |
if (!\extension_loaded('awscrt')) { |
| 312 |
throw new CommonRuntimeException("AWS Common Runtime for PHP is required to use Signature V4A" . ". Please install it using the instructions found at" . " https://github.com/aws/aws-sdk-php/blob/master/CRT_INSTRUCTIONS.md"); |
| 313 |
} |
| 314 |
$credentials_provider = new StaticCredentialsProvider(['access_key_id' => $credentials->getAccessKeyId(), 'secret_access_key' => $credentials->getSecretKey(), 'session_token' => $credentials->getSecurityToken()]); |
| 315 |
$sha = $this->getPayload($request); |
| 316 |
$signingConfig = new SigningConfigAWS(['algorithm' => SigningAlgorithm::SIGv4_ASYMMETRIC, 'signature_type' => SignatureType::HTTP_REQUEST_HEADERS, 'credentials_provider' => $credentials_provider, 'signed_body_value' => $sha, 'region' => "*", 'service' => $signingService, 'date' => \time()]); |
| 317 |
$sha = $this->getPayload($request); |
| 318 |
$invocationId = $request->getHeader("aws-sdk-invocation-id"); |
| 319 |
$retry = $request->getHeader("aws-sdk-retry"); |
| 320 |
$request = $request->withoutHeader("aws-sdk-invocation-id"); |
| 321 |
$request = $request->withoutHeader("aws-sdk-retry"); |
| 322 |
$http_request = new Request($request->getMethod(), (string) $request->getUri(), [], \array_map(function ($header) { |
| 323 |
return $header[0]; |
| 324 |
}, $request->getHeaders())); |
| 325 |
Signing::signRequestAws(Signable::fromHttpRequest($http_request), $signingConfig, function ($signing_result, $error_code) use(&$http_request) { |
| 326 |
$signing_result->applyToHttpRequest($http_request); |
| 327 |
}); |
| 328 |
$sigV4AHeaders = $http_request->headers(); |
| 329 |
foreach ($sigV4AHeaders->toArray() as $h => $v) { |
| 330 |
$request = $request->withHeader($h, $v); |
| 331 |
} |
| 332 |
$request = $request->withHeader("aws-sdk-invocation-id", $invocationId); |
| 333 |
$request = $request->withHeader("x-amz-content-sha256", $sha); |
| 334 |
$request = $request->withHeader("aws-sdk-retry", $retry); |
| 335 |
$request = $request->withHeader("x-amz-region-set", "*"); |
| 336 |
return $request; |
| 337 |
} |
| 338 |
} |
| 339 |
|