friendica-addons/s3_storage/vendor/akeeba/s3/src/Signature/V4.php

386 lines
12 KiB
PHP

<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2020 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Postproc\Connector\S3v4\Signature;
// Protection against direct access
defined('AKEEBAENGINE') or die();
use Akeeba\Engine\Postproc\Connector\S3v4\Signature;
use DateTime;
/**
* Implements the Amazon AWS v4 signatures
*
* @see http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
*/
class V4 extends Signature
{
/**
* Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
* add custom headers, e.g. x-amz-content-sha256
*
* @param array $headers The associative array of headers to process
* @param array $amzHeaders The associative array of amz-* headers to process
*
* @return void
*/
public function preProcessHeaders(array &$headers, array &$amzHeaders): void
{
// Do we already have an SHA-256 payload hash?
if (isset($amzHeaders['x-amz-content-sha256']))
{
return;
}
// Set the payload hash header
$input = $this->request->getInput();
if (is_object($input))
{
$requestPayloadHash = $input->getSha256();
}
else
{
$requestPayloadHash = hash('sha256', '', false);
}
$amzHeaders['x-amz-content-sha256'] = $requestPayloadHash;
}
/**
* Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
* pre-authorized URLs for downloading files from S3.
*
* @param integer|null $lifetime Lifetime in seconds. NULL for default lifetime.
* @param bool $https Use HTTPS ($hostBucket should be false for SSL verification)?
*
* @return string The presigned URL
*/
public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
{
// Set the Expires header
if (is_null($lifetime))
{
$lifetime = 10;
}
/**
* Authenticated URLs must always go through the generic regional endpoint, not the virtual hosting-style domain
* name. This means that if you have a bucket "example" in the EU West 1 (Ireland) region we have to go through
* http://s3-eu-west-1.amazonaws.com/example instead of http://example.amazonaws.com/ for all authenticated URLs
*/
$region = $this->request->getConfiguration()->getRegion();
$hostname = $this->getPresignedHostnameForRegion($region);
$this->request->setHeader('Host', $hostname);
// Set the expiration time in seconds
$this->request->setHeader('Expires', (int) $lifetime);
// Get the query parameters, including the calculated signature
$bucket = $this->request->getBucket();
$uri = $this->request->getResource();
$headers = $this->request->getHeaders();
$protocol = $https ? 'https' : 'http';
$serialisedParams = $this->getAuthorizationHeader();
// The query parameters are returned serialized; unserialize them, then build and return the URL.
$queryParameters = unserialize($serialisedParams);
$query = http_build_query($queryParameters);
$url = $protocol . '://' . $headers['Host'] . $uri;
$url .= (strpos($uri, '?') !== false) ? '&' : '?';
$url .= $query;
return $url;
}
/**
* Returns the authorization header for the request
*
* @return string
*/
public function getAuthorizationHeader(): string
{
$verb = strtoupper($this->request->getVerb());
$resourcePath = $this->request->getResource();
$headers = $this->request->getHeaders();
$amzHeaders = $this->request->getAmzHeaders();
$parameters = $this->request->getParameters();
$bucket = $this->request->getBucket();
$isPresignedURL = false;
// See the Connector class for the explanation behind this ugly workaround
$amazonIsBraindead = isset($headers['workaround-braindead-error-from-amazon']);
if ($amazonIsBraindead)
{
unset ($headers['workaround-braindead-error-from-amazon']);
}
// Get the credentials scope
$signatureDate = new DateTime($headers['Date']);
$credentialScope = $signatureDate->format('Ymd') . '/' .
$this->request->getConfiguration()->getRegion() . '/' .
's3/aws4_request';
/**
* If the Expires header is set up we're pre-signing a download URL. The string to sign is a bit
* different in this case and we have to pass certain headers as query string parameters.
*
* @see http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
*/
if (isset($headers['Expires']))
{
$gmtDate = clone $signatureDate;
$gmtDate->setTimezone(new \DateTimeZone('GMT'));
$parameters['X-Amz-Algorithm'] = "AWS4-HMAC-SHA256";
$parameters['X-Amz-Credential'] = $this->request->getConfiguration()->getAccess() . '/' . $credentialScope;
$parameters['X-Amz-Date'] = $gmtDate->format('Ymd\THis\Z');
$parameters['X-Amz-Expires'] = sprintf('%u', $headers['Expires']);
$token = $this->request->getConfiguration()->getToken();
if (!empty($token))
{
$parameters['x-amz-security-token'] = $token;
}
unset($headers['Expires']);
unset($headers['Date']);
unset($headers['Content-MD5']);
unset($headers['Content-Type']);
$isPresignedURL = true;
}
// ========== Step 1: Create a canonical request ==========
// See http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
$canonicalHeaders = "";
$signedHeadersArray = [];
// Calculate the canonical headers and the signed headers
if ($isPresignedURL)
{
// Presigned URLs use UNSIGNED-PAYLOAD instead
unset($amzHeaders['x-amz-content-sha256']);
}
$allHeaders = array_merge($headers, $amzHeaders);
ksort($allHeaders);
foreach ($allHeaders as $k => $v)
{
$lowercaseHeaderName = strtolower($k);
if ($amazonIsBraindead && ($lowercaseHeaderName == 'content-length'))
{
/**
* I know it looks crazy. It is. Somehow Amazon requires me to do this and only on _some_ servers, mind
* you. This is something undocumented and which is not covered by their official SDK. I had to write
* my own library because of that and the official SDK's inability to upload large files without using
* at least as much memory as the file itself (which doesn't fly well for files around 2Gb, let me tell
* you that!).
*/
$v = "$v,$v";
}
$canonicalHeaders .= $lowercaseHeaderName . ':' . trim($v) . "\n";
$signedHeadersArray[] = $lowercaseHeaderName;
}
$signedHeaders = implode(';', $signedHeadersArray);
if ($isPresignedURL)
{
$parameters['X-Amz-SignedHeaders'] = $signedHeaders;
}
// The canonical URI is the resource path
$canonicalURI = $resourcePath;
$bucketResource = '/' . $bucket;
$regionalHostname = ($headers['Host'] != 's3.amazonaws.com') && ($headers['Host'] != $bucket . '.s3.amazonaws.com');
// Special case: if the canonical URI ends in /?location the bucket name DOES count as part of the canonical URL
// even though the Host is s3.amazonaws.com (in which case it normally shouldn't count). Yeah, I know, it makes
// no sense!!!
if (!$regionalHostname && ($headers['Host'] == 's3.amazonaws.com') && (substr($canonicalURI, -10) == '/?location'))
{
$regionalHostname = true;
}
if (!$regionalHostname && (strpos($canonicalURI, $bucketResource) === 0))
{
if ($canonicalURI === $bucketResource)
{
$canonicalURI = '/';
}
else
{
$canonicalURI = substr($canonicalURI, strlen($bucketResource));
}
}
// If the resource path has a query yank it and parse it into the parameters array
$questionMarkPos = strpos($canonicalURI, '?');
if ($questionMarkPos !== false)
{
$canonicalURI = substr($canonicalURI, 0, $questionMarkPos);
$queryString = @substr($canonicalURI, $questionMarkPos + 1);
@parse_str($queryString, $extraQuery);
if (count($extraQuery))
{
$parameters = array_merge($parameters, $extraQuery);
}
}
// The canonical query string is the string representation of $parameters, alpha sorted by key
ksort($parameters);
// We build the query the hard way because http_build_query in PHP 5.3 does NOT have the fourth parameter
// (encoding type), defaulting to RFC 1738 encoding whereas S3 expects RFC 3986 encoding
$canonicalQueryString = '';
if (!empty($parameters))
{
$temp = [];
foreach ($parameters as $k => $v)
{
$temp[] = $this->urlencode($k) . '=' . $this->urlencode($v);
}
$canonicalQueryString = implode('&', $temp);
}
// Get the payload hash
$requestPayloadHash = 'UNSIGNED-PAYLOAD';
if (isset($amzHeaders['x-amz-content-sha256']))
{
$requestPayloadHash = $amzHeaders['x-amz-content-sha256'];
}
// Calculate the canonical request
$canonicalRequest = $verb . "\n" .
$canonicalURI . "\n" .
$canonicalQueryString . "\n" .
$canonicalHeaders . "\n" .
$signedHeaders . "\n" .
$requestPayloadHash;
$hashedCanonicalRequest = hash('sha256', $canonicalRequest);
// ========== Step 2: Create a string to sign ==========
// See http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
if (!isset($headers['Date']))
{
$headers['Date'] = '';
}
$stringToSign = "AWS4-HMAC-SHA256\n" .
$headers['Date'] . "\n" .
$credentialScope . "\n" .
$hashedCanonicalRequest;
if ($isPresignedURL)
{
$stringToSign = "AWS4-HMAC-SHA256\n" .
$parameters['X-Amz-Date'] . "\n" .
$credentialScope . "\n" .
$hashedCanonicalRequest;
}
// ========== Step 3: Calculate the signature ==========
// See http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
$kSigning = $this->getSigningKey($signatureDate);
$signature = hash_hmac('sha256', $stringToSign, $kSigning, false);
// ========== Step 4: Add the signing information to the Request ==========
// See http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
$authorization = 'AWS4-HMAC-SHA256 Credential=' .
$this->request->getConfiguration()->getAccess() . '/' . $credentialScope . ', ' .
'SignedHeaders=' . $signedHeaders . ', ' .
'Signature=' . $signature;
// For presigned URLs we only return the Base64-encoded signature without the AWS format specifier and the
// public access key.
if ($isPresignedURL)
{
$parameters['X-Amz-Signature'] = $signature;
return serialize($parameters);
}
return $authorization;
}
/**
* Calculate the AWS4 signing key
*
* @param DateTime $signatureDate The date the signing key is good for
*
* @return string
*/
private function getSigningKey(DateTime $signatureDate): string
{
$kSecret = $this->request->getConfiguration()->getSecret();
$kDate = hash_hmac('sha256', $signatureDate->format('Ymd'), 'AWS4' . $kSecret, true);
$kRegion = hash_hmac('sha256', $this->request->getConfiguration()->getRegion(), $kDate, true);
$kService = hash_hmac('sha256', 's3', $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
return $kSigning;
}
private function urlencode(?string $toEncode): string
{
if (empty($toEncode))
{
return '';
}
return str_replace('+', '%20', urlencode($toEncode));
}
/**
* Get the correct hostname for the given AWS region
*
* @param string $region
*
* @return string
*/
private function getPresignedHostnameForRegion(string $region): string
{
$endpoint = 's3.' . $region . '.amazonaws.com';
$dualstackEnabled = $this->request->getConfiguration()->getDualstackUrl();
// If dual-stack URLs are enabled then prepend the endpoint
if ($dualstackEnabled)
{
$endpoint = 's3.dualstack.' . $region . '.amazonaws.com';
}
if ($region == 'cn-north-1')
{
return $endpoint . '.cn';
}
return $endpoint;
}
}