1
0

SignatureManager.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  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;
  8. use NCU\Security\Signature\Enum\SignatoryType;
  9. use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
  10. use NCU\Security\Signature\Exceptions\IncomingRequestException;
  11. use NCU\Security\Signature\Exceptions\InvalidKeyOriginException;
  12. use NCU\Security\Signature\Exceptions\InvalidSignatureException;
  13. use NCU\Security\Signature\Exceptions\SignatoryConflictException;
  14. use NCU\Security\Signature\Exceptions\SignatoryException;
  15. use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
  16. use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException;
  17. use NCU\Security\Signature\Exceptions\SignatureException;
  18. use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
  19. use NCU\Security\Signature\IIncomingSignedRequest;
  20. use NCU\Security\Signature\IOutgoingSignedRequest;
  21. use NCU\Security\Signature\ISignatoryManager;
  22. use NCU\Security\Signature\ISignatureManager;
  23. use NCU\Security\Signature\Model\Signatory;
  24. use OC\Security\Signature\Db\SignatoryMapper;
  25. use OC\Security\Signature\Model\IncomingSignedRequest;
  26. use OC\Security\Signature\Model\OutgoingSignedRequest;
  27. use OCP\DB\Exception as DBException;
  28. use OCP\IAppConfig;
  29. use OCP\IRequest;
  30. use Psr\Log\LoggerInterface;
  31. /**
  32. * ISignatureManager is a service integrated to core that provide tools
  33. * to set/get authenticity of/from outgoing/incoming request.
  34. *
  35. * Quick description of the signature, added to the headers
  36. * {
  37. * "(request-target)": "post /path",
  38. * "content-length": 385,
  39. * "date": "Mon, 08 Jul 2024 14:16:20 GMT",
  40. * "digest": "SHA-256=U7gNVUQiixe5BRbp4Tg0xCZMTcSWXXUZI2\\/xtHM40S0=",
  41. * "host": "hostname.of.the.recipient",
  42. * "Signature": "keyId=\"https://author.hostname/key\",algorithm=\"sha256\",headers=\"content-length
  43. * date digest host\",signature=\"DzN12OCS1rsA[...]o0VmxjQooRo6HHabg==\""
  44. * }
  45. *
  46. * 'content-length' is the total length of the data/content
  47. * 'date' is the datetime the request have been initiated
  48. * 'digest' is a checksum of the data/content
  49. * 'host' is the hostname of the recipient of the request (remote when signing outgoing request, local on
  50. * incoming request)
  51. * 'Signature' contains the signature generated using the private key, and metadata:
  52. * - 'keyId' is a unique id, formatted as an url. hostname is used to retrieve the public key via custom
  53. * discovery
  54. * - 'algorithm' define the algorithm used to generate signature
  55. * - 'headers' contains a list of element used during the generation of the signature
  56. * - 'signature' is the encrypted string, using local private key, of an array containing elements
  57. * listed in 'headers' and their value. Some elements (content-length date digest host) are mandatory
  58. * to ensure authenticity override protection.
  59. *
  60. * @since 31.0.0
  61. */
  62. class SignatureManager implements ISignatureManager {
  63. public const DATE_HEADER = 'D, d M Y H:i:s T';
  64. public const DATE_TTL = 300;
  65. public const SIGNATORY_TTL = 86400 * 3;
  66. public const BODY_MAXSIZE = 50000; // max size of the payload of the request
  67. public const APPCONFIG_IDENTITY = 'security.signature.identity';
  68. public function __construct(
  69. private readonly IRequest $request,
  70. private readonly SignatoryMapper $mapper,
  71. private readonly IAppConfig $appConfig,
  72. private readonly LoggerInterface $logger,
  73. ) {
  74. }
  75. /**
  76. * @inheritDoc
  77. *
  78. * @param ISignatoryManager $signatoryManager used to get details about remote instance
  79. * @param string|null $body if NULL, body will be extracted from php://input
  80. *
  81. * @return IIncomingSignedRequest
  82. * @throws IncomingRequestException if anything looks wrong with the incoming request
  83. * @throws SignatureNotFoundException if incoming request is not signed
  84. * @throws SignatureException if signature could not be confirmed
  85. * @since 31.0.0
  86. */
  87. public function getIncomingSignedRequest(
  88. ISignatoryManager $signatoryManager,
  89. ?string $body = null,
  90. ): IIncomingSignedRequest {
  91. $body = $body ?? file_get_contents('php://input');
  92. $options = $signatoryManager->getOptions();
  93. if (strlen($body) > ($options['bodyMaxSize'] ?? self::BODY_MAXSIZE)) {
  94. throw new IncomingRequestException('content of request is too big');
  95. }
  96. // generate IncomingSignedRequest based on body and request
  97. $signedRequest = new IncomingSignedRequest($body, $this->request, $options);
  98. try {
  99. // confirm the validity of content and identity of the incoming request
  100. $this->confirmIncomingRequestSignature($signedRequest, $signatoryManager, $options['ttlSignatory'] ?? self::SIGNATORY_TTL);
  101. } catch (SignatureException $e) {
  102. $this->logger->warning(
  103. 'signature could not be verified', [
  104. 'exception' => $e,
  105. 'signedRequest' => $signedRequest,
  106. 'signatoryManager' => get_class($signatoryManager)
  107. ]
  108. );
  109. throw $e;
  110. }
  111. return $signedRequest;
  112. }
  113. /**
  114. * confirm that the Signature is signed using the correct private key, using
  115. * clear version of the Signature and the public key linked to the keyId
  116. *
  117. * @param IIncomingSignedRequest $signedRequest
  118. * @param ISignatoryManager $signatoryManager
  119. *
  120. * @throws SignatoryNotFoundException
  121. * @throws SignatureException
  122. */
  123. private function confirmIncomingRequestSignature(
  124. IIncomingSignedRequest $signedRequest,
  125. ISignatoryManager $signatoryManager,
  126. int $ttlSignatory,
  127. ): void {
  128. $knownSignatory = null;
  129. try {
  130. $knownSignatory = $this->getStoredSignatory($signedRequest->getKeyId());
  131. // refreshing ttl and compare with previous public key
  132. if ($ttlSignatory > 0 && $knownSignatory->getLastUpdated() < (time() - $ttlSignatory)) {
  133. $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest);
  134. $this->updateSignatoryMetadata($signatory);
  135. $knownSignatory->setMetadata($signatory->getMetadata() ?? []);
  136. }
  137. $signedRequest->setSignatory($knownSignatory);
  138. $signedRequest->verify();
  139. } catch (InvalidKeyOriginException $e) {
  140. throw $e; // issue while requesting remote instance also means there is no 2nd try
  141. } catch (SignatoryNotFoundException) {
  142. // if no signatory in cache, we retrieve the one from the remote instance (using
  143. // $signatoryManager), check its validity with current signature and store it
  144. $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest);
  145. $signedRequest->setSignatory($signatory);
  146. $signedRequest->verify();
  147. $this->storeSignatory($signatory);
  148. } catch (SignatureException) {
  149. // if public key (from cache) is not valid, we try to refresh it (based on SignatoryType)
  150. try {
  151. $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest);
  152. } catch (SignatoryNotFoundException $e) {
  153. $this->manageDeprecatedSignatory($knownSignatory);
  154. throw $e;
  155. }
  156. $signedRequest->setSignatory($signatory);
  157. try {
  158. $signedRequest->verify();
  159. } catch (InvalidSignatureException $e) {
  160. $this->logger->debug('signature issue', ['signed' => $signedRequest, 'exception' => $e]);
  161. throw $e;
  162. }
  163. $this->storeSignatory($signatory);
  164. }
  165. }
  166. /**
  167. * @inheritDoc
  168. *
  169. * @param ISignatoryManager $signatoryManager
  170. * @param string $content body to be signed
  171. * @param string $method needed in the signature
  172. * @param string $uri needed in the signature
  173. *
  174. * @return IOutgoingSignedRequest
  175. * @throws IdentityNotFoundException
  176. * @throws SignatoryException
  177. * @throws SignatoryNotFoundException
  178. * @since 31.0.0
  179. */
  180. public function getOutgoingSignedRequest(
  181. ISignatoryManager $signatoryManager,
  182. string $content,
  183. string $method,
  184. string $uri,
  185. ): IOutgoingSignedRequest {
  186. $signedRequest = new OutgoingSignedRequest(
  187. $content,
  188. $signatoryManager,
  189. $this->extractIdentityFromUri($uri),
  190. $method,
  191. parse_url($uri, PHP_URL_PATH) ?? '/'
  192. );
  193. $signedRequest->sign();
  194. return $signedRequest;
  195. }
  196. /**
  197. * @inheritDoc
  198. *
  199. * @param ISignatoryManager $signatoryManager
  200. * @param array $payload original payload, will be used to sign and completed with new headers with
  201. * signature elements
  202. * @param string $method needed in the signature
  203. * @param string $uri needed in the signature
  204. *
  205. * @return array new payload to be sent, including original payload and signature elements in headers
  206. * @since 31.0.0
  207. */
  208. public function signOutgoingRequestIClientPayload(
  209. ISignatoryManager $signatoryManager,
  210. array $payload,
  211. string $method,
  212. string $uri,
  213. ): array {
  214. $signedRequest = $this->getOutgoingSignedRequest($signatoryManager, $payload['body'], $method, $uri);
  215. $payload['headers'] = array_merge($payload['headers'], $signedRequest->getHeaders());
  216. return $payload;
  217. }
  218. /**
  219. * @inheritDoc
  220. *
  221. * @param string $host remote host
  222. * @param string $account linked account, should be used when multiple signature can exist for the same
  223. * host
  224. *
  225. * @return Signatory
  226. * @throws SignatoryNotFoundException if entry does not exist in local database
  227. * @since 31.0.0
  228. */
  229. public function getSignatory(string $host, string $account = ''): Signatory {
  230. return $this->mapper->getByHost($host, $account);
  231. }
  232. /**
  233. * @inheritDoc
  234. *
  235. * keyId is set using app config 'core/security.signature.identity'
  236. *
  237. * @param string $path
  238. *
  239. * @return string
  240. * @throws IdentityNotFoundException is identity is not set in app config
  241. * @since 31.0.0
  242. */
  243. public function generateKeyIdFromConfig(string $path): string {
  244. if (!$this->appConfig->hasKey('core', self::APPCONFIG_IDENTITY, true)) {
  245. throw new IdentityNotFoundException(self::APPCONFIG_IDENTITY . ' not set');
  246. }
  247. $identity = trim($this->appConfig->getValueString('core', self::APPCONFIG_IDENTITY, lazy: true), '/');
  248. return 'https://' . $identity . '/' . ltrim($path, '/');
  249. }
  250. /**
  251. * @inheritDoc
  252. *
  253. * @param string $uri
  254. *
  255. * @return string
  256. * @throws IdentityNotFoundException if identity cannot be extracted
  257. * @since 31.0.0
  258. */
  259. public function extractIdentityFromUri(string $uri): string {
  260. return Signatory::extractIdentityFromUri($uri);
  261. }
  262. /**
  263. * get remote signatory using the ISignatoryManager
  264. * and confirm the validity of the keyId
  265. *
  266. * @param ISignatoryManager $signatoryManager
  267. * @param IIncomingSignedRequest $signedRequest
  268. *
  269. * @return Signatory
  270. * @throws InvalidKeyOriginException
  271. * @throws SignatoryNotFoundException
  272. * @see ISignatoryManager::getRemoteSignatory
  273. */
  274. private function getSaneRemoteSignatory(
  275. ISignatoryManager $signatoryManager,
  276. IIncomingSignedRequest $signedRequest,
  277. ): Signatory {
  278. $signatory = $signatoryManager->getRemoteSignatory($signedRequest->getOrigin());
  279. if ($signatory === null) {
  280. throw new SignatoryNotFoundException('empty result from getRemoteSignatory');
  281. }
  282. try {
  283. if ($signatory->getKeyId() !== $signedRequest->getKeyId()) {
  284. throw new InvalidKeyOriginException('keyId from signatory not related to the one from request');
  285. }
  286. } catch (SignatureElementNotFoundException) {
  287. throw new InvalidKeyOriginException('missing keyId');
  288. }
  289. $signatory->setProviderId($signatoryManager->getProviderId());
  290. return $signatory;
  291. }
  292. /**
  293. * @param string $keyId
  294. *
  295. * @return Signatory
  296. * @throws SignatoryNotFoundException
  297. */
  298. private function getStoredSignatory(string $keyId): Signatory {
  299. return $this->mapper->getByKeyId($keyId);
  300. }
  301. /**
  302. * @param Signatory $signatory
  303. */
  304. private function storeSignatory(Signatory $signatory): void {
  305. try {
  306. $this->insertSignatory($signatory);
  307. } catch (DBException $e) {
  308. if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
  309. $this->logger->warning('exception while storing signature', ['exception' => $e]);
  310. throw $e;
  311. }
  312. try {
  313. $this->updateKnownSignatory($signatory);
  314. } catch (SignatoryNotFoundException $e) {
  315. $this->logger->warning('strange behavior, signatory not found ?', ['exception' => $e]);
  316. }
  317. }
  318. }
  319. /**
  320. * @param Signatory $signatory
  321. */
  322. private function insertSignatory(Signatory $signatory): void {
  323. $time = time();
  324. $signatory->setCreation($time);
  325. $signatory->setLastUpdated($time);
  326. $signatory->setMetadata($signatory->getMetadata() ?? []); // trigger insert on field metadata using current or default value
  327. $this->mapper->insert($signatory);
  328. }
  329. /**
  330. * @param Signatory $signatory
  331. *
  332. * @throws SignatoryNotFoundException
  333. * @throws SignatoryConflictException
  334. */
  335. private function updateKnownSignatory(Signatory $signatory): void {
  336. $knownSignatory = $this->getStoredSignatory($signatory->getKeyId());
  337. switch ($signatory->getType()) {
  338. case SignatoryType::FORGIVABLE:
  339. $this->deleteSignatory($knownSignatory->getKeyId());
  340. $this->insertSignatory($signatory);
  341. return;
  342. case SignatoryType::REFRESHABLE:
  343. $this->updateSignatoryPublicKey($signatory);
  344. $this->updateSignatoryMetadata($signatory);
  345. break;
  346. case SignatoryType::TRUSTED:
  347. // TODO: send notice to admin
  348. throw new SignatoryConflictException();
  349. case SignatoryType::STATIC:
  350. // TODO: send warning to admin
  351. throw new SignatoryConflictException();
  352. }
  353. }
  354. /**
  355. * This is called when a remote signatory does not exist anymore
  356. *
  357. * @param Signatory|null $knownSignatory NULL is not known
  358. *
  359. * @throws SignatoryConflictException
  360. * @throws SignatoryNotFoundException
  361. */
  362. private function manageDeprecatedSignatory(?Signatory $knownSignatory): void {
  363. switch ($knownSignatory?->getType()) {
  364. case null: // unknown in local database
  365. case SignatoryType::FORGIVABLE: // who cares ?
  366. throw new SignatoryNotFoundException(); // meaning we just return the correct exception
  367. case SignatoryType::REFRESHABLE:
  368. // TODO: send notice to admin
  369. throw new SignatoryConflictException(); // while it can be refreshed, it must exist
  370. case SignatoryType::TRUSTED:
  371. case SignatoryType::STATIC:
  372. // TODO: send warning to admin
  373. throw new SignatoryConflictException(); // no way.
  374. }
  375. }
  376. private function updateSignatoryPublicKey(Signatory $signatory): void {
  377. $this->mapper->updatePublicKey($signatory);
  378. }
  379. private function updateSignatoryMetadata(Signatory $signatory): void {
  380. $this->mapper->updateMetadata($signatory);
  381. }
  382. private function deleteSignatory(string $keyId): void {
  383. $this->mapper->deleteByKeyId($keyId);
  384. }
  385. }