diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -86,6 +86,8 @@ 'PhutilAWSException' => 'future/aws/PhutilAWSException.php', 'PhutilAWSFuture' => 'future/aws/PhutilAWSFuture.php', 'PhutilAWSS3Future' => 'future/aws/PhutilAWSS3Future.php', + 'PhutilAWSv4Signature' => 'future/aws/PhutilAWSv4Signature.php', + 'PhutilAWSv4SignatureTestCase' => 'future/aws/__tests__/PhutilAWSv4SignatureTestCase.php', 'PhutilAggregateException' => 'error/PhutilAggregateException.php', 'PhutilAllCapsEnglishLocale' => 'internationalization/locales/PhutilAllCapsEnglishLocale.php', 'PhutilAmazonAuthAdapter' => 'auth/PhutilAmazonAuthAdapter.php', @@ -602,6 +604,8 @@ 'PhutilAWSException' => 'Exception', 'PhutilAWSFuture' => 'FutureProxy', 'PhutilAWSS3Future' => 'PhutilAWSFuture', + 'PhutilAWSv4Signature' => 'Phobject', + 'PhutilAWSv4SignatureTestCase' => 'PhutilTestCase', 'PhutilAggregateException' => 'Exception', 'PhutilAllCapsEnglishLocale' => 'PhutilLocale', 'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter', diff --git a/src/future/aws/PhutilAWSv4Signature.php b/src/future/aws/PhutilAWSv4Signature.php new file mode 100644 --- /dev/null +++ b/src/future/aws/PhutilAWSv4Signature.php @@ -0,0 +1,256 @@ +accessKey = $access_key; + return $this; + } + + public function setSecretKey(PhutilOpaqueEnvelope $secret_key) { + $this->secretKey = $secret_key; + return $this; + } + + public function setDate($date) { + $this->date = $date; + return $this; + } + + public function getDate() { + if ($this->date === null) { + $this->date = date('c'); + } + return $this->date; + } + + public function setRegion($region) { + $this->region = $region; + return $this; + } + + public function getRegion() { + return $this->region; + } + + public function setService($service) { + $this->service = $service; + return $this; + } + + public function getService() { + return $this->service; + } + + public function setSigningKey($signing_key) { + $this->signingKey = $signing_key; + return $this; + } + + public function getSigningKey() { + if ($this->signingKey === null) { + $this->signingKey = $this->computeSigningKey(); + } + + return $this->signingKey; + } + + private function getAlgorithm() { + return 'AWS4-HMAC-SHA256'; + } + + private function getHost(HTTPSFuture $future) { + $uri = new PhutilURI($future->getURI()); + return $uri->getDomain(); + } + + private function getPath(HTTPSFuture $future) { + $uri = new PhutilURI($future->getURI()); + return $uri->getPath(); + } + + public function signRequest(HTTPSFuture $future) { + $body_signature = $this->getBodySignature($future); + + $future->addHeader('X-Amz-Content-sha256', $body_signature); + $future->addHeader('X-Amz-Date', $this->getDate()); + + $request_signature = $this->getCanonicalRequestSignature( + $future, + $body_signature); + + $string_to_sign = $this->getStringToSign($request_signature); + + $signing_key = $this->getSigningKey(); + + $signature = hash_hmac('sha256', $string_to_sign, $signing_key); + + $algorithm = $this->getAlgorithm(); + $credential = $this->getCredential(); + $signed_headers = $this->getSignedHeaderList($future); + + $authorization = + $algorithm.' '. + 'Credential='.$credential.','. + 'SignedHeaders='.$signed_headers.','. + 'Signature='.$signature; + + $future->addHeader('Authorization', $authorization); + + return $future; + } + + private function getBodySignature(HTTPSFuture $future) { + $http_body = $future->getData(); + + if (is_array($http_body)) { + $http_body = ''; + } + + return hash('sha256', $http_body); + } + + private function getCanonicalRequestSignature( + HTTPSFuture $future, + $body_signature) { + + $http_method = $future->getMethod(); + + $path = $this->getPath($future); + $path = rawurlencode($path); + $path = str_replace('%2F', '/', $path); + + $canonical_parameters = $this->getCanonicalParameterList($future); + $canonical_headers = $this->getCanonicalHeaderList($future); + $signed_headers = $this->getSignedHeaderList($future); + + $canonical_request = + $http_method."\n". + $path."\n". + $canonical_parameters."\n". + $canonical_headers."\n". + "\n". + $signed_headers."\n". + $body_signature; + + return hash('sha256', $canonical_request); + } + + private function getStringToSign($request_signature) { + $algorithm = $this->getAlgorithm(); + $date = $this->getDate(); + $scope_parts = $this->getScopeParts(); + $scope = implode('/', $scope_parts); + + $string_to_sign = + $algorithm."\n". + $date."\n". + $scope."\n". + $request_signature; + + return $string_to_sign; + } + + private function getScopeParts() { + return array( + substr($this->getDate(), 0, 8), + $this->getRegion(), + $this->getService(), + 'aws4_request', + ); + } + + private function computeSigningKey() { + $secret_key = $this->secretKey; + if (!$secret_key) { + throw new Exception( + pht( + 'You must either provide a signing key with setSigningKey(), or '. + 'provide a secret key with setSecretKey().')); + } + + // NOTE: This part of the algorithm uses the raw binary hashes, and the + // result is not human-readable. + $raw_hash = true; + + $signing_key = 'AWS4'.$secret_key->openEnvelope(); + + $scope_parts = $this->getScopeParts(); + foreach ($scope_parts as $scope_part) { + $signing_key = hash_hmac('sha256', $scope_part, $signing_key, $raw_hash); + } + + return $signing_key; + } + + private function getCanonicalHeaderList(HTTPSFuture $future) { + $headers = $this->getCanonicalHeaderMap($future); + + $canonical_headers = array(); + foreach ($headers as $header => $header_value) { + $canonical_headers[] = $header.':'.trim($header_value); + } + + return implode("\n", $canonical_headers); + } + + private function getCanonicalHeaderMap(HTTPSFuture $future) { + $headers = $future->getHeaders(); + $headers[] = array( + 'Host', + $this->getHost($future), + ); + + $header_map = array(); + foreach ($headers as $header) { + list($key, $value) = $header; + $key = phutil_utf8_strtolower($key); + $header_map[$key] = $value; + } + + ksort($header_map); + + return $header_map; + } + + private function getSignedHeaderList(HTTPSFuture $future) { + $headers = $this->getCanonicalHeaderMap($future); + return implode(';', array_keys($headers)); + } + + private function getCanonicalParameterList(HTTPSFuture $future) { + $uri = new PhutilURI($future->getURI()); + $params = $uri->getQueryParams(); + + ksort($params); + $canonical_parameters = array(); + foreach ($params as $key => $value) { + $canonical_parameters[] = rawurlencode($key).'='.rawurlencode($value); + } + $canonical_parameters = implode('&', $canonical_parameters); + + return $canonical_parameters; + } + + private function getCredential() { + $access_key = $this->accessKey; + if (!strlen($access_key)) { + throw new PhutilInvalidStateException('setAccessKey'); + } + + $parts = $this->getScopeParts(); + array_unshift($parts, $access_key); + + return implode('/', $parts); + } + +} diff --git a/src/future/aws/__tests__/PhutilAWSv4SignatureTestCase.php b/src/future/aws/__tests__/PhutilAWSv4SignatureTestCase.php new file mode 100644 --- /dev/null +++ b/src/future/aws/__tests__/PhutilAWSv4SignatureTestCase.php @@ -0,0 +1,159 @@ +setMethod($method) + ->addHeader('Range', 'bytes=0-9'); + + $signature = id(new PhutilAWSv4Signature()) + ->setAccessKey($access_key) + ->setSecretKey(new PhutilOpaqueEnvelope($secret_key)) + ->setDate($date) + ->setRegion($region) + ->setService($service); + + $signature->signRequest($future); + + $expect = <<assertSignature($expect, $future); + } + + + public function testAWSv4SignaturesS3PutObject() { + $access_key = 'AKIAIOSFODNN7EXAMPLE'; + $secret_key = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; + $date = '20130524T000000Z'; + $region = 'us-east-1'; + $service = 's3'; + $uri = 'https://examplebucket.s3.amazonaws.com/test$file.text'; + $method = 'PUT'; + $body = 'Welcome to Amazon S3.'; + + $future = id(new HTTPSFuture($uri, $body)) + ->setMethod($method) + ->addHeader('X-Amz-Storage-Class', 'REDUCED_REDUNDANCY') + ->addHeader('Date', 'Fri, 24 May 2013 00:00:00 GMT'); + + $signature = id(new PhutilAWSv4Signature()) + ->setAccessKey($access_key) + ->setSecretKey(new PhutilOpaqueEnvelope($secret_key)) + ->setDate($date) + ->setRegion($region) + ->setService($service); + + $signature->signRequest($future); + + $expect = <<assertSignature($expect, $future); + } + + + public function testAWSv4SignaturesS3GetBucketLifecycle() { + $access_key = 'AKIAIOSFODNN7EXAMPLE'; + $secret_key = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; + $date = '20130524T000000Z'; + $region = 'us-east-1'; + $service = 's3'; + $uri = 'https://examplebucket.s3.amazonaws.com/?lifecycle'; + $method = 'GET'; + + $future = id(new HTTPSFuture($uri)) + ->setMethod($method); + + $signature = id(new PhutilAWSv4Signature()) + ->setAccessKey($access_key) + ->setSecretKey(new PhutilOpaqueEnvelope($secret_key)) + ->setDate($date) + ->setRegion($region) + ->setService($service); + + $signature->signRequest($future); + + $expect = <<assertSignature($expect, $future); + } + + + public function testAWSv4SignaturesS3GetBucket() { + $access_key = 'AKIAIOSFODNN7EXAMPLE'; + $secret_key = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; + $date = '20130524T000000Z'; + $region = 'us-east-1'; + $service = 's3'; + $uri = 'https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J'; + $method = 'GET'; + + $future = id(new HTTPSFuture($uri)) + ->setMethod($method); + + $signature = id(new PhutilAWSv4Signature()) + ->setAccessKey($access_key) + ->setSecretKey(new PhutilOpaqueEnvelope($secret_key)) + ->setDate($date) + ->setRegion($region) + ->setService($service); + + $signature->signRequest($future); + + $expect = <<assertSignature($expect, $future); + } + + + private function assertSignature($expect, HTTPSFuture $signed) { + $authorization = null; + foreach ($signed->getHeaders() as $header) { + list($key, $value) = $header; + if (phutil_utf8_strtolower($key) === 'authorization') { + $authorization = $value; + break; + } + } + + $expect = str_replace("\n\n", ' ', $expect); + $expect = str_replace("\n", '', $expect); + + $this->assertEqual($expect, $authorization); + } + + +}