SignatureV4.php 12 KB

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