serviceName = $serviceName; $this->regionName = $regionName; } /** * Set the service name instead of inferring it from a request URL * * @param string $service Name of the service used when signing * * @return self */ public function setServiceName($service) { $this->serviceName = $service; return $this; } /** * Set the region name instead of inferring it from a request URL * * @param string $region Name of the region used when signing * * @return self */ public function setRegionName($region) { $this->regionName = $region; return $this; } /** * Set the maximum number of computed hashes to cache * * @param int $maxCacheSize Maximum number of hashes to cache * * @return self */ public function setMaxCacheSize($maxCacheSize) { $this->maxCacheSize = $maxCacheSize; return $this; } public function signRequest(RequestInterface $request, CredentialsInterface $credentials) { $timestamp = $this->getTimestamp(); $longDate = gmdate(DateFormat::ISO8601, $timestamp); $shortDate = substr($longDate, 0, 8); // Remove any previously set Authorization headers so that retries work $request->removeHeader('Authorization'); // Requires a x-amz-date header or Date if ($request->hasHeader('x-amz-date') || !$request->hasHeader('Date')) { $request->setHeader('x-amz-date', $longDate); } else { $request->setHeader('Date', gmdate(DateFormat::RFC1123, $timestamp)); } // Add the security token if one is present if ($credentials->getSecurityToken()) { $request->setHeader('x-amz-security-token', $credentials->getSecurityToken()); } // Parse the service and region or use one that is explicitly set $region = $this->regionName; $service = $this->serviceName; if (!$region || !$service) { $url = Url::factory($request->getUrl()); $region = $region ?: HostNameUtils::parseRegionName($url); $service = $service ?: HostNameUtils::parseServiceName($url); } $credentialScope = $this->createScope($shortDate, $region, $service); $payload = $this->getPayload($request); $signingContext = $this->createSigningContext($request, $payload); $signingContext['string_to_sign'] = $this->createStringToSign( $longDate, $credentialScope, $signingContext['canonical_request'] ); // Calculate the signing key using a series of derived keys $signingKey = $this->getSigningKey($shortDate, $region, $service, $credentials->getSecretKey()); $signature = hash_hmac('sha256', $signingContext['string_to_sign'], $signingKey); $request->setHeader('Authorization', "AWS4-HMAC-SHA256 " . "Credential={$credentials->getAccessKeyId()}/{$credentialScope}, " . "SignedHeaders={$signingContext['signed_headers']}, Signature={$signature}"); // Add debug information to the request $request->getParams()->set('aws.signature', $signingContext); } public function createPresignedUrl( RequestInterface $request, CredentialsInterface $credentials, $expires ) { $request = $this->createPresignedRequest($request, $credentials); $query = $request->getQuery(); $httpDate = gmdate(DateFormat::ISO8601, $this->getTimestamp()); $shortDate = substr($httpDate, 0, 8); $scope = $this->createScope( $shortDate, $this->regionName, $this->serviceName ); $this->addQueryValues($scope, $request, $credentials, $expires); $payload = $this->getPresignedPayload($request); $context = $this->createSigningContext($request, $payload); $stringToSign = $this->createStringToSign( $httpDate, $scope, $context['canonical_request'] ); $key = $this->getSigningKey( $shortDate, $this->regionName, $this->serviceName, $credentials->getSecretKey() ); $query['X-Amz-Signature'] = hash_hmac('sha256', $stringToSign, $key); return $request->getUrl(); } /** * Converts a POST request to a GET request by moving POST fields into the * query string. * * Useful for pre-signing query protocol requests. * * @param EntityEnclosingRequestInterface $request Request to clone * * @return RequestInterface * @throws \InvalidArgumentException if the method is not POST */ public static function convertPostToGet(EntityEnclosingRequestInterface $request) { if ($request->getMethod() !== 'POST') { throw new \InvalidArgumentException('Expected a POST request but ' . 'received a ' . $request->getMethod() . ' request.'); } $cloned = RequestFactory::getInstance() ->cloneRequestWithMethod($request, 'GET'); // Move POST fields to the query if they are present foreach ($request->getPostFields() as $name => $value) { $cloned->getQuery()->set($name, $value); } return $cloned; } /** * Get the payload part of a signature from a request. * * @param RequestInterface $request * * @return string */ protected function getPayload(RequestInterface $request) { // Calculate the request signature payload if ($request->hasHeader('x-amz-content-sha256')) { // Handle streaming operations (e.g. Glacier.UploadArchive) return (string) $request->getHeader('x-amz-content-sha256'); } if ($request instanceof EntityEnclosingRequestInterface) { return hash( 'sha256', $request->getMethod() == 'POST' && count($request->getPostFields()) ? (string) $request->getPostFields() : (string) $request->getBody() ); } return self::DEFAULT_PAYLOAD; } /** * Get the payload of a request for use with pre-signed URLs. * * @param RequestInterface $request * * @return string */ protected function getPresignedPayload(RequestInterface $request) { return $this->getPayload($request); } protected function createCanonicalizedPath(RequestInterface $request) { $doubleEncoded = rawurlencode(ltrim($request->getPath(), '/')); return '/' . str_replace('%2F', '/', $doubleEncoded); } private function createStringToSign($longDate, $credentialScope, $creq) { return "AWS4-HMAC-SHA256\n{$longDate}\n{$credentialScope}\n" . hash('sha256', $creq); } private function createPresignedRequest( RequestInterface $request, CredentialsInterface $credentials ) { $sr = RequestFactory::getInstance()->cloneRequestWithMethod($request, 'GET'); // Move POST fields to the query if they are present if ($request instanceof EntityEnclosingRequestInterface) { foreach ($request->getPostFields() as $name => $value) { $sr->getQuery()->set($name, $value); } } // Make sure to handle temporary credentials if ($token = $credentials->getSecurityToken()) { $sr->setHeader('X-Amz-Security-Token', $token); $sr->getQuery()->set('X-Amz-Security-Token', $token); } $this->moveHeadersToQuery($sr); return $sr; } /** * Create the canonical representation of a request * * @param RequestInterface $request Request to canonicalize * @param string $payload Request payload (typically the value * of the x-amz-content-sha256 header. * * @return array Returns an array of context information including: * - canonical_request * - signed_headers */ private function createSigningContext(RequestInterface $request, $payload) { $signable = array( 'host' => true, 'date' => true, 'content-md5' => true ); // Normalize the path as required by SigV4 and ensure it's absolute $canon = $request->getMethod() . "\n" . $this->createCanonicalizedPath($request) . "\n" . $this->getCanonicalizedQueryString($request) . "\n"; $canonHeaders = array(); foreach ($request->getHeaders()->getAll() as $key => $values) { $key = strtolower($key); if (isset($signable[$key]) || substr($key, 0, 6) === 'x-amz-') { $values = $values->toArray(); if (count($values) == 1) { $values = $values[0]; } else { sort($values); $values = implode(',', $values); } $canonHeaders[$key] = $key . ':' . preg_replace('/\s+/', ' ', $values); } } ksort($canonHeaders); $signedHeadersString = implode(';', array_keys($canonHeaders)); $canon .= implode("\n", $canonHeaders) . "\n\n" . $signedHeadersString . "\n" . $payload; return array( 'canonical_request' => $canon, 'signed_headers' => $signedHeadersString ); } /** * Get a hash for a specific key and value. If the hash was previously * cached, return it * * @param string $shortDate Short date * @param string $region Region name * @param string $service Service name * @param string $secretKey Secret Access Key * * @return string */ private function getSigningKey($shortDate, $region, $service, $secretKey) { $cacheKey = $shortDate . '_' . $region . '_' . $service . '_' . $secretKey; // Retrieve the hash form the cache or create it and add it to the cache if (!isset($this->hashCache[$cacheKey])) { // When the cache size reaches the max, then just clear the cache if (++$this->cacheSize > $this->maxCacheSize) { $this->hashCache = array(); $this->cacheSize = 0; } $dateKey = hash_hmac('sha256', $shortDate, 'AWS4' . $secretKey, true); $regionKey = hash_hmac('sha256', $region, $dateKey, true); $serviceKey = hash_hmac('sha256', $service, $regionKey, true); $this->hashCache[$cacheKey] = hash_hmac('sha256', 'aws4_request', $serviceKey, true); } return $this->hashCache[$cacheKey]; } /** * Get the canonicalized query string for a request * * @param RequestInterface $request * @return string */ private function getCanonicalizedQueryString(RequestInterface $request) { $queryParams = $request->getQuery()->getAll(); unset($queryParams['X-Amz-Signature']); if (empty($queryParams)) { return ''; } $qs = ''; ksort($queryParams); foreach ($queryParams as $key => $values) { if (is_array($values)) { sort($values); } elseif ($values === 0) { $values = array('0'); } elseif (!$values) { $values = array(''); } foreach ((array) $values as $value) { if ($value === QueryString::BLANK) { $value = ''; } $qs .= rawurlencode($key) . '=' . rawurlencode($value) . '&'; } } return substr($qs, 0, -1); } private function convertExpires($expires) { if ($expires instanceof \DateTime) { $expires = $expires->getTimestamp(); } elseif (!is_numeric($expires)) { $expires = strtotime($expires); } $duration = $expires - time(); // Ensure that the duration of the signature is not longer than a week if ($duration > 604800) { throw new \InvalidArgumentException('The expiration date of a ' . 'signature version 4 presigned URL must be less than one ' . 'week'); } return $duration; } private function createScope($shortDate, $region, $service) { return $shortDate . '/' . $region . '/' . $service . '/aws4_request'; } private function addQueryValues( $scope, RequestInterface $request, CredentialsInterface $credentials, $expires ) { $credential = $credentials->getAccessKeyId() . '/' . $scope; // Set query params required for pre-signed URLs $request->getQuery() ->set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256') ->set('X-Amz-Credential', $credential) ->set('X-Amz-Date', gmdate('Ymd\THis\Z', $this->getTimestamp())) ->set('X-Amz-SignedHeaders', 'Host') ->set('X-Amz-Expires', $this->convertExpires($expires)); } private function moveHeadersToQuery(RequestInterface $request) { $query = $request->getQuery(); foreach ($request->getHeaders() as $name => $header) { if (substr($name, 0, 5) == 'x-amz') { $query[$header->getName()] = (string) $header; } if ($name !== 'host') { $request->removeHeader($name); } } } }