SignatureV4.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. <?php
  2. namespace Aws\Signature;
  3. use Aws\Credentials\CredentialsInterface;
  4. use AWS\CRT\Auth\Signable;
  5. use AWS\CRT\Auth\SignatureType;
  6. use AWS\CRT\Auth\SignedBodyHeaderType;
  7. use AWS\CRT\Auth\Signing;
  8. use AWS\CRT\Auth\SigningAlgorithm;
  9. use AWS\CRT\Auth\SigningConfigAWS;
  10. use AWS\CRT\Auth\StaticCredentialsProvider;
  11. use AWS\CRT\HTTP\Request;
  12. use Aws\Exception\CommonRuntimeException;
  13. use Aws\Exception\CouldNotCreateChecksumException;
  14. use GuzzleHttp\Psr7;
  15. use Psr\Http\Message\RequestInterface;
  16. /**
  17. * Signature Version 4
  18. * @link http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
  19. */
  20. class SignatureV4 implements SignatureInterface
  21. {
  22. use SignatureTrait;
  23. const ISO8601_BASIC = 'Ymd\THis\Z';
  24. const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
  25. const AMZ_CONTENT_SHA256_HEADER = 'X-Amz-Content-Sha256';
  26. /** @var string */
  27. private $service;
  28. /** @var string */
  29. protected $region;
  30. /** @var bool */
  31. private $unsigned;
  32. /** @var bool */
  33. private $useV4a;
  34. /**
  35. * The following headers are not signed because signing these headers
  36. * would potentially cause a signature mismatch when sending a request
  37. * through a proxy or if modified at the HTTP client level.
  38. *
  39. * @return array
  40. */
  41. protected function getHeaderBlacklist()
  42. {
  43. return [
  44. 'cache-control' => true,
  45. 'content-type' => true,
  46. 'content-length' => true,
  47. 'expect' => true,
  48. 'max-forwards' => true,
  49. 'pragma' => true,
  50. 'range' => true,
  51. 'te' => true,
  52. 'if-match' => true,
  53. 'if-none-match' => true,
  54. 'if-modified-since' => true,
  55. 'if-unmodified-since' => true,
  56. 'if-range' => true,
  57. 'accept' => true,
  58. 'authorization' => true,
  59. 'proxy-authorization' => true,
  60. 'from' => true,
  61. 'referer' => true,
  62. 'user-agent' => true,
  63. 'X-Amz-User-Agent' => true,
  64. 'x-amzn-trace-id' => true,
  65. 'aws-sdk-invocation-id' => true,
  66. 'aws-sdk-retry' => true,
  67. ];
  68. }
  69. /**
  70. * @param string $service Service name to use when signing
  71. * @param string $region Region name to use when signing
  72. * @param array $options Array of configuration options used when signing
  73. * - unsigned-body: Flag to make request have unsigned payload.
  74. * Unsigned body is used primarily for streaming requests.
  75. */
  76. public function __construct($service, $region, array $options = [])
  77. {
  78. $this->service = $service;
  79. $this->region = $region;
  80. $this->unsigned = isset($options['unsigned-body']) ? $options['unsigned-body'] : false;
  81. $this->useV4a = isset($options['use_v4a']) && $options['use_v4a'] === true;
  82. }
  83. /**
  84. * {@inheritdoc}
  85. */
  86. public function signRequest(
  87. RequestInterface $request,
  88. CredentialsInterface $credentials,
  89. $signingService = null
  90. ) {
  91. $ldt = gmdate(self::ISO8601_BASIC);
  92. $sdt = substr($ldt, 0, 8);
  93. $parsed = $this->parseRequest($request);
  94. $parsed['headers']['X-Amz-Date'] = [$ldt];
  95. if ($token = $credentials->getSecurityToken()) {
  96. $parsed['headers']['X-Amz-Security-Token'] = [$token];
  97. }
  98. $service = isset($signingService) ? $signingService : $this->service;
  99. if ($this->useV4a) {
  100. return $this->signWithV4a($credentials, $request, $service);
  101. }
  102. $cs = $this->createScope($sdt, $this->region, $service);
  103. $payload = $this->getPayload($request);
  104. if ($payload == self::UNSIGNED_PAYLOAD) {
  105. $parsed['headers'][self::AMZ_CONTENT_SHA256_HEADER] = [$payload];
  106. }
  107. $context = $this->createContext($parsed, $payload);
  108. $toSign = $this->createStringToSign($ldt, $cs, $context['creq']);
  109. $signingKey = $this->getSigningKey(
  110. $sdt,
  111. $this->region,
  112. $service,
  113. $credentials->getSecretKey()
  114. );
  115. $signature = hash_hmac('sha256', $toSign, $signingKey);
  116. $parsed['headers']['Authorization'] = [
  117. "AWS4-HMAC-SHA256 "
  118. . "Credential={$credentials->getAccessKeyId()}/{$cs}, "
  119. . "SignedHeaders={$context['headers']}, Signature={$signature}"
  120. ];
  121. return $this->buildRequest($parsed);
  122. }
  123. /**
  124. * Get the headers that were used to pre-sign the request.
  125. * Used for the X-Amz-SignedHeaders header.
  126. *
  127. * @param array $headers
  128. * @return array
  129. */
  130. private function getPresignHeaders(array $headers)
  131. {
  132. $presignHeaders = [];
  133. $blacklist = $this->getHeaderBlacklist();
  134. foreach ($headers as $name => $value) {
  135. $lName = strtolower($name);
  136. if (!isset($blacklist[$lName])
  137. && $name !== self::AMZ_CONTENT_SHA256_HEADER
  138. ) {
  139. $presignHeaders[] = $lName;
  140. }
  141. }
  142. return $presignHeaders;
  143. }
  144. /**
  145. * {@inheritdoc}
  146. */
  147. public function presign(
  148. RequestInterface $request,
  149. CredentialsInterface $credentials,
  150. $expires,
  151. array $options = []
  152. ) {
  153. $startTimestamp = isset($options['start_time'])
  154. ? $this->convertToTimestamp($options['start_time'], null)
  155. : time();
  156. $expiresTimestamp = $this->convertToTimestamp($expires, $startTimestamp);
  157. if ($this->useV4a) {
  158. return $this->presignWithV4a(
  159. $request,
  160. $credentials,
  161. $this->convertExpires($expiresTimestamp, $startTimestamp)
  162. );
  163. }
  164. $parsed = $this->createPresignedRequest($request, $credentials);
  165. $payload = $this->getPresignedPayload($request);
  166. $httpDate = gmdate(self::ISO8601_BASIC, $startTimestamp);
  167. $shortDate = substr($httpDate, 0, 8);
  168. $scope = $this->createScope($shortDate, $this->region, $this->service);
  169. $credential = $credentials->getAccessKeyId() . '/' . $scope;
  170. if ($credentials->getSecurityToken()) {
  171. unset($parsed['headers']['X-Amz-Security-Token']);
  172. }
  173. $parsed['query']['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256';
  174. $parsed['query']['X-Amz-Credential'] = $credential;
  175. $parsed['query']['X-Amz-Date'] = gmdate('Ymd\THis\Z', $startTimestamp);
  176. $parsed['query']['X-Amz-SignedHeaders'] = implode(';', $this->getPresignHeaders($parsed['headers']));
  177. $parsed['query']['X-Amz-Expires'] = $this->convertExpires($expiresTimestamp, $startTimestamp);
  178. $context = $this->createContext($parsed, $payload);
  179. $stringToSign = $this->createStringToSign($httpDate, $scope, $context['creq']);
  180. $key = $this->getSigningKey(
  181. $shortDate,
  182. $this->region,
  183. $this->service,
  184. $credentials->getSecretKey()
  185. );
  186. $parsed['query']['X-Amz-Signature'] = hash_hmac('sha256', $stringToSign, $key);
  187. return $this->buildRequest($parsed);
  188. }
  189. /**
  190. * Converts a POST request to a GET request by moving POST fields into the
  191. * query string.
  192. *
  193. * Useful for pre-signing query protocol requests.
  194. *
  195. * @param RequestInterface $request Request to clone
  196. *
  197. * @return RequestInterface
  198. * @throws \InvalidArgumentException if the method is not POST
  199. */
  200. public static function convertPostToGet(RequestInterface $request, $additionalQueryParams = "")
  201. {
  202. if ($request->getMethod() !== 'POST') {
  203. throw new \InvalidArgumentException('Expected a POST request but '
  204. . 'received a ' . $request->getMethod() . ' request.');
  205. }
  206. $sr = $request->withMethod('GET')
  207. ->withBody(Psr7\Utils::streamFor(''))
  208. ->withoutHeader('Content-Type')
  209. ->withoutHeader('Content-Length');
  210. // Move POST fields to the query if they are present
  211. if ($request->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') {
  212. $body = (string) $request->getBody() . $additionalQueryParams;
  213. $sr = $sr->withUri($sr->getUri()->withQuery($body));
  214. }
  215. return $sr;
  216. }
  217. protected function getPayload(RequestInterface $request)
  218. {
  219. if ($this->unsigned && $request->getUri()->getScheme() == 'https') {
  220. return self::UNSIGNED_PAYLOAD;
  221. }
  222. // Calculate the request signature payload
  223. if ($request->hasHeader(self::AMZ_CONTENT_SHA256_HEADER)) {
  224. // Handle streaming operations (e.g. Glacier.UploadArchive)
  225. return $request->getHeaderLine(self::AMZ_CONTENT_SHA256_HEADER);
  226. }
  227. if (!$request->getBody()->isSeekable()) {
  228. throw new CouldNotCreateChecksumException('sha256');
  229. }
  230. try {
  231. return Psr7\Utils::hash($request->getBody(), 'sha256');
  232. } catch (\Exception $e) {
  233. throw new CouldNotCreateChecksumException('sha256', $e);
  234. }
  235. }
  236. protected function getPresignedPayload(RequestInterface $request)
  237. {
  238. return $this->getPayload($request);
  239. }
  240. protected function createCanonicalizedPath($path)
  241. {
  242. $doubleEncoded = rawurlencode(ltrim($path, '/'));
  243. return '/' . str_replace('%2F', '/', $doubleEncoded);
  244. }
  245. private function createStringToSign($longDate, $credentialScope, $creq)
  246. {
  247. $hash = hash('sha256', $creq);
  248. return "AWS4-HMAC-SHA256\n{$longDate}\n{$credentialScope}\n{$hash}";
  249. }
  250. private function createPresignedRequest(
  251. RequestInterface $request,
  252. CredentialsInterface $credentials
  253. ) {
  254. $parsedRequest = $this->parseRequest($request);
  255. // Make sure to handle temporary credentials
  256. if ($token = $credentials->getSecurityToken()) {
  257. $parsedRequest['headers']['X-Amz-Security-Token'] = [$token];
  258. }
  259. return $this->moveHeadersToQuery($parsedRequest);
  260. }
  261. /**
  262. * @param array $parsedRequest
  263. * @param string $payload Hash of the request payload
  264. * @return array Returns an array of context information
  265. */
  266. private function createContext(array $parsedRequest, $payload)
  267. {
  268. $blacklist = $this->getHeaderBlacklist();
  269. // Normalize the path as required by SigV4
  270. $canon = $parsedRequest['method'] . "\n"
  271. . $this->createCanonicalizedPath($parsedRequest['path']) . "\n"
  272. . $this->getCanonicalizedQuery($parsedRequest['query']) . "\n";
  273. // Case-insensitively aggregate all of the headers.
  274. $aggregate = [];
  275. foreach ($parsedRequest['headers'] as $key => $values) {
  276. $key = strtolower($key);
  277. if (!isset($blacklist[$key])) {
  278. foreach ($values as $v) {
  279. $aggregate[$key][] = $v;
  280. }
  281. }
  282. }
  283. ksort($aggregate);
  284. $canonHeaders = [];
  285. foreach ($aggregate as $k => $v) {
  286. if (count($v) > 0) {
  287. sort($v);
  288. }
  289. $canonHeaders[] = $k . ':' . preg_replace('/\s+/', ' ', implode(',', $v));
  290. }
  291. $signedHeadersString = implode(';', array_keys($aggregate));
  292. $canon .= implode("\n", $canonHeaders) . "\n\n"
  293. . $signedHeadersString . "\n"
  294. . $payload;
  295. return ['creq' => $canon, 'headers' => $signedHeadersString];
  296. }
  297. private function getCanonicalizedQuery(array $query)
  298. {
  299. unset($query['X-Amz-Signature']);
  300. if (!$query) {
  301. return '';
  302. }
  303. $qs = '';
  304. ksort($query);
  305. foreach ($query as $k => $v) {
  306. if (!is_array($v)) {
  307. $qs .= rawurlencode($k) . '=' . rawurlencode($v !== null ? $v : '') . '&';
  308. } else {
  309. sort($v);
  310. foreach ($v as $value) {
  311. $qs .= rawurlencode($k) . '=' . rawurlencode($value !== null ? $value : '') . '&';
  312. }
  313. }
  314. }
  315. return substr($qs, 0, -1);
  316. }
  317. private function convertToTimestamp($dateValue, $relativeTimeBase = null)
  318. {
  319. if ($dateValue instanceof \DateTimeInterface) {
  320. $timestamp = $dateValue->getTimestamp();
  321. } elseif (!is_numeric($dateValue)) {
  322. $timestamp = strtotime($dateValue,
  323. $relativeTimeBase === null ? time() : $relativeTimeBase
  324. );
  325. } else {
  326. $timestamp = $dateValue;
  327. }
  328. return $timestamp;
  329. }
  330. private function convertExpires($expiresTimestamp, $startTimestamp)
  331. {
  332. $duration = $expiresTimestamp - $startTimestamp;
  333. // Ensure that the duration of the signature is not longer than a week
  334. if ($duration > 604800) {
  335. throw new \InvalidArgumentException('The expiration date of a '
  336. . 'signature version 4 presigned URL must be less than one '
  337. . 'week');
  338. }
  339. return $duration;
  340. }
  341. private function moveHeadersToQuery(array $parsedRequest)
  342. {
  343. //x-amz-user-agent shouldn't be put in a query param
  344. unset($parsedRequest['headers']['X-Amz-User-Agent']);
  345. foreach ($parsedRequest['headers'] as $name => $header) {
  346. $lname = strtolower($name);
  347. if (substr($lname, 0, 5) == 'x-amz') {
  348. $parsedRequest['query'][$name] = $header;
  349. }
  350. $blacklist = $this->getHeaderBlacklist();
  351. if (isset($blacklist[$lname])
  352. || $lname === strtolower(self::AMZ_CONTENT_SHA256_HEADER)
  353. ) {
  354. unset($parsedRequest['headers'][$name]);
  355. }
  356. }
  357. return $parsedRequest;
  358. }
  359. private function parseRequest(RequestInterface $request)
  360. {
  361. // Clean up any previously set headers.
  362. /** @var RequestInterface $request */
  363. $request = $request
  364. ->withoutHeader('X-Amz-Date')
  365. ->withoutHeader('Date')
  366. ->withoutHeader('Authorization');
  367. $uri = $request->getUri();
  368. return [
  369. 'method' => $request->getMethod(),
  370. 'path' => $uri->getPath(),
  371. 'query' => Psr7\Query::parse($uri->getQuery()),
  372. 'uri' => $uri,
  373. 'headers' => $request->getHeaders(),
  374. 'body' => $request->getBody(),
  375. 'version' => $request->getProtocolVersion()
  376. ];
  377. }
  378. private function buildRequest(array $req)
  379. {
  380. if ($req['query']) {
  381. $req['uri'] = $req['uri']->withQuery(Psr7\Query::build($req['query']));
  382. }
  383. return new Psr7\Request(
  384. $req['method'],
  385. $req['uri'],
  386. $req['headers'],
  387. $req['body'],
  388. $req['version']
  389. );
  390. }
  391. protected function verifyCRTLoaded()
  392. {
  393. if (!extension_loaded('awscrt')) {
  394. throw new CommonRuntimeException(
  395. "AWS Common Runtime for PHP is required to use Signature V4A"
  396. . ". Please install it using the instructions found at"
  397. . " https://github.com/aws/aws-sdk-php/blob/master/CRT_INSTRUCTIONS.md"
  398. );
  399. }
  400. }
  401. protected function createCRTStaticCredentialsProvider($credentials)
  402. {
  403. return new StaticCredentialsProvider([
  404. 'access_key_id' => $credentials->getAccessKeyId(),
  405. 'secret_access_key' => $credentials->getSecretKey(),
  406. 'session_token' => $credentials->getSecurityToken(),
  407. ]);
  408. }
  409. private function removeIllegalV4aHeaders(&$request)
  410. {
  411. $illegalV4aHeaders = [
  412. self::AMZ_CONTENT_SHA256_HEADER,
  413. "aws-sdk-invocation-id",
  414. "aws-sdk-retry",
  415. 'x-amz-region-set',
  416. ];
  417. $storedHeaders = [];
  418. foreach ($illegalV4aHeaders as $header) {
  419. if ($request->hasHeader($header)){
  420. $storedHeaders[$header] = $request->getHeader($header);
  421. $request = $request->withoutHeader($header);
  422. }
  423. }
  424. return $storedHeaders;
  425. }
  426. private function CRTRequestFromGuzzleRequest($request)
  427. {
  428. return new Request(
  429. $request->getMethod(),
  430. (string) $request->getUri(),
  431. [], //leave empty as the query is parsed from the uri object
  432. array_map(function ($header) {return $header[0];}, $request->getHeaders())
  433. );
  434. }
  435. /**
  436. * @param CredentialsInterface $credentials
  437. * @param RequestInterface $request
  438. * @param $signingService
  439. * @param SigningConfigAWS|null $signingConfig
  440. * @return RequestInterface
  441. */
  442. protected function signWithV4a(
  443. CredentialsInterface $credentials,
  444. RequestInterface $request,
  445. $signingService,
  446. SigningConfigAWS $signingConfig = null
  447. ){
  448. $this->verifyCRTLoaded();
  449. $signingConfig = $signingConfig ?? new SigningConfigAWS([
  450. 'algorithm' => SigningAlgorithm::SIGv4_ASYMMETRIC,
  451. 'signature_type' => SignatureType::HTTP_REQUEST_HEADERS,
  452. 'credentials_provider' => $this->createCRTStaticCredentialsProvider($credentials),
  453. 'signed_body_value' => $this->getPayload($request),
  454. 'should_normalize_uri_path' => true,
  455. 'use_double_uri_encode' => true,
  456. 'region' => "*",
  457. 'service' => $signingService,
  458. 'date' => time(),
  459. ]);
  460. $removedIllegalHeaders = $this->removeIllegalV4aHeaders($request);
  461. $http_request = $this->CRTRequestFromGuzzleRequest($request);
  462. Signing::signRequestAws(
  463. Signable::fromHttpRequest($http_request),
  464. $signingConfig, function ($signing_result, $error_code) use (&$http_request) {
  465. $signing_result->applyToHttpRequest($http_request);
  466. });
  467. foreach ($removedIllegalHeaders as $header => $value) {
  468. $request = $request->withHeader($header, $value);
  469. }
  470. $sigV4AHeaders = $http_request->headers();
  471. foreach ($sigV4AHeaders->toArray() as $h => $v) {
  472. $request = $request->withHeader($h, $v);
  473. }
  474. return $request;
  475. }
  476. protected function presignWithV4a(
  477. RequestInterface $request,
  478. CredentialsInterface $credentials,
  479. $expires
  480. )
  481. {
  482. $this->verifyCRTLoaded();
  483. $credentials_provider = $this->createCRTStaticCredentialsProvider($credentials);
  484. $signingConfig = new SigningConfigAWS([
  485. 'algorithm' => SigningAlgorithm::SIGv4_ASYMMETRIC,
  486. 'signature_type' => SignatureType::HTTP_REQUEST_QUERY_PARAMS,
  487. 'credentials_provider' => $credentials_provider,
  488. 'signed_body_value' => $this->getPresignedPayload($request),
  489. 'region' => "*",
  490. 'service' => $this->service,
  491. 'date' => time(),
  492. 'expiration_in_seconds' => $expires
  493. ]);
  494. $this->removeIllegalV4aHeaders($request);
  495. foreach ($this->getHeaderBlacklist() as $headerName => $headerValue) {
  496. if ($request->hasHeader($headerName)) {
  497. $request = $request->withoutHeader($headerName);
  498. }
  499. }
  500. $http_request = $this->CRTRequestFromGuzzleRequest($request);
  501. Signing::signRequestAws(
  502. Signable::fromHttpRequest($http_request),
  503. $signingConfig, function ($signing_result, $error_code) use (&$http_request) {
  504. $signing_result->applyToHttpRequest($http_request);
  505. });
  506. return $request->withUri(
  507. new Psr7\Uri($http_request->pathAndQuery())
  508. );
  509. }
  510. }