SignerV4.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. <?php
  2. namespace AsyncAws\Core\Signer;
  3. use AsyncAws\Core\Credentials\Credentials;
  4. use AsyncAws\Core\Exception\InvalidArgument;
  5. use AsyncAws\Core\Request;
  6. use AsyncAws\Core\RequestContext;
  7. use AsyncAws\Core\Stream\ReadOnceResultStream;
  8. use AsyncAws\Core\Stream\RewindableStream;
  9. use AsyncAws\Core\Stream\StringStream;
  10. /**
  11. * Version4 of signer.
  12. *
  13. * @author Jérémy Derussé <jeremy@derusse.com>
  14. */
  15. class SignerV4 implements Signer
  16. {
  17. private const ALGORITHM_REQUEST = 'AWS4-HMAC-SHA256';
  18. private const BLACKLIST_HEADERS = [
  19. 'cache-control' => true,
  20. 'content-type' => true,
  21. 'content-length' => true,
  22. 'expect' => true,
  23. 'max-forwards' => true,
  24. 'pragma' => true,
  25. 'range' => true,
  26. 'te' => true,
  27. 'if-match' => true,
  28. 'if-none-match' => true,
  29. 'if-modified-since' => true,
  30. 'if-unmodified-since' => true,
  31. 'if-range' => true,
  32. 'accept' => true,
  33. 'authorization' => true,
  34. 'proxy-authorization' => true,
  35. 'from' => true,
  36. 'referer' => true,
  37. 'user-agent' => true,
  38. 'x-amzn-trace-id' => true,
  39. 'aws-sdk-invocation-id' => true,
  40. 'aws-sdk-retry' => true,
  41. ];
  42. private $scopeName;
  43. private $region;
  44. public function __construct(string $scopeName, string $region)
  45. {
  46. $this->scopeName = $scopeName;
  47. $this->region = $region;
  48. }
  49. public function presign(Request $request, Credentials $credentials, RequestContext $context): void
  50. {
  51. $now = $context->getCurrentDate() ?? new \DateTimeImmutable();
  52. // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
  53. $now = $now->setTimezone(new \DateTimeZone('UTC'));
  54. $expires = $context->getExpirationDate() ?? $now->add(new \DateInterval('PT1H'));
  55. $this->handleSignature($request, $credentials, $now, $expires, true);
  56. }
  57. public function sign(Request $request, Credentials $credentials, RequestContext $context): void
  58. {
  59. $now = $context->getCurrentDate() ?? new \DateTimeImmutable();
  60. // Signer date have to be UTC https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
  61. $now = $now->setTimezone(new \DateTimeZone('UTC'));
  62. $this->handleSignature($request, $credentials, $now, $now, false);
  63. }
  64. protected function buildBodyDigest(Request $request, bool $isPresign): string
  65. {
  66. if ($request->hasHeader('x-amz-content-sha256')) {
  67. /** @var string $hash */
  68. $hash = $request->getHeader('x-amz-content-sha256');
  69. } else {
  70. $body = $request->getBody();
  71. if ($body instanceof ReadOnceResultStream) {
  72. $request->setBody($body = RewindableStream::create($body));
  73. }
  74. $hash = $request->getBody()->hash();
  75. }
  76. if ('UNSIGNED-PAYLOAD' === $hash) {
  77. $request->setHeader('x-amz-content-sha256', $hash);
  78. }
  79. return $hash;
  80. }
  81. protected function convertBodyToStream(SigningContext $context): void
  82. {
  83. $request = $context->getRequest();
  84. $request->setBody(StringStream::create($request->getBody()));
  85. }
  86. protected function buildCanonicalPath(Request $request): string
  87. {
  88. $doubleEncoded = rawurlencode(ltrim($request->getUri(), '/'));
  89. return '/' . str_replace('%2F', '/', $doubleEncoded);
  90. }
  91. private function handleSignature(Request $request, Credentials $credentials, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void
  92. {
  93. $this->removePresign($request);
  94. $this->sanitizeHostForHeader($request);
  95. $this->assignAmzQueryValues($request, $credentials, $isPresign);
  96. $this->buildTime($request, $now, $expires, $isPresign);
  97. $credentialScope = $this->buildCredentialString($request, $credentials, $now, $isPresign);
  98. $context = new SigningContext(
  99. $request,
  100. $now,
  101. implode('/', $credentialScope),
  102. $this->buildSigningKey($credentials, $credentialScope)
  103. );
  104. if ($isPresign) {
  105. // Should be called before `buildBodyDigest` because this method may alter the body
  106. $this->convertBodyToQuery($request);
  107. } else {
  108. $this->convertBodyToStream($context);
  109. }
  110. $bodyDigest = $this->buildBodyDigest($request, $isPresign);
  111. if ($isPresign) {
  112. // Should be called after `buildBodyDigest` because this method may remove the header `x-amz-content-sha256`
  113. $this->convertHeaderToQuery($request);
  114. }
  115. $canonicalHeaders = $this->buildCanonicalHeaders($request, $isPresign);
  116. $canonicalRequest = $this->buildCanonicalRequest($request, $canonicalHeaders, $bodyDigest);
  117. $stringToSign = $this->buildStringToSign($context->getNow(), $context->getCredentialString(), $canonicalRequest);
  118. $context->setSignature($signature = $this->buildSignature($stringToSign, $context->getSigningKey()));
  119. if ($isPresign) {
  120. $request->setQueryAttribute('X-Amz-Signature', $signature);
  121. } else {
  122. $request->setHeader('authorization', sprintf(
  123. '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
  124. self::ALGORITHM_REQUEST,
  125. $credentials->getAccessKeyId(),
  126. implode('/', $credentialScope),
  127. implode(';', array_keys($canonicalHeaders)),
  128. $signature
  129. ));
  130. }
  131. }
  132. private function removePresign(Request $request): void
  133. {
  134. $request->removeQueryAttribute('X-Amz-Algorithm');
  135. $request->removeQueryAttribute('X-Amz-Signature');
  136. $request->removeQueryAttribute('X-Amz-Security-Token');
  137. $request->removeQueryAttribute('X-Amz-Date');
  138. $request->removeQueryAttribute('X-Amz-Expires');
  139. $request->removeQueryAttribute('X-Amz-Credential');
  140. $request->removeQueryAttribute('X-Amz-SignedHeaders');
  141. }
  142. private function sanitizeHostForHeader(Request $request): void
  143. {
  144. if (false === $parsedUrl = parse_url($request->getEndpoint())) {
  145. throw new InvalidArgument(sprintf('The endpoint "%s" is invalid.', $request->getEndpoint()));
  146. }
  147. if (!isset($parsedUrl['host'])) {
  148. return;
  149. }
  150. $host = $parsedUrl['host'];
  151. if (isset($parsedUrl['port'])) {
  152. $host .= ':' . $parsedUrl['port'];
  153. }
  154. $request->setHeader('host', $host);
  155. }
  156. private function assignAmzQueryValues(Request $request, Credentials $credentials, bool $isPresign): void
  157. {
  158. if ($isPresign) {
  159. $request->setQueryAttribute('X-Amz-Algorithm', self::ALGORITHM_REQUEST);
  160. if (null !== $sessionToken = $credentials->getSessionToken()) {
  161. $request->setQueryAttribute('X-Amz-Security-Token', $sessionToken);
  162. }
  163. return;
  164. }
  165. if (null !== $sessionToken = $credentials->getSessionToken()) {
  166. $request->setHeader('x-amz-security-token', $sessionToken);
  167. }
  168. }
  169. private function buildTime(Request $request, \DateTimeImmutable $now, \DateTimeImmutable $expires, bool $isPresign): void
  170. {
  171. if ($isPresign) {
  172. $duration = $expires->getTimestamp() - $now->getTimestamp();
  173. if ($duration > 604800) {
  174. throw new InvalidArgument('The expiration date of presigned URL must be less than one week');
  175. }
  176. if ($duration < 0) {
  177. throw new InvalidArgument('The expiration date of presigned URL must be in the future');
  178. }
  179. $request->setQueryAttribute('X-Amz-Date', $now->format('Ymd\THis\Z'));
  180. $request->setQueryAttribute('X-Amz-Expires', $duration);
  181. } else {
  182. $request->setHeader('X-Amz-Date', $now->format('Ymd\THis\Z'));
  183. }
  184. }
  185. private function buildCredentialString(Request $request, Credentials $credentials, \DateTimeImmutable $now, bool $isPresign): array
  186. {
  187. $credentialScope = [$now->format('Ymd'), $this->region, $this->scopeName, 'aws4_request'];
  188. if ($isPresign) {
  189. $request->setQueryAttribute('X-Amz-Credential', $credentials->getAccessKeyId() . '/' . implode('/', $credentialScope));
  190. }
  191. return $credentialScope;
  192. }
  193. private function convertHeaderToQuery(Request $request): void
  194. {
  195. foreach ($request->getHeaders() as $name => $value) {
  196. if ('x-amz' === substr($name, 0, 5)) {
  197. $request->setQueryAttribute($name, $value);
  198. }
  199. if (isset(self::BLACKLIST_HEADERS[$name])) {
  200. $request->removeHeader($name);
  201. }
  202. }
  203. $request->removeHeader('x-amz-content-sha256');
  204. }
  205. private function convertBodyToQuery(Request $request): void
  206. {
  207. if ('POST' !== $request->getMethod()) {
  208. return;
  209. }
  210. $request->setMethod('GET');
  211. if ('application/x-www-form-urlencoded' === $request->getHeader('Content-Type')) {
  212. parse_str($request->getBody()->stringify(), $params);
  213. foreach ($params as $name => $value) {
  214. $request->setQueryAttribute($name, $value);
  215. }
  216. }
  217. $request->removeHeader('content-type');
  218. $request->removeHeader('content-length');
  219. $request->setBody(StringStream::create(''));
  220. }
  221. private function buildCanonicalHeaders(Request $request, bool $isPresign): array
  222. {
  223. // Case-insensitively aggregate all of the headers.
  224. $canonicalHeaders = [];
  225. foreach ($request->getHeaders() as $key => $value) {
  226. $key = strtolower($key);
  227. if (isset(self::BLACKLIST_HEADERS[$key])) {
  228. continue;
  229. }
  230. $canonicalHeaders[$key] = $key . ':' . preg_replace('/\s+/', ' ', $value);
  231. }
  232. ksort($canonicalHeaders);
  233. if ($isPresign) {
  234. $request->setQueryAttribute('X-Amz-SignedHeaders', implode(';', array_keys($canonicalHeaders)));
  235. }
  236. return $canonicalHeaders;
  237. }
  238. private function buildCanonicalRequest(Request $request, array $canonicalHeaders, string $bodyDigest): string
  239. {
  240. return implode("\n", [
  241. $request->getMethod(),
  242. $this->buildCanonicalPath($request),
  243. $this->buildCanonicalQuery($request),
  244. implode("\n", array_values($canonicalHeaders)),
  245. '', // empty line after headers
  246. implode(';', array_keys($canonicalHeaders)),
  247. $bodyDigest,
  248. ]);
  249. }
  250. private function buildCanonicalQuery(Request $request): string
  251. {
  252. $query = $request->getQuery();
  253. unset($query['X-Amz-Signature']);
  254. if (!$query) {
  255. return '';
  256. }
  257. ksort($query);
  258. $encodedQuery = [];
  259. foreach ($query as $key => $values) {
  260. if (!\is_array($values)) {
  261. $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($values);
  262. continue;
  263. }
  264. sort($values);
  265. foreach ($values as $value) {
  266. $encodedQuery[] = rawurlencode($key) . '=' . rawurlencode($value);
  267. }
  268. }
  269. return implode('&', $encodedQuery);
  270. }
  271. private function buildStringToSign(\DateTimeImmutable $now, string $credentialString, string $canonicalRequest): string
  272. {
  273. return implode("\n", [
  274. self::ALGORITHM_REQUEST,
  275. $now->format('Ymd\THis\Z'),
  276. $credentialString,
  277. hash('sha256', $canonicalRequest),
  278. ]);
  279. }
  280. private function buildSigningKey(Credentials $credentials, array $credentialScope): string
  281. {
  282. $signingKey = 'AWS4' . $credentials->getSecretKey();
  283. foreach ($credentialScope as $scopePart) {
  284. $signingKey = hash_hmac('sha256', $scopePart, $signingKey, true);
  285. }
  286. return $signingKey;
  287. }
  288. private function buildSignature(string $stringToSign, string $signingKey): string
  289. {
  290. return hash_hmac('sha256', $stringToSign, $signingKey);
  291. }
  292. }