123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- <?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);
- }
- }
|