| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351 |
- <?php
- namespace AsyncAws\Core\Signer;
- use AsyncAws\Core\Credentials\Credentials;
- use AsyncAws\Core\Exception\InvalidArgument;
- use AsyncAws\Core\Request;
- use AsyncAws\Core\RequestContext;
- use AsyncAws\Core\Stream\ReadOnceResultStream;
- use AsyncAws\Core\Stream\RewindableStream;
- use AsyncAws\Core\Stream\StringStream;
- /**
- * Version4 of signer.
- *
- * @author Jérémy Derussé <jeremy@derusse.com>
- */
- class SignerV4 implements Signer
- {
- private const ALGORITHM_REQUEST = 'AWS4-HMAC-SHA256';
- private const BLACKLIST_HEADERS = [
- '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-amzn-trace-id' => true,
- 'aws-sdk-invocation-id' => true,
- 'aws-sdk-retry' => true,
- ];
- private $scopeName;
- private $region;
- public function __construct(string $scopeName, string $region)
- {
- $this->scopeName = $scopeName;
- $this->region = $region;
- }
- public function presign(Request $request, Credentials $credentials, RequestContext $context): void
- {
- $now = $context->getCurrentDate() ?? new \DateTimeImmutable();
- // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
- $now = $now->setTimezone(new \DateTimeZone('UTC'));
- $expires = $context->getExpirationDate() ?? $now->add(new \DateInterval('PT1H'));
- $this->handleSignature($request, $credentials, $now, $expires, true);
- }
- public function sign(Request $request, Credentials $credentials, RequestContext $context): void
- {
- $now = $context->getCurrentDate() ?? new \DateTimeImmutable();
- // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
- $now = $now->setTimezone(new \DateTimeZone('UTC'));
- $this->handleSignature($request, $credentials, $now, $now, false);
- }
- protected function buildBodyDigest(Request $request, bool $isPresign): string
- {
- if ($request->hasHeader('x-amz-content-sha256')) {
- /** @var string $hash */
- $hash = $request->getHeader('x-amz-content-sha256');
- } else {
- $body = $request->getBody();
- if ($body instanceof ReadOnceResultStream) {
- $request->setBody($body = RewindableStream::create($body));
- }
- $hash = $request->getBody()->hash();
- }
- if ('UNSIGNED-PAYLOAD' === $hash) {
- $request->setHeader('x-amz-content-sha256', $hash);
- }
- return $hash;
- }
- protected function convertBodyToStream(SigningContext $context): void
- {
- $request = $context->getRequest();
- $request->setBody(StringStream::create($request->getBody()));
- }
- protected function buildCanonicalPath(Request $request): string
- {
- $doubleEncoded = rawurlencode(ltrim($request->getUri(), '/'));
- return '/' . str_replace('%2F', '/', $doubleEncoded);
- }
- private function handleSignature(Request $request, Credentials $credentials, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void
- {
- $this->removePresign($request);
- $this->sanitizeHostForHeader($request);
- $this->assignAmzQueryValues($request, $credentials, $isPresign);
- $this->buildTime($request, $now, $expires, $isPresign);
- $credentialScope = $this->buildCredentialString($request, $credentials, $now, $isPresign);
- $context = new SigningContext(
- $request,
- $now,
- implode('/', $credentialScope),
- $this->buildSigningKey($credentials, $credentialScope)
- );
- if ($isPresign) {
- // Should be called before `buildBodyDigest` because this method may alter the body
- $this->convertBodyToQuery($request);
- } else {
- $this->convertBodyToStream($context);
- }
- $bodyDigest = $this->buildBodyDigest($request, $isPresign);
- if ($isPresign) {
- // Should be called after `buildBodyDigest` because this method may remove the header `x-amz-content-sha256`
- $this->convertHeaderToQuery($request);
- }
- $canonicalHeaders = $this->buildCanonicalHeaders($request, $isPresign);
- $canonicalRequest = $this->buildCanonicalRequest($request, $canonicalHeaders, $bodyDigest);
- $stringToSign = $this->buildStringToSign($context->getNow(), $context->getCredentialString(), $canonicalRequest);
- $context->setSignature($signature = $this->buildSignature($stringToSign, $context->getSigningKey()));
- if ($isPresign) {
- $request->setQueryAttribute('X-Amz-Signature', $signature);
- } else {
- $request->setHeader('authorization', sprintf(
- '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
- self::ALGORITHM_REQUEST,
- $credentials->getAccessKeyId(),
- implode('/', $credentialScope),
- implode(';', array_keys($canonicalHeaders)),
- $signature
- ));
- }
- }
- private function removePresign(Request $request): void
- {
- $request->removeQueryAttribute('X-Amz-Algorithm');
- $request->removeQueryAttribute('X-Amz-Signature');
- $request->removeQueryAttribute('X-Amz-Security-Token');
- $request->removeQueryAttribute('X-Amz-Date');
- $request->removeQueryAttribute('X-Amz-Expires');
- $request->removeQueryAttribute('X-Amz-Credential');
- $request->removeQueryAttribute('X-Amz-SignedHeaders');
- }
- private function sanitizeHostForHeader(Request $request): void
- {
- if (false === $parsedUrl = parse_url($request->getEndpoint())) {
- throw new InvalidArgument(sprintf('The endpoint "%s" is invalid.', $request->getEndpoint()));
- }
- if (!isset($parsedUrl['host'])) {
- return;
- }
- $host = $parsedUrl['host'];
- if (isset($parsedUrl['port'])) {
- $host .= ':' . $parsedUrl['port'];
- }
- $request->setHeader('host', $host);
- }
- private function assignAmzQueryValues(Request $request, Credentials $credentials, bool $isPresign): void
- {
- if ($isPresign) {
- $request->setQueryAttribute('X-Amz-Algorithm', self::ALGORITHM_REQUEST);
- if (null !== $sessionToken = $credentials->getSessionToken()) {
- $request->setQueryAttribute('X-Amz-Security-Token', $sessionToken);
- }
- return;
- }
- if (null !== $sessionToken = $credentials->getSessionToken()) {
- $request->setHeader('x-amz-security-token', $sessionToken);
- }
- }
- private function buildTime(Request $request, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void
- {
- if ($isPresign) {
- $duration = $expires->getTimestamp() - $now->getTimestamp();
- if ($duration > 604800) {
- throw new InvalidArgument('The expiration date of presigned URL must be less than one week');
- }
- if ($duration < 0) {
- throw new InvalidArgument('The expiration date of presigned URL must be in the future');
- }
- $request->setQueryAttribute('X-Amz-Date', $now->format('Ymd\THis\Z'));
- $request->setQueryAttribute('X-Amz-Expires', $duration);
- } else {
- $request->setHeader('X-Amz-Date', $now->format('Ymd\THis\Z'));
- }
- }
- private function buildCredentialString(Request $request, Credentials $credentials, \DateTimeImmutable $now, bool $isPresign): array
- {
- $credentialScope = [$now->format('Ymd'), $this->region, $this->scopeName, 'aws4_request'];
- if ($isPresign) {
- $request->setQueryAttribute('X-Amz-Credential', $credentials->getAccessKeyId() . '/' . implode('/', $credentialScope));
- }
- return $credentialScope;
- }
- private function convertHeaderToQuery(Request $request): void
- {
- foreach ($request->getHeaders() as $name => $value) {
- if ('x-amz' === substr($name, 0, 5)) {
- $request->setQueryAttribute($name, $value);
- }
- if (isset(self::BLACKLIST_HEADERS[$name])) {
- $request->removeHeader($name);
- }
- }
- $request->removeHeader('x-amz-content-sha256');
- }
- private function convertBodyToQuery(Request $request): void
- {
- if ('POST' !== $request->getMethod()) {
- return;
- }
- $request->setMethod('GET');
- if ('application/x-www-form-urlencoded' === $request->getHeader('Content-Type')) {
- parse_str($request->getBody()->stringify(), $params);
- foreach ($params as $name => $value) {
- $request->setQueryAttribute($name, $value);
- }
- }
- $request->removeHeader('content-type');
- $request->removeHeader('content-length');
- $request->setBody(StringStream::create(''));
- }
- private function buildCanonicalHeaders(Request $request, bool $isPresign): array
- {
- // Case-insensitively aggregate all of the headers.
- $canonicalHeaders = [];
- foreach ($request->getHeaders() as $key => $value) {
- $key = strtolower($key);
- if (isset(self::BLACKLIST_HEADERS[$key])) {
- continue;
- }
- $canonicalHeaders[$key] = $key . ':' . preg_replace('/\s+/', ' ', $value);
- }
- ksort($canonicalHeaders);
- if ($isPresign) {
- $request->setQueryAttribute('X-Amz-SignedHeaders', implode(';', array_keys($canonicalHeaders)));
- }
- return $canonicalHeaders;
- }
- private function buildCanonicalRequest(Request $request, array $canonicalHeaders, string $bodyDigest): string
- {
- return implode("\n", [
- $request->getMethod(),
- $this->buildCanonicalPath($request),
- $this->buildCanonicalQuery($request),
- implode("\n", array_values($canonicalHeaders)),
- '', // empty line after headers
- implode(';', array_keys($canonicalHeaders)),
- $bodyDigest,
- ]);
- }
- private function buildCanonicalQuery(Request $request): string
- {
- $query = $request->getQuery();
- unset($query['X-Amz-Signature']);
- if (!$query) {
- return '';
- }
- ksort($query);
- $encodedQuery = [];
- foreach ($query as $key => $values) {
- if (!\is_array($values)) {
- $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($values);
- continue;
- }
- sort($values);
- foreach ($values as $value) {
- $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($value);
- }
- }
- return implode('&', $encodedQuery);
- }
- private function buildStringToSign(\DateTimeImmutable $now, string $credentialString, string $canonicalRequest): string
- {
- return implode("\n", [
- self::ALGORITHM_REQUEST,
- $now->format('Ymd\THis\Z'),
- $credentialString,
- hash('sha256', $canonicalRequest),
- ]);
- }
- private function buildSigningKey(Credentials $credentials, array $credentialScope): string
- {
- $signingKey = 'AWS4' . $credentials->getSecretKey();
- foreach ($credentialScope as $scopePart) {
- $signingKey = hash_hmac('sha256', $scopePart, $signingKey, true);
- }
- return $signingKey;
- }
- private function buildSignature(string $stringToSign, string $signingKey): string
- {
- return hash_hmac('sha256', $stringToSign, $signingKey);
- }
- }
|