SignatureV4.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. <?php
  2. namespace Aws\Signature;
  3. use Aws\Credentials\CredentialsInterface;
  4. use Aws\Exception\CouldNotCreateChecksumException;
  5. use GuzzleHttp\Psr7;
  6. use Psr\Http\Message\RequestInterface;
  7. /**
  8. * Signature Version 4
  9. * @link http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
  10. */
  11. class SignatureV4 implements SignatureInterface
  12. {
  13. use SignatureTrait;
  14. const ISO8601_BASIC = 'Ymd\THis\Z';
  15. const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
  16. const AMZ_CONTENT_SHA256_HEADER = 'X-Amz-Content-Sha256';
  17. /** @var string */
  18. private $service;
  19. /** @var string */
  20. private $region;
  21. /** @var bool */
  22. private $unsigned;
  23. /**
  24. * The following headers are not signed because signing these headers
  25. * would potentially cause a signature mismatch when sending a request
  26. * through a proxy or if modified at the HTTP client level.
  27. *
  28. * @return array
  29. */
  30. private function getHeaderBlacklist()
  31. {
  32. return [
  33. 'cache-control' => true,
  34. 'content-type' => true,
  35. 'content-length' => true,
  36. 'expect' => true,
  37. 'max-forwards' => true,
  38. 'pragma' => true,
  39. 'range' => true,
  40. 'te' => true,
  41. 'if-match' => true,
  42. 'if-none-match' => true,
  43. 'if-modified-since' => true,
  44. 'if-unmodified-since' => true,
  45. 'if-range' => true,
  46. 'accept' => true,
  47. 'authorization' => true,
  48. 'proxy-authorization' => true,
  49. 'from' => true,
  50. 'referer' => true,
  51. 'user-agent' => true,
  52. 'x-amzn-trace-id' => true,
  53. 'aws-sdk-invocation-id' => true,
  54. 'aws-sdk-retry' => true,
  55. ];
  56. }
  57. /**
  58. * @param string $service Service name to use when signing
  59. * @param string $region Region name to use when signing
  60. * @param array $options Array of configuration options used when signing
  61. * - unsigned-body: Flag to make request have unsigned payload.
  62. * Unsigned body is used primarily for streaming requests.
  63. */
  64. public function __construct($service, $region, array $options = [])
  65. {
  66. $this->service = $service;
  67. $this->region = $region;
  68. $this->unsigned = isset($options['unsigned-body']) ? $options['unsigned-body'] : false;
  69. }
  70. public function signRequest(
  71. RequestInterface $request,
  72. CredentialsInterface $credentials
  73. ) {
  74. $ldt = gmdate(self::ISO8601_BASIC);
  75. $sdt = substr($ldt, 0, 8);
  76. $parsed = $this->parseRequest($request);
  77. $parsed['headers']['X-Amz-Date'] = [$ldt];
  78. if ($token = $credentials->getSecurityToken()) {
  79. $parsed['headers']['X-Amz-Security-Token'] = [$token];
  80. }
  81. $cs = $this->createScope($sdt, $this->region, $this->service);
  82. $payload = $this->getPayload($request);
  83. if ($payload == self::UNSIGNED_PAYLOAD) {
  84. $parsed['headers'][self::AMZ_CONTENT_SHA256_HEADER] = [$payload];
  85. }
  86. $context = $this->createContext($parsed, $payload);
  87. $toSign = $this->createStringToSign($ldt, $cs, $context['creq']);
  88. $signingKey = $this->getSigningKey(
  89. $sdt,
  90. $this->region,
  91. $this->service,
  92. $credentials->getSecretKey()
  93. );
  94. $signature = hash_hmac('sha256', $toSign, $signingKey);
  95. $parsed['headers']['Authorization'] = [
  96. "AWS4-HMAC-SHA256 "
  97. . "Credential={$credentials->getAccessKeyId()}/{$cs}, "
  98. . "SignedHeaders={$context['headers']}, Signature={$signature}"
  99. ];
  100. return $this->buildRequest($parsed);
  101. }
  102. /**
  103. * Get the headers that were used to pre-sign the request.
  104. * Used for the X-Amz-SignedHeaders header.
  105. *
  106. * @param array $headers
  107. * @return array
  108. */
  109. private function getPresignHeaders(array $headers)
  110. {
  111. $presignHeaders = [];
  112. $blacklist = $this->getHeaderBlacklist();
  113. foreach ($headers as $name => $value) {
  114. $lName = strtolower($name);
  115. if (!isset($blacklist[$lName])
  116. && $name !== self::AMZ_CONTENT_SHA256_HEADER
  117. ) {
  118. $presignHeaders[] = $lName;
  119. }
  120. }
  121. return $presignHeaders;
  122. }
  123. public function presign(
  124. RequestInterface $request,
  125. CredentialsInterface $credentials,
  126. $expires,
  127. array $options = []
  128. ) {
  129. $startTimestamp = isset($options['start_time'])
  130. ? $this->convertToTimestamp($options['start_time'], null)
  131. : time();
  132. $expiresTimestamp = $this->convertToTimestamp($expires, $startTimestamp);
  133. $parsed = $this->createPresignedRequest($request, $credentials);
  134. $payload = $this->getPresignedPayload($request);
  135. $httpDate = gmdate(self::ISO8601_BASIC, $startTimestamp);
  136. $shortDate = substr($httpDate, 0, 8);
  137. $scope = $this->createScope($shortDate, $this->region, $this->service);
  138. $credential = $credentials->getAccessKeyId() . '/' . $scope;
  139. if ($credentials->getSecurityToken()) {
  140. unset($parsed['headers']['X-Amz-Security-Token']);
  141. }
  142. $parsed['query']['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256';
  143. $parsed['query']['X-Amz-Credential'] = $credential;
  144. $parsed['query']['X-Amz-Date'] = gmdate('Ymd\THis\Z', $startTimestamp);
  145. $parsed['query']['X-Amz-SignedHeaders'] = implode(';', $this->getPresignHeaders($parsed['headers']));
  146. $parsed['query']['X-Amz-Expires'] = $this->convertExpires($expiresTimestamp, $startTimestamp);
  147. $context = $this->createContext($parsed, $payload);
  148. $stringToSign = $this->createStringToSign($httpDate, $scope, $context['creq']);
  149. $key = $this->getSigningKey(
  150. $shortDate,
  151. $this->region,
  152. $this->service,
  153. $credentials->getSecretKey()
  154. );
  155. $parsed['query']['X-Amz-Signature'] = hash_hmac('sha256', $stringToSign, $key);
  156. return $this->buildRequest($parsed);
  157. }
  158. /**
  159. * Converts a POST request to a GET request by moving POST fields into the
  160. * query string.
  161. *
  162. * Useful for pre-signing query protocol requests.
  163. *
  164. * @param RequestInterface $request Request to clone
  165. *
  166. * @return RequestInterface
  167. * @throws \InvalidArgumentException if the method is not POST
  168. */
  169. public static function convertPostToGet(RequestInterface $request, $additionalQueryParams = "")
  170. {
  171. if ($request->getMethod() !== 'POST') {
  172. throw new \InvalidArgumentException('Expected a POST request but '
  173. . 'received a ' . $request->getMethod() . ' request.');
  174. }
  175. $sr = $request->withMethod('GET')
  176. ->withBody(Psr7\stream_for(''))
  177. ->withoutHeader('Content-Type')
  178. ->withoutHeader('Content-Length');
  179. // Move POST fields to the query if they are present
  180. if ($request->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') {
  181. $body = (string) $request->getBody() . $additionalQueryParams;
  182. $sr = $sr->withUri($sr->getUri()->withQuery($body));
  183. }
  184. return $sr;
  185. }
  186. protected function getPayload(RequestInterface $request)
  187. {
  188. if ($this->unsigned && $request->getUri()->getScheme() == 'https') {
  189. return self::UNSIGNED_PAYLOAD;
  190. }
  191. // Calculate the request signature payload
  192. if ($request->hasHeader(self::AMZ_CONTENT_SHA256_HEADER)) {
  193. // Handle streaming operations (e.g. Glacier.UploadArchive)
  194. return $request->getHeaderLine(self::AMZ_CONTENT_SHA256_HEADER);
  195. }
  196. if (!$request->getBody()->isSeekable()) {
  197. throw new CouldNotCreateChecksumException('sha256');
  198. }
  199. try {
  200. return Psr7\hash($request->getBody(), 'sha256');
  201. } catch (\Exception $e) {
  202. throw new CouldNotCreateChecksumException('sha256', $e);
  203. }
  204. }
  205. protected function getPresignedPayload(RequestInterface $request)
  206. {
  207. return $this->getPayload($request);
  208. }
  209. protected function createCanonicalizedPath($path)
  210. {
  211. $doubleEncoded = rawurlencode(ltrim($path, '/'));
  212. return '/' . str_replace('%2F', '/', $doubleEncoded);
  213. }
  214. private function createStringToSign($longDate, $credentialScope, $creq)
  215. {
  216. $hash = hash('sha256', $creq);
  217. return "AWS4-HMAC-SHA256\n{$longDate}\n{$credentialScope}\n{$hash}";
  218. }
  219. private function createPresignedRequest(
  220. RequestInterface $request,
  221. CredentialsInterface $credentials
  222. ) {
  223. $parsedRequest = $this->parseRequest($request);
  224. // Make sure to handle temporary credentials
  225. if ($token = $credentials->getSecurityToken()) {
  226. $parsedRequest['headers']['X-Amz-Security-Token'] = [$token];
  227. }
  228. return $this->moveHeadersToQuery($parsedRequest);
  229. }
  230. /**
  231. * @param array $parsedRequest
  232. * @param string $payload Hash of the request payload
  233. * @return array Returns an array of context information
  234. */
  235. private function createContext(array $parsedRequest, $payload)
  236. {
  237. $blacklist = $this->getHeaderBlacklist();
  238. // Normalize the path as required by SigV4
  239. $canon = $parsedRequest['method'] . "\n"
  240. . $this->createCanonicalizedPath($parsedRequest['path']) . "\n"
  241. . $this->getCanonicalizedQuery($parsedRequest['query']) . "\n";
  242. // Case-insensitively aggregate all of the headers.
  243. $aggregate = [];
  244. foreach ($parsedRequest['headers'] as $key => $values) {
  245. $key = strtolower($key);
  246. if (!isset($blacklist[$key])) {
  247. foreach ($values as $v) {
  248. $aggregate[$key][] = $v;
  249. }
  250. }
  251. }
  252. ksort($aggregate);
  253. $canonHeaders = [];
  254. foreach ($aggregate as $k => $v) {
  255. if (count($v) > 0) {
  256. sort($v);
  257. }
  258. $canonHeaders[] = $k . ':' . preg_replace('/\s+/', ' ', implode(',', $v));
  259. }
  260. $signedHeadersString = implode(';', array_keys($aggregate));
  261. $canon .= implode("\n", $canonHeaders) . "\n\n"
  262. . $signedHeadersString . "\n"
  263. . $payload;
  264. return ['creq' => $canon, 'headers' => $signedHeadersString];
  265. }
  266. private function getCanonicalizedQuery(array $query)
  267. {
  268. unset($query['X-Amz-Signature']);
  269. if (!$query) {
  270. return '';
  271. }
  272. $qs = '';
  273. ksort($query);
  274. foreach ($query as $k => $v) {
  275. if (!is_array($v)) {
  276. $qs .= rawurlencode($k) . '=' . rawurlencode($v) . '&';
  277. } else {
  278. sort($v);
  279. foreach ($v as $value) {
  280. $qs .= rawurlencode($k) . '=' . rawurlencode($value) . '&';
  281. }
  282. }
  283. }
  284. return substr($qs, 0, -1);
  285. }
  286. private function convertToTimestamp($dateValue, $relativeTimeBase = null)
  287. {
  288. if ($dateValue instanceof \DateTimeInterface) {
  289. $timestamp = $dateValue->getTimestamp();
  290. } elseif (!is_numeric($dateValue)) {
  291. $timestamp = strtotime($dateValue,
  292. $relativeTimeBase === null ? time() : $relativeTimeBase
  293. );
  294. } else {
  295. $timestamp = $dateValue;
  296. }
  297. return $timestamp;
  298. }
  299. private function convertExpires($expiresTimestamp, $startTimestamp)
  300. {
  301. $duration = $expiresTimestamp - $startTimestamp;
  302. // Ensure that the duration of the signature is not longer than a week
  303. if ($duration > 604800) {
  304. throw new \InvalidArgumentException('The expiration date of a '
  305. . 'signature version 4 presigned URL must be less than one '
  306. . 'week');
  307. }
  308. return $duration;
  309. }
  310. private function moveHeadersToQuery(array $parsedRequest)
  311. {
  312. foreach ($parsedRequest['headers'] as $name => $header) {
  313. $lname = strtolower($name);
  314. if (substr($lname, 0, 5) == 'x-amz') {
  315. $parsedRequest['query'][$name] = $header;
  316. }
  317. $blacklist = $this->getHeaderBlacklist();
  318. if (isset($blacklist[$lname])
  319. || $lname === strtolower(self::AMZ_CONTENT_SHA256_HEADER)
  320. ) {
  321. unset($parsedRequest['headers'][$name]);
  322. }
  323. }
  324. return $parsedRequest;
  325. }
  326. private function parseRequest(RequestInterface $request)
  327. {
  328. // Clean up any previously set headers.
  329. /** @var RequestInterface $request */
  330. $request = $request
  331. ->withoutHeader('X-Amz-Date')
  332. ->withoutHeader('Date')
  333. ->withoutHeader('Authorization');
  334. $uri = $request->getUri();
  335. return [
  336. 'method' => $request->getMethod(),
  337. 'path' => $uri->getPath(),
  338. 'query' => Psr7\parse_query($uri->getQuery()),
  339. 'uri' => $uri,
  340. 'headers' => $request->getHeaders(),
  341. 'body' => $request->getBody(),
  342. 'version' => $request->getProtocolVersion()
  343. ];
  344. }
  345. private function buildRequest(array $req)
  346. {
  347. if ($req['query']) {
  348. $req['uri'] = $req['uri']->withQuery(Psr7\build_query($req['query']));
  349. }
  350. return new Psr7\Request(
  351. $req['method'],
  352. $req['uri'],
  353. $req['headers'],
  354. $req['body'],
  355. $req['version']
  356. );
  357. }
  358. }