diff options
Diffstat (limited to 'lib/private/Security/Signature/SignatureManager.php')
-rw-r--r-- | lib/private/Security/Signature/SignatureManager.php | 426 |
1 files changed, 426 insertions, 0 deletions
diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php new file mode 100644 index 00000000000..91a06e29b4a --- /dev/null +++ b/lib/private/Security/Signature/SignatureManager.php @@ -0,0 +1,426 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Security\Signature; + +use NCU\Security\Signature\Enum\SignatoryType; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\InvalidKeyOriginException; +use NCU\Security\Signature\Exceptions\InvalidSignatureException; +use NCU\Security\Signature\Exceptions\SignatoryConflictException; +use NCU\Security\Signature\Exceptions\SignatoryException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\IOutgoingSignedRequest; +use NCU\Security\Signature\ISignatoryManager; +use NCU\Security\Signature\ISignatureManager; +use NCU\Security\Signature\Model\Signatory; +use OC\Security\Signature\Db\SignatoryMapper; +use OC\Security\Signature\Model\IncomingSignedRequest; +use OC\Security\Signature\Model\OutgoingSignedRequest; +use OCP\DB\Exception as DBException; +use OCP\IAppConfig; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * ISignatureManager is a service integrated to core that provide tools + * to set/get authenticity of/from outgoing/incoming request. + * + * Quick description of the signature, added to the headers + * { + * "(request-target)": "post /path", + * "content-length": 385, + * "date": "Mon, 08 Jul 2024 14:16:20 GMT", + * "digest": "SHA-256=U7gNVUQiixe5BRbp4Tg0xCZMTcSWXXUZI2\\/xtHM40S0=", + * "host": "hostname.of.the.recipient", + * "Signature": "keyId=\"https://author.hostname/key\",algorithm=\"sha256\",headers=\"content-length + * date digest host\",signature=\"DzN12OCS1rsA[...]o0VmxjQooRo6HHabg==\"" + * } + * + * 'content-length' is the total length of the data/content + * 'date' is the datetime the request have been initiated + * 'digest' is a checksum of the data/content + * 'host' is the hostname of the recipient of the request (remote when signing outgoing request, local on + * incoming request) + * 'Signature' contains the signature generated using the private key, and metadata: + * - 'keyId' is a unique id, formatted as an url. hostname is used to retrieve the public key via custom + * discovery + * - 'algorithm' define the algorithm used to generate signature + * - 'headers' contains a list of element used during the generation of the signature + * - 'signature' is the encrypted string, using local private key, of an array containing elements + * listed in 'headers' and their value. Some elements (content-length date digest host) are mandatory + * to ensure authenticity override protection. + * + * @since 31.0.0 + */ +class SignatureManager implements ISignatureManager { + public const DATE_HEADER = 'D, d M Y H:i:s T'; + public const DATE_TTL = 300; + public const SIGNATORY_TTL = 86400 * 3; + public const BODY_MAXSIZE = 50000; // max size of the payload of the request + public const APPCONFIG_IDENTITY = 'security.signature.identity'; + + public function __construct( + private readonly IRequest $request, + private readonly SignatoryMapper $mapper, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + } + + /** + * @inheritDoc + * + * @param ISignatoryManager $signatoryManager used to get details about remote instance + * @param string|null $body if NULL, body will be extracted from php://input + * + * @return IIncomingSignedRequest + * @throws IncomingRequestException if anything looks wrong with the incoming request + * @throws SignatureNotFoundException if incoming request is not signed + * @throws SignatureException if signature could not be confirmed + * @since 31.0.0 + */ + public function getIncomingSignedRequest( + ISignatoryManager $signatoryManager, + ?string $body = null, + ): IIncomingSignedRequest { + $body = $body ?? file_get_contents('php://input'); + $options = $signatoryManager->getOptions(); + if (strlen($body) > ($options['bodyMaxSize'] ?? self::BODY_MAXSIZE)) { + throw new IncomingRequestException('content of request is too big'); + } + + // generate IncomingSignedRequest based on body and request + $signedRequest = new IncomingSignedRequest($body, $this->request, $options); + + try { + // confirm the validity of content and identity of the incoming request + $this->confirmIncomingRequestSignature($signedRequest, $signatoryManager, $options['ttlSignatory'] ?? self::SIGNATORY_TTL); + } catch (SignatureException $e) { + $this->logger->warning( + 'signature could not be verified', [ + 'exception' => $e, + 'signedRequest' => $signedRequest, + 'signatoryManager' => get_class($signatoryManager) + ] + ); + throw $e; + } + + return $signedRequest; + } + + /** + * confirm that the Signature is signed using the correct private key, using + * clear version of the Signature and the public key linked to the keyId + * + * @param IIncomingSignedRequest $signedRequest + * @param ISignatoryManager $signatoryManager + * + * @throws SignatoryNotFoundException + * @throws SignatureException + */ + private function confirmIncomingRequestSignature( + IIncomingSignedRequest $signedRequest, + ISignatoryManager $signatoryManager, + int $ttlSignatory, + ): void { + $knownSignatory = null; + try { + $knownSignatory = $this->getStoredSignatory($signedRequest->getKeyId()); + // refreshing ttl and compare with previous public key + if ($ttlSignatory > 0 && $knownSignatory->getLastUpdated() < (time() - $ttlSignatory)) { + $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest); + $this->updateSignatoryMetadata($signatory); + $knownSignatory->setMetadata($signatory->getMetadata() ?? []); + } + + $signedRequest->setSignatory($knownSignatory); + $signedRequest->verify(); + } catch (InvalidKeyOriginException $e) { + throw $e; // issue while requesting remote instance also means there is no 2nd try + } catch (SignatoryNotFoundException) { + // if no signatory in cache, we retrieve the one from the remote instance (using + // $signatoryManager), check its validity with current signature and store it + $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest); + $signedRequest->setSignatory($signatory); + $signedRequest->verify(); + $this->storeSignatory($signatory); + } catch (SignatureException) { + // if public key (from cache) is not valid, we try to refresh it (based on SignatoryType) + try { + $signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest); + } catch (SignatoryNotFoundException $e) { + $this->manageDeprecatedSignatory($knownSignatory); + throw $e; + } + + $signedRequest->setSignatory($signatory); + try { + $signedRequest->verify(); + } catch (InvalidSignatureException $e) { + $this->logger->debug('signature issue', ['signed' => $signedRequest, 'exception' => $e]); + throw $e; + } + + $this->storeSignatory($signatory); + } + } + + /** + * @inheritDoc + * + * @param ISignatoryManager $signatoryManager + * @param string $content body to be signed + * @param string $method needed in the signature + * @param string $uri needed in the signature + * + * @return IOutgoingSignedRequest + * @throws IdentityNotFoundException + * @throws SignatoryException + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function getOutgoingSignedRequest( + ISignatoryManager $signatoryManager, + string $content, + string $method, + string $uri, + ): IOutgoingSignedRequest { + $signedRequest = new OutgoingSignedRequest( + $content, + $signatoryManager, + $this->extractIdentityFromUri($uri), + $method, + parse_url($uri, PHP_URL_PATH) ?? '/' + ); + + $signedRequest->sign(); + + return $signedRequest; + } + + /** + * @inheritDoc + * + * @param ISignatoryManager $signatoryManager + * @param array $payload original payload, will be used to sign and completed with new headers with + * signature elements + * @param string $method needed in the signature + * @param string $uri needed in the signature + * + * @return array new payload to be sent, including original payload and signature elements in headers + * @since 31.0.0 + */ + public function signOutgoingRequestIClientPayload( + ISignatoryManager $signatoryManager, + array $payload, + string $method, + string $uri, + ): array { + $signedRequest = $this->getOutgoingSignedRequest($signatoryManager, $payload['body'], $method, $uri); + $payload['headers'] = array_merge($payload['headers'], $signedRequest->getHeaders()); + + return $payload; + } + + /** + * @inheritDoc + * + * @param string $host remote host + * @param string $account linked account, should be used when multiple signature can exist for the same + * host + * + * @return Signatory + * @throws SignatoryNotFoundException if entry does not exist in local database + * @since 31.0.0 + */ + public function getSignatory(string $host, string $account = ''): Signatory { + return $this->mapper->getByHost($host, $account); + } + + + /** + * @inheritDoc + * + * keyId is set using app config 'core/security.signature.identity' + * + * @param string $path + * + * @return string + * @throws IdentityNotFoundException is identity is not set in app config + * @since 31.0.0 + */ + public function generateKeyIdFromConfig(string $path): string { + if (!$this->appConfig->hasKey('core', self::APPCONFIG_IDENTITY, true)) { + throw new IdentityNotFoundException(self::APPCONFIG_IDENTITY . ' not set'); + } + + $identity = trim($this->appConfig->getValueString('core', self::APPCONFIG_IDENTITY, lazy: true), '/'); + + return 'https://' . $identity . '/' . ltrim($path, '/'); + } + + /** + * @inheritDoc + * + * @param string $uri + * + * @return string + * @throws IdentityNotFoundException if identity cannot be extracted + * @since 31.0.0 + */ + public function extractIdentityFromUri(string $uri): string { + return Signatory::extractIdentityFromUri($uri); + } + + /** + * get remote signatory using the ISignatoryManager + * and confirm the validity of the keyId + * + * @param ISignatoryManager $signatoryManager + * @param IIncomingSignedRequest $signedRequest + * + * @return Signatory + * @throws InvalidKeyOriginException + * @throws SignatoryNotFoundException + * @see ISignatoryManager::getRemoteSignatory + */ + private function getSaneRemoteSignatory( + ISignatoryManager $signatoryManager, + IIncomingSignedRequest $signedRequest, + ): Signatory { + $signatory = $signatoryManager->getRemoteSignatory($signedRequest->getOrigin()); + if ($signatory === null) { + throw new SignatoryNotFoundException('empty result from getRemoteSignatory'); + } + try { + if ($signatory->getKeyId() !== $signedRequest->getKeyId()) { + throw new InvalidKeyOriginException('keyId from signatory not related to the one from request'); + } + } catch (SignatureElementNotFoundException) { + throw new InvalidKeyOriginException('missing keyId'); + } + $signatory->setProviderId($signatoryManager->getProviderId()); + + return $signatory; + } + + /** + * @param string $keyId + * + * @return Signatory + * @throws SignatoryNotFoundException + */ + private function getStoredSignatory(string $keyId): Signatory { + return $this->mapper->getByKeyId($keyId); + } + + /** + * @param Signatory $signatory + */ + private function storeSignatory(Signatory $signatory): void { + try { + $this->insertSignatory($signatory); + } catch (DBException $e) { + if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + $this->logger->warning('exception while storing signature', ['exception' => $e]); + throw $e; + } + + try { + $this->updateKnownSignatory($signatory); + } catch (SignatoryNotFoundException $e) { + $this->logger->warning('strange behavior, signatory not found ?', ['exception' => $e]); + } + } + } + + /** + * @param Signatory $signatory + */ + private function insertSignatory(Signatory $signatory): void { + $time = time(); + $signatory->setCreation($time); + $signatory->setLastUpdated($time); + $signatory->setMetadata($signatory->getMetadata() ?? []); // trigger insert on field metadata using current or default value + $this->mapper->insert($signatory); + } + + /** + * @param Signatory $signatory + * + * @throws SignatoryNotFoundException + * @throws SignatoryConflictException + */ + private function updateKnownSignatory(Signatory $signatory): void { + $knownSignatory = $this->getStoredSignatory($signatory->getKeyId()); + switch ($signatory->getType()) { + case SignatoryType::FORGIVABLE: + $this->deleteSignatory($knownSignatory->getKeyId()); + $this->insertSignatory($signatory); + return; + + case SignatoryType::REFRESHABLE: + $this->updateSignatoryPublicKey($signatory); + $this->updateSignatoryMetadata($signatory); + break; + + case SignatoryType::TRUSTED: + // TODO: send notice to admin + throw new SignatoryConflictException(); + + case SignatoryType::STATIC: + // TODO: send warning to admin + throw new SignatoryConflictException(); + } + } + + /** + * This is called when a remote signatory does not exist anymore + * + * @param Signatory|null $knownSignatory NULL is not known + * + * @throws SignatoryConflictException + * @throws SignatoryNotFoundException + */ + private function manageDeprecatedSignatory(?Signatory $knownSignatory): void { + switch ($knownSignatory?->getType()) { + case null: // unknown in local database + case SignatoryType::FORGIVABLE: // who cares ? + throw new SignatoryNotFoundException(); // meaning we just return the correct exception + + case SignatoryType::REFRESHABLE: + // TODO: send notice to admin + throw new SignatoryConflictException(); // while it can be refreshed, it must exist + + case SignatoryType::TRUSTED: + case SignatoryType::STATIC: + // TODO: send warning to admin + throw new SignatoryConflictException(); // no way. + } + } + + + private function updateSignatoryPublicKey(Signatory $signatory): void { + $this->mapper->updatePublicKey($signatory); + } + + private function updateSignatoryMetadata(Signatory $signatory): void { + $this->mapper->updateMetadata($signatory); + } + + private function deleteSignatory(string $keyId): void { + $this->mapper->deleteByKeyId($keyId); + } +} |