IncomingSignedRequest.php 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\Security\Signature\Model;
  8. use JsonSerializable;
  9. use NCU\Security\Signature\Enum\DigestAlgorithm;
  10. use NCU\Security\Signature\Enum\SignatureAlgorithm;
  11. use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
  12. use NCU\Security\Signature\Exceptions\IncomingRequestException;
  13. use NCU\Security\Signature\Exceptions\InvalidSignatureException;
  14. use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
  15. use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException;
  16. use NCU\Security\Signature\Exceptions\SignatureException;
  17. use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
  18. use NCU\Security\Signature\IIncomingSignedRequest;
  19. use NCU\Security\Signature\ISignatureManager;
  20. use NCU\Security\Signature\Model\Signatory;
  21. use OC\Security\Signature\SignatureManager;
  22. use OCP\IRequest;
  23. use ValueError;
  24. /**
  25. * @inheritDoc
  26. *
  27. * @see ISignatureManager for details on signature
  28. * @since 31.0.0
  29. */
  30. class IncomingSignedRequest extends SignedRequest implements
  31. IIncomingSignedRequest,
  32. JsonSerializable {
  33. private string $origin = '';
  34. /**
  35. * @param string $body
  36. * @param IRequest $request
  37. * @param array $options
  38. *
  39. * @throws IncomingRequestException if incoming request is wrongly signed
  40. * @throws SignatureException if signature is faulty
  41. * @throws SignatureNotFoundException if signature is not implemented
  42. */
  43. public function __construct(
  44. string $body,
  45. private readonly IRequest $request,
  46. private readonly array $options = [],
  47. ) {
  48. parent::__construct($body);
  49. $this->verifyHeaders();
  50. $this->extractSignatureHeader();
  51. $this->reconstructSignatureData();
  52. try {
  53. // we set origin based on the keyId defined in the Signature header of the request
  54. $this->setOrigin(Signatory::extractIdentityFromUri($this->getSigningElement('keyId')));
  55. } catch (IdentityNotFoundException $e) {
  56. throw new IncomingRequestException($e->getMessage());
  57. }
  58. }
  59. /**
  60. * confirm that:
  61. *
  62. * - date is available in the header and its value is less than 5 minutes old
  63. * - content-length is available and is the same as the payload size
  64. * - digest is available and fit the checksum of the payload
  65. *
  66. * @throws IncomingRequestException
  67. * @throws SignatureNotFoundException
  68. */
  69. private function verifyHeaders(): void {
  70. if ($this->request->getHeader('Signature') === '') {
  71. throw new SignatureNotFoundException('missing Signature in header');
  72. }
  73. // confirm presence of date, content-length, digest and Signature
  74. $date = $this->request->getHeader('date');
  75. if ($date === '') {
  76. throw new IncomingRequestException('missing date in header');
  77. }
  78. $contentLength = $this->request->getHeader('content-length');
  79. if ($contentLength === '') {
  80. throw new IncomingRequestException('missing content-length in header');
  81. }
  82. $digest = $this->request->getHeader('digest');
  83. if ($digest === '') {
  84. throw new IncomingRequestException('missing digest in header');
  85. }
  86. // confirm date
  87. try {
  88. $dTime = new \DateTime($date);
  89. $requestTime = $dTime->getTimestamp();
  90. } catch (\Exception) {
  91. throw new IncomingRequestException('datetime exception');
  92. }
  93. if ($requestTime < (time() - ($this->options['ttl'] ?? SignatureManager::DATE_TTL))) {
  94. throw new IncomingRequestException('object is too old');
  95. }
  96. // confirm validity of content-length
  97. if (strlen($this->getBody()) !== (int)$contentLength) {
  98. throw new IncomingRequestException('inexact content-length in header');
  99. }
  100. // confirm digest value, based on body
  101. [$algo, ] = explode('=', $digest);
  102. try {
  103. $this->setDigestAlgorithm(DigestAlgorithm::from($algo));
  104. } catch (ValueError) {
  105. throw new IncomingRequestException('unknown digest algorithm');
  106. }
  107. if ($digest !== $this->getDigest()) {
  108. throw new IncomingRequestException('invalid value for digest in header');
  109. }
  110. }
  111. /**
  112. * extract data from the header entry 'Signature' and convert its content from string to an array
  113. * also confirm that it contains the minimum mandatory information
  114. *
  115. * @throws IncomingRequestException
  116. */
  117. private function extractSignatureHeader(): void {
  118. $details = [];
  119. foreach (explode(',', $this->request->getHeader('Signature')) as $entry) {
  120. if ($entry === '' || !strpos($entry, '=')) {
  121. continue;
  122. }
  123. [$k, $v] = explode('=', $entry, 2);
  124. preg_match('/^"([^"]+)"$/', $v, $var);
  125. if ($var[0] !== '') {
  126. $v = trim($var[0], '"');
  127. }
  128. $details[$k] = $v;
  129. }
  130. $this->setSigningElements($details);
  131. try {
  132. // confirm keys are in the Signature header
  133. $this->getSigningElement('keyId');
  134. $this->getSigningElement('headers');
  135. $this->setSignature($this->getSigningElement('signature'));
  136. } catch (SignatureElementNotFoundException $e) {
  137. throw new IncomingRequestException($e->getMessage());
  138. }
  139. }
  140. /**
  141. * reconstruct signature data based on signature's metadata stored in the 'Signature' header
  142. *
  143. * @throws SignatureException
  144. * @throws SignatureElementNotFoundException
  145. */
  146. private function reconstructSignatureData(): void {
  147. $usedHeaders = explode(' ', $this->getSigningElement('headers'));
  148. $neededHeaders = array_merge(['date', 'host', 'content-length', 'digest'],
  149. array_keys($this->options['extraSignatureHeaders'] ?? []));
  150. $missingHeaders = array_diff($neededHeaders, $usedHeaders);
  151. if ($missingHeaders !== []) {
  152. throw new SignatureException('missing entries in Signature.headers: ' . json_encode($missingHeaders));
  153. }
  154. $estimated = ['(request-target): ' . strtolower($this->request->getMethod()) . ' ' . $this->request->getRequestUri()];
  155. foreach ($usedHeaders as $key) {
  156. if ($key === '(request-target)') {
  157. continue;
  158. }
  159. $value = (strtolower($key) === 'host') ? $this->request->getServerHost() : $this->request->getHeader($key);
  160. if ($value === '') {
  161. throw new SignatureException('missing header ' . $key . ' in request');
  162. }
  163. $estimated[] = $key . ': ' . $value;
  164. }
  165. $this->setSignatureData($estimated);
  166. }
  167. /**
  168. * @inheritDoc
  169. *
  170. * @return IRequest
  171. * @since 31.0.0
  172. */
  173. public function getRequest(): IRequest {
  174. return $this->request;
  175. }
  176. /**
  177. * set the hostname at the source of the request,
  178. * based on the keyId defined in the signature header.
  179. *
  180. * @param string $origin
  181. * @since 31.0.0
  182. */
  183. private function setOrigin(string $origin): void {
  184. $this->origin = $origin;
  185. }
  186. /**
  187. * @inheritDoc
  188. *
  189. * @return string
  190. * @throws IncomingRequestException
  191. * @since 31.0.0
  192. */
  193. public function getOrigin(): string {
  194. if ($this->origin === '') {
  195. throw new IncomingRequestException('empty origin');
  196. }
  197. return $this->origin;
  198. }
  199. /**
  200. * returns the keyId extracted from the signature headers.
  201. * keyId is a mandatory entry in the headers of a signed request.
  202. *
  203. * @return string
  204. * @throws SignatureElementNotFoundException
  205. * @since 31.0.0
  206. */
  207. public function getKeyId(): string {
  208. return $this->getSigningElement('keyId');
  209. }
  210. /**
  211. * @inheritDoc
  212. *
  213. * @throws SignatureException
  214. * @throws SignatoryNotFoundException
  215. * @since 31.0.0
  216. */
  217. public function verify(): void {
  218. $publicKey = $this->getSignatory()->getPublicKey();
  219. if ($publicKey === '') {
  220. throw new SignatoryNotFoundException('empty public key');
  221. }
  222. $algorithm = SignatureAlgorithm::tryFrom($this->getSigningElement('algorithm')) ?? SignatureAlgorithm::RSA_SHA256;
  223. if (openssl_verify(
  224. implode("\n", $this->getSignatureData()),
  225. base64_decode($this->getSignature()),
  226. $publicKey,
  227. $algorithm->value
  228. ) !== 1) {
  229. throw new InvalidSignatureException('signature issue');
  230. }
  231. }
  232. public function jsonSerialize(): array {
  233. return array_merge(
  234. parent::jsonSerialize(),
  235. [
  236. 'options' => $this->options,
  237. 'origin' => $this->origin,
  238. ]
  239. );
  240. }
  241. }