diff --git a/apps/cloud_federation_api/lib/Capabilities.php b/apps/cloud_federation_api/lib/Capabilities.php index ca4ea928cb823..08806caa5e3e3 100644 --- a/apps/cloud_federation_api/lib/Capabilities.php +++ b/apps/cloud_federation_api/lib/Capabilities.php @@ -6,20 +6,27 @@ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\CloudFederationAPI; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\SignatoryException; +use OC\OCM\OCMSignatoryManager; use OCP\Capabilities\ICapability; +use OCP\IAppConfig; use OCP\IURLGenerator; use OCP\OCM\Exceptions\OCMArgumentException; use OCP\OCM\IOCMProvider; +use Psr\Log\LoggerInterface; class Capabilities implements ICapability { - public const API_VERSION = '1.0-proposal1'; + public const API_VERSION = '1.1'; // informative, real version. public function __construct( private IURLGenerator $urlGenerator, + private IAppConfig $appConfig, private IOCMProvider $provider, + private readonly OCMSignatoryManager $ocmSignatoryManager, + private readonly LoggerInterface $logger, ) { } @@ -28,15 +35,20 @@ public function __construct( * * @return array{ * ocm: array{ + * apiVersion: '1.0-proposal1', * enabled: bool, - * apiVersion: string, * endPoint: string, + * publicKey: array{ + * keyId: string, + * publicKeyPem: string, + * }, * resourceTypes: list, * protocols: array - * }>, - * }, + * }>, + * version: string + * } * } * @throws OCMArgumentException */ @@ -60,6 +72,17 @@ public function getCapabilities() { $this->provider->addResourceType($resource); - return ['ocm' => $this->provider->jsonSerialize()]; + // Adding a public key to the ocm discovery + try { + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $this->provider->setSignatory($this->ocmSignatoryManager->getLocalSignatory()); + } else { + $this->logger->debug('ocm public key feature disabled'); + } + } catch (SignatoryException|IdentityNotFoundException $e) { + $this->logger->warning('cannot generate local signatory', ['exception' => $e]); + } + + return ['ocm' => json_decode(json_encode($this->provider->jsonSerialize()), true)]; } } diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index a7b17f010cee9..a243d286c71b2 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -5,8 +5,17 @@ */ namespace OCA\CloudFederationAPI\Controller; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\ISignatureManager; +use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\ResponseDefinitions; +use OCA\FederatedFileSharing\AddressHandler; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\BruteForceProtection; @@ -22,11 +31,14 @@ use OCP\Federation\ICloudFederationFactory; use OCP\Federation\ICloudFederationProviderManager; use OCP\Federation\ICloudIdManager; +use OCP\IAppConfig; use OCP\IGroupManager; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IProviderFactory; +use OCP\Share\IShare; use OCP\Util; use Psr\Log\LoggerInterface; @@ -50,8 +62,13 @@ public function __construct( private IURLGenerator $urlGenerator, private ICloudFederationProviderManager $cloudFederationProviderManager, private Config $config, + private readonly AddressHandler $addressHandler, + private readonly IAppConfig $appConfig, private ICloudFederationFactory $factory, private ICloudIdManager $cloudIdManager, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private readonly IProviderFactory $shareProviderFactory, ) { parent::__construct($appName, $request); } @@ -81,11 +98,20 @@ public function __construct( #[NoCSRFRequired] #[BruteForceProtection(action: 'receiveFederatedShare')] public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) { + try { + // if request is signed and well signed, no exception are thrown + // if request is not signed and host is known for not supporting signed request, no exception are thrown + $signedRequest = $this->getSignedRequest(); + $this->confirmSignedOrigin($signedRequest, 'owner', $owner); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + // check if all required parameters are set if ($shareWith === null || $name === null || $providerId === null || - $owner === null || $resourceType === null || $shareType === null || !is_array($protocol) || @@ -208,6 +234,16 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $ #[PublicPage] #[BruteForceProtection(action: 'receiveFederatedShareNotification')] public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) { + try { + // if request is signed and well signed, no exception are thrown + // if request is not signed and host is known for not supporting signed request, no exception are thrown + $signedRequest = $this->getSignedRequest(); + $this->confirmShareOrigin($signedRequest, $notification['sharedSecret'] ?? ''); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + // check if all required parameters are set if ($notificationType === null || $resourceType === null || @@ -256,6 +292,7 @@ public function receiveNotification($notificationType, $resourceType, $providerI $response->throttle(); return $response; } catch (\Exception $e) { + $this->logger->warning('incoming notification exception', ['exception' => $e]); return new JSONResponse( [ 'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(), @@ -286,4 +323,144 @@ private function mapUid($uid) { return $uid; } + + + /** + * returns signed request if available. + * throw an exception: + * - if request is signed, but wrongly signed + * - if request is not signed but instance is configured to only accept signed ocm request + * + * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request + * @throws IncomingRequestException + */ + private function getSignedRequest(): ?IIncomingSignedRequest { + try { + $signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager); + $this->logger->debug('signed request available', ['signedRequest' => $signedRequest]); + return $signedRequest; + } catch (SignatureNotFoundException|SignatoryNotFoundException $e) { + $this->logger->debug('remote does not support signed request', ['exception' => $e]); + // remote does not support signed request. + // currently we still accept unsigned request until lazy appconfig + // core.enforce_signed_ocm_request is set to true (default: false) + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { + $this->logger->notice('ignored unsigned request', ['exception' => $e]); + throw new IncomingRequestException('Unsigned request'); + } + } catch (SignatureException $e) { + $this->logger->warning('wrongly signed request', ['exception' => $e]); + throw new IncomingRequestException('Invalid signature'); + } + return null; + } + + + /** + * confirm that the value related to $key entry from the payload is in format userid@hostname + * and compare hostname with the origin of the signed request. + * + * If request is not signed, we still verify that the hostname from the extracted value does, + * actually, not support signed request + * + * @param IIncomingSignedRequest|null $signedRequest + * @param string $key entry from data available in data + * @param string $value value itself used in case request is not signed + * + * @throws IncomingRequestException + */ + private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void { + if ($signedRequest === null) { + $instance = $this->getHostFromFederationId($value); + try { + $this->signatureManager->getSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } + + $body = json_decode($signedRequest->getBody(), true) ?? []; + $entry = trim($body[$key] ?? '', '@'); + if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']'); + } + } + + + /** + * confirm that the value related to share token is in format userid@hostname + * and compare hostname with the origin of the signed request. + * + * If request is not signed, we still verify that the hostname from the extracted value does, + * actually, not support signed request + * + * @param IIncomingSignedRequest|null $signedRequest + * @param string $token + * + * @throws IncomingRequestException + */ + private function confirmShareOrigin(?IIncomingSignedRequest $signedRequest, string $token): void { + if ($token === '') { + throw new BadRequestException(['sharedSecret']); + } + + $provider = $this->shareProviderFactory->getProviderForType(IShare::TYPE_REMOTE); + $share = $provider->getShareByToken($token); + try { + $this->confirmShareEntry($signedRequest, $share->getSharedWith()); + } catch (IncomingRequestException $e) { + // notification might come from the instance that owns the share + $this->logger->debug('could not confirm origin on sharedWith (' . $share->getSharedWIth() . '); going with shareOwner (' . $share->getShareOwner() . ')', ['exception' => $e]); + try { + $this->confirmShareEntry($signedRequest, $share->getShareOwner()); + } catch (IncomingRequestException $f) { + // if both entry are failing, we log first exception as warning and second exception + // will be logged as warning by the controller + $this->logger->warning('could not confirm origin on sharedWith (' . $share->getSharedWIth() . '); going with shareOwner (' . $share->getShareOwner() . ')', ['exception' => $e]); + throw $f; + } + } + } + + /** + * @param IIncomingSignedRequest|null $signedRequest + * @param string $entry + * + * @return void + * @throws IncomingRequestException + */ + private function confirmShareEntry(?IIncomingSignedRequest $signedRequest, string $entry): void { + $instance = $this->getHostFromFederationId($entry); + if ($signedRequest === null) { + try { + $this->signatureManager->getSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } elseif ($instance !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('token sharedWith (' . $instance . ') not linked to origin (' . $signedRequest->getOrigin() . ')'); + } + } + + /** + * @param string $entry + * @return string + * @throws IncomingRequestException + */ + private function getHostFromFederationId(string $entry): string { + if (!str_contains($entry, '@')) { + throw new IncomingRequestException('entry ' . $entry . ' does not contains @'); + } + $rightPart = substr($entry, strrpos($entry, '@') + 1); + + // in case the full scheme is sent; getting rid of it + $rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart); + try { + return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart); + } catch (IdentityNotFoundException) { + throw new IncomingRequestException('invalid host within federation id: ' . $entry); + } + } } diff --git a/apps/cloud_federation_api/openapi.json b/apps/cloud_federation_api/openapi.json index d15c7cef8130b..1c69ea2d08359 100644 --- a/apps/cloud_federation_api/openapi.json +++ b/apps/cloud_federation_api/openapi.json @@ -43,21 +43,41 @@ "ocm": { "type": "object", "required": [ - "enabled", "apiVersion", + "enabled", "endPoint", - "resourceTypes" + "publicKey", + "resourceTypes", + "version" ], "properties": { + "apiVersion": { + "type": "string", + "enum": [ + "1.0-proposal1" + ] + }, "enabled": { "type": "boolean" }, - "apiVersion": { - "type": "string" - }, "endPoint": { "type": "string" }, + "publicKey": { + "type": "object", + "required": [ + "keyId", + "publicKeyPem" + ], + "properties": { + "keyId": { + "type": "string" + }, + "publicKeyPem": { + "type": "string" + } + } + }, "resourceTypes": { "type": "array", "items": { @@ -85,6 +105,9 @@ } } } + }, + "version": { + "type": "string" } } } diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php index 11e78f5cb97f6..139c873b0d6e4 100644 --- a/apps/federatedfilesharing/lib/FederatedShareProvider.php +++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php @@ -250,7 +250,8 @@ protected function askOwnerToReShare($shareWith, IShare $share, $shareId) { $remote, $shareWith, $share->getPermissions(), - $share->getNode()->getName() + $share->getNode()->getName(), + $share->getShareType(), ); return [$token, $remoteId]; diff --git a/apps/federatedfilesharing/lib/Notifications.php b/apps/federatedfilesharing/lib/Notifications.php index 3c97177358788..3a111ae0ed0e3 100644 --- a/apps/federatedfilesharing/lib/Notifications.php +++ b/apps/federatedfilesharing/lib/Notifications.php @@ -108,12 +108,13 @@ public function sendRemoteShare($token, $shareWith, $name, $remoteId, $owner, $o * @throws HintException * @throws \OC\ServerNotAvailableException */ - public function requestReShare($token, $id, $shareId, $remote, $shareWith, $permission, $filename) { + public function requestReShare($token, $id, $shareId, $remote, $shareWith, $permission, $filename, $shareType) { $fields = [ 'shareWith' => $shareWith, 'token' => $token, 'permission' => $permission, 'remoteId' => $shareId, + 'shareType' => $shareType, ]; $ocmFields = $fields; diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php index dbf7d2af6e5ce..bacd2b3f7cf99 100644 --- a/apps/files_sharing/lib/External/Storage.php +++ b/apps/files_sharing/lib/External/Storage.php @@ -88,6 +88,7 @@ public function __construct($options) { parent::__construct( [ 'secure' => ((parse_url($remote, PHP_URL_SCHEME) ?? 'https') === 'https'), + 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false), 'host' => $host, 'root' => $webDavEndpoint, 'user' => $options['token'], diff --git a/build/integration/features/bootstrap/FederationContext.php b/build/integration/features/bootstrap/FederationContext.php index e146b46644cda..bbd81396df56a 100644 --- a/build/integration/features/bootstrap/FederationContext.php +++ b/build/integration/features/bootstrap/FederationContext.php @@ -38,7 +38,7 @@ public function startFederatedServer() { $port = getenv('PORT_FED'); - self::$phpFederatedServerPid = exec('php -S localhost:' . $port . ' -t ../../ >/dev/null & echo $!'); + self::$phpFederatedServerPid = exec('PHP_CLI_SERVER_WORKERS=2 php -S localhost:' . $port . ' -t ../../ >/dev/null & echo $!'); } /** diff --git a/build/integration/federation_features/cleanup-remote-storage.feature b/build/integration/federation_features/cleanup-remote-storage.feature index 6339edb60b66e..a3585bdee96c2 100644 --- a/build/integration/federation_features/cleanup-remote-storage.feature +++ b/build/integration/federation_features/cleanup-remote-storage.feature @@ -4,6 +4,27 @@ Feature: cleanup-remote-storage Background: Given using api version "1" + Scenario: cleanup remote storage with no storage + Given Using server "LOCAL" + And user "user0" exists + Given Using server "REMOTE" + And user "user1" exists + # Rename file so it has a unique name in the target server (as the target + # server may have its own /textfile0.txt" file) + And User "user1" copies file "/textfile0.txt" to "/remote-share.txt" + And User "user1" from server "REMOTE" shares "/remote-share.txt" with user "user0" from server "LOCAL" + And As an "user1" + And Deleting last share + And the OCS status code should be "100" + And the HTTP status code should be "200" + And Deleting last share + And Using server "LOCAL" + When invoking occ with "sharing:cleanup-remote-storage" + Then the command was successful + And the command output contains the text "0 remote storage(s) need(s) to be checked" + And the command output contains the text "0 remote share(s) exist" + And the command output contains the text "no storages deleted" + Scenario: cleanup remote storage with active storages Given Using server "LOCAL" And user "user0" exists diff --git a/core/Controller/OCMController.php b/core/Controller/OCMController.php index 59529b66e121a..f15a4a5677996 100644 --- a/core/Controller/OCMController.php +++ b/core/Controller/OCMController.php @@ -17,7 +17,7 @@ use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\Capabilities\ICapability; -use OCP\IConfig; +use OCP\IAppConfig; use OCP\IRequest; use OCP\Server; use Psr\Container\ContainerExceptionInterface; @@ -31,7 +31,7 @@ class OCMController extends Controller { public function __construct( IRequest $request, - private IConfig $config, + private readonly IAppConfig $appConfig, private LoggerInterface $logger, ) { parent::__construct('core', $request); @@ -54,10 +54,10 @@ public function __construct( public function discovery(): DataResponse { try { $cap = Server::get( - $this->config->getAppValue( - 'core', - 'ocm_providers', - '\OCA\CloudFederationAPI\Capabilities' + $this->appConfig->getValueString( + 'core', 'ocm_providers', + \OCA\CloudFederationAPI\Capabilities::class, + lazy: true ) ); diff --git a/core/Migrations/Version31000Date20240101084401.php b/core/Migrations/Version31000Date20240101084401.php new file mode 100644 index 0000000000000..60792dcac2139 --- /dev/null +++ b/core/Migrations/Version31000Date20240101084401.php @@ -0,0 +1,135 @@ +hasTable('sec_signatory')) { + $table = $schema->createTable('sec_signatory'); + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 64, + 'autoincrement' => true, + 'unsigned' => true, + ]); + // key_id_sum will store a hash version of the key_id, more appropriate for search/index + $table->addColumn('key_id_sum', Types::STRING, [ + 'notnull' => true, + 'length' => 127, + ]); + $table->addColumn('key_id', Types::STRING, [ + 'notnull' => true, + 'length' => 512 + ]); + // host/provider_id/account will help generate a unique entry, not based on key_id + // this way, a spoofed instance cannot publish a new key_id for same host+provider_id + // account will be used only to stored multiple keys for the same provider_id/host + $table->addColumn('host', Types::STRING, [ + 'notnull' => true, + 'length' => 512 + ]); + $table->addColumn('provider_id', Types::STRING, [ + 'notnull' => true, + 'length' => 31, + ]); + $table->addColumn('account', Types::STRING, [ + 'notnull' => false, + 'length' => 127, + 'default' => '' + ]); + $table->addColumn('public_key', Types::TEXT, [ + 'notnull' => true, + 'default' => '' + ]); + $table->addColumn('metadata', Types::TEXT, [ + 'notnull' => true, + 'default' => '[]' + ]); + // type+status are informative about the trustability of remote instance and status of the signatory + $table->addColumn('type', Types::SMALLINT, [ + 'notnull' => true, + 'length' => 2, + 'default' => 9 + ]); + $table->addColumn('status', Types::SMALLINT, [ + 'notnull' => true, + 'length' => 2, + 'default' => 0, + ]); + $table->addColumn('creation', Types::INTEGER, [ + 'notnull' => false, + 'length' => 4, + 'default' => 0, + 'unsigned' => true, + ]); + $table->addColumn('last_updated', Types::INTEGER, [ + 'notnull' => false, + 'length' => 4, + 'default' => 0, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id'], 'sec_sig_id'); + $table->addUniqueIndex(['provider_id', 'host', 'account'], 'sec_sig_unic'); + $table->addIndex(['key_id_sum', 'provider_id'], 'sec_sig_key'); + + return $schema; + } + + return null; + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index cc6b8be326c49..b3d4c92220cac 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -12,6 +12,25 @@ 'NCU\\Config\\Exceptions\\UnknownKeyException' => $baseDir . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', 'NCU\\Config\\IUserConfig' => $baseDir . '/lib/unstable/Config/IUserConfig.php', 'NCU\\Config\\ValueType' => $baseDir . '/lib/unstable/Config/ValueType.php', + 'NCU\\Security\\Signature\\Enum\\SignatoryStatus' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatoryStatus.php', + 'NCU\\Security\\Signature\\Enum\\SignatoryType' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatoryType.php', + 'NCU\\Security\\Signature\\Enum\\SignatureAlgorithm' => $baseDir . '/lib/unstable/Security/Signature/Enum/SignatureAlgorithm.php', + 'NCU\\Security\\Signature\\Exceptions\\IdentityNotFoundException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/IdentityNotFoundException.php', + 'NCU\\Security\\Signature\\Exceptions\\IncomingRequestException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/IncomingRequestException.php', + 'NCU\\Security\\Signature\\Exceptions\\InvalidKeyOriginException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/InvalidKeyOriginException.php', + 'NCU\\Security\\Signature\\Exceptions\\InvalidSignatureException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/InvalidSignatureException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatoryConflictException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/SignatoryConflictException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatoryException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/SignatoryException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatoryNotFoundException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/SignatoryNotFoundException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatureElementNotFoundException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/SignatureElementNotFoundException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatureException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/SignatureException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatureNotFoundException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/SignatureNotFoundException.php', + 'NCU\\Security\\Signature\\IIncomingSignedRequest' => $baseDir . '/lib/unstable/Security/Signature/IIncomingSignedRequest.php', + 'NCU\\Security\\Signature\\IOutgoingSignedRequest' => $baseDir . '/lib/unstable/Security/Signature/IOutgoingSignedRequest.php', + 'NCU\\Security\\Signature\\ISignatoryManager' => $baseDir . '/lib/unstable/Security/Signature/ISignatoryManager.php', + 'NCU\\Security\\Signature\\ISignatureManager' => $baseDir . '/lib/unstable/Security/Signature/ISignatureManager.php', + 'NCU\\Security\\Signature\\ISignedRequest' => $baseDir . '/lib/unstable/Security/Signature/ISignedRequest.php', + 'NCU\\Security\\Signature\\Model\\Signatory' => $baseDir . '/lib/unstable/Security/Signature/Model/Signatory.php', 'OCP\\Accounts\\IAccount' => $baseDir . '/lib/public/Accounts/IAccount.php', 'OCP\\Accounts\\IAccountManager' => $baseDir . '/lib/public/Accounts/IAccountManager.php', 'OCP\\Accounts\\IAccountProperty' => $baseDir . '/lib/public/Accounts/IAccountProperty.php', @@ -1392,6 +1411,7 @@ 'OC\\Core\\Migrations\\Version30000Date20240814180800' => $baseDir . '/core/Migrations/Version30000Date20240814180800.php', 'OC\\Core\\Migrations\\Version30000Date20240815080800' => $baseDir . '/core/Migrations/Version30000Date20240815080800.php', 'OC\\Core\\Migrations\\Version30000Date20240906095113' => $baseDir . '/core/Migrations/Version30000Date20240906095113.php', + 'OC\\Core\\Migrations\\Version31000Date20240101084401' => $baseDir . '/core/Migrations/Version31000Date20240101084401.php', 'OC\\Core\\Migrations\\Version31000Date20240814184402' => $baseDir . '/core/Migrations/Version31000Date20240814184402.php', 'OC\\Core\\Migrations\\Version31000Date20241018063111' => $baseDir . '/core/Migrations/Version31000Date20241018063111.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', @@ -1732,6 +1752,7 @@ 'OC\\OCM\\Model\\OCMProvider' => $baseDir . '/lib/private/OCM/Model/OCMProvider.php', 'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php', 'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php', + 'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php', 'OC\\OCS\\ApiHelper' => $baseDir . '/lib/private/OCS/ApiHelper.php', 'OC\\OCS\\CoreCapabilities' => $baseDir . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => $baseDir . '/lib/private/OCS/DiscoveryService.php', @@ -1908,6 +1929,11 @@ 'OC\\Security\\RateLimiting\\Limiter' => $baseDir . '/lib/private/Security/RateLimiting/Limiter.php', 'OC\\Security\\RemoteHostValidator' => $baseDir . '/lib/private/Security/RemoteHostValidator.php', 'OC\\Security\\SecureRandom' => $baseDir . '/lib/private/Security/SecureRandom.php', + 'OC\\Security\\Signature\\Db\\SignatoryMapper' => $baseDir . '/lib/private/Security/Signature/Db/SignatoryMapper.php', + 'OC\\Security\\Signature\\Model\\IncomingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/IncomingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\OutgoingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/OutgoingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\SignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/SignedRequest.php', + 'OC\\Security\\Signature\\SignatureManager' => $baseDir . '/lib/private/Security/Signature/SignatureManager.php', 'OC\\Security\\TrustedDomainHelper' => $baseDir . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => $baseDir . '/lib/private/Security/VerificationToken/CleanUpJob.php', 'OC\\Security\\VerificationToken\\VerificationToken' => $baseDir . '/lib/private/Security/VerificationToken/VerificationToken.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 54959bc6b9164..31e93679086ec 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -53,6 +53,25 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'NCU\\Config\\Exceptions\\UnknownKeyException' => __DIR__ . '/../../..' . '/lib/unstable/Config/Exceptions/UnknownKeyException.php', 'NCU\\Config\\IUserConfig' => __DIR__ . '/../../..' . '/lib/unstable/Config/IUserConfig.php', 'NCU\\Config\\ValueType' => __DIR__ . '/../../..' . '/lib/unstable/Config/ValueType.php', + 'NCU\\Security\\Signature\\Enum\\SignatoryStatus' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatoryStatus.php', + 'NCU\\Security\\Signature\\Enum\\SignatoryType' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatoryType.php', + 'NCU\\Security\\Signature\\Enum\\SignatureAlgorithm' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Enum/SignatureAlgorithm.php', + 'NCU\\Security\\Signature\\Exceptions\\IdentityNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/IdentityNotFoundException.php', + 'NCU\\Security\\Signature\\Exceptions\\IncomingRequestException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/IncomingRequestException.php', + 'NCU\\Security\\Signature\\Exceptions\\InvalidKeyOriginException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/InvalidKeyOriginException.php', + 'NCU\\Security\\Signature\\Exceptions\\InvalidSignatureException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/InvalidSignatureException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatoryConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/SignatoryConflictException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatoryException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/SignatoryException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatoryNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/SignatoryNotFoundException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatureElementNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/SignatureElementNotFoundException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatureException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/SignatureException.php', + 'NCU\\Security\\Signature\\Exceptions\\SignatureNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/SignatureNotFoundException.php', + 'NCU\\Security\\Signature\\IIncomingSignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/IIncomingSignedRequest.php', + 'NCU\\Security\\Signature\\IOutgoingSignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/IOutgoingSignedRequest.php', + 'NCU\\Security\\Signature\\ISignatoryManager' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignatoryManager.php', + 'NCU\\Security\\Signature\\ISignatureManager' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignatureManager.php', + 'NCU\\Security\\Signature\\ISignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignedRequest.php', + 'NCU\\Security\\Signature\\Model\\Signatory' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/Signatory.php', 'OCP\\Accounts\\IAccount' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccount.php', 'OCP\\Accounts\\IAccountManager' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountManager.php', 'OCP\\Accounts\\IAccountProperty' => __DIR__ . '/../../..' . '/lib/public/Accounts/IAccountProperty.php', @@ -1433,6 +1452,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version30000Date20240814180800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240814180800.php', 'OC\\Core\\Migrations\\Version30000Date20240815080800' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240815080800.php', 'OC\\Core\\Migrations\\Version30000Date20240906095113' => __DIR__ . '/../../..' . '/core/Migrations/Version30000Date20240906095113.php', + 'OC\\Core\\Migrations\\Version31000Date20240101084401' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20240101084401.php', 'OC\\Core\\Migrations\\Version31000Date20240814184402' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20240814184402.php', 'OC\\Core\\Migrations\\Version31000Date20241018063111' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20241018063111.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', @@ -1773,6 +1793,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\OCM\\Model\\OCMProvider' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMProvider.php', 'OC\\OCM\\Model\\OCMResource' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMResource.php', 'OC\\OCM\\OCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryService.php', + 'OC\\OCM\\OCMSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMSignatoryManager.php', 'OC\\OCS\\ApiHelper' => __DIR__ . '/../../..' . '/lib/private/OCS/ApiHelper.php', 'OC\\OCS\\CoreCapabilities' => __DIR__ . '/../../..' . '/lib/private/OCS/CoreCapabilities.php', 'OC\\OCS\\DiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCS/DiscoveryService.php', @@ -1949,6 +1970,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Security\\RateLimiting\\Limiter' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Limiter.php', 'OC\\Security\\RemoteHostValidator' => __DIR__ . '/../../..' . '/lib/private/Security/RemoteHostValidator.php', 'OC\\Security\\SecureRandom' => __DIR__ . '/../../..' . '/lib/private/Security/SecureRandom.php', + 'OC\\Security\\Signature\\Db\\SignatoryMapper' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Db/SignatoryMapper.php', + 'OC\\Security\\Signature\\Model\\IncomingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/IncomingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\OutgoingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/OutgoingSignedRequest.php', + 'OC\\Security\\Signature\\Model\\SignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/SignedRequest.php', + 'OC\\Security\\Signature\\SignatureManager' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/SignatureManager.php', 'OC\\Security\\TrustedDomainHelper' => __DIR__ . '/../../..' . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/CleanUpJob.php', 'OC\\Security\\VerificationToken\\VerificationToken' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/VerificationToken.php', diff --git a/lib/private/Federation/CloudFederationProviderManager.php b/lib/private/Federation/CloudFederationProviderManager.php index bf7648d472b4f..e93542943510a 100644 --- a/lib/private/Federation/CloudFederationProviderManager.php +++ b/lib/private/Federation/CloudFederationProviderManager.php @@ -8,7 +8,9 @@ */ namespace OC\Federation; +use NCU\Security\Signature\ISignatureManager; use OC\AppFramework\Http; +use OC\OCM\OCMSignatoryManager; use OCP\App\IAppManager; use OCP\Federation\Exceptions\ProviderDoesNotExistsException; use OCP\Federation\ICloudFederationNotification; @@ -16,8 +18,10 @@ use OCP\Federation\ICloudFederationProviderManager; use OCP\Federation\ICloudFederationShare; use OCP\Federation\ICloudIdManager; +use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; +use OCP\IAppConfig; use OCP\IConfig; use OCP\OCM\Exceptions\OCMProviderException; use OCP\OCM\IOCMDiscoveryService; @@ -37,9 +41,12 @@ class CloudFederationProviderManager implements ICloudFederationProviderManager public function __construct( private IConfig $config, private IAppManager $appManager, + private IAppConfig $appConfig, private IClientService $httpClientService, private ICloudIdManager $cloudIdManager, private IOCMDiscoveryService $discoveryService, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, private LoggerInterface $logger, ) { } @@ -99,17 +106,11 @@ public function getCloudFederationProvider($resourceType) { public function sendShare(ICloudFederationShare $share) { $cloudID = $this->cloudIdManager->resolveCloudId($share->getShareWith()); try { - $ocmProvider = $this->discoveryService->discover($cloudID->getRemote()); - } catch (OCMProviderException $e) { - return false; - } - - $client = $this->httpClientService->newClient(); - try { - $response = $client->post($ocmProvider->getEndPoint() . '/shares', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($share->getShare()), - ])); - + try { + $response = $this->postOcmPayload($cloudID->getRemote(), '/shares', json_encode($share->getShare())); + } catch (OCMProviderException) { + return false; + } if ($response->getStatusCode() === Http::STATUS_CREATED) { $result = json_decode($response->getBody(), true); return (is_array($result)) ? $result : []; @@ -135,13 +136,9 @@ public function sendShare(ICloudFederationShare $share) { */ public function sendCloudShare(ICloudFederationShare $share): IResponse { $cloudID = $this->cloudIdManager->resolveCloudId($share->getShareWith()); - $ocmProvider = $this->discoveryService->discover($cloudID->getRemote()); - $client = $this->httpClientService->newClient(); try { - return $client->post($ocmProvider->getEndPoint() . '/shares', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($share->getShare()), - ])); + return $this->postOcmPayload($cloudID->getRemote(), '/shares', json_encode($share->getShare()), $client); } catch (\Throwable $e) { $this->logger->error('Error while sending share to federation server: ' . $e->getMessage(), ['exception' => $e]); try { @@ -160,16 +157,11 @@ public function sendCloudShare(ICloudFederationShare $share): IResponse { */ public function sendNotification($url, ICloudFederationNotification $notification) { try { - $ocmProvider = $this->discoveryService->discover($url); - } catch (OCMProviderException $e) { - return false; - } - - $client = $this->httpClientService->newClient(); - try { - $response = $client->post($ocmProvider->getEndPoint() . '/notifications', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($notification->getMessage()), - ])); + try { + $response = $this->postOcmPayload($url, '/notifications', json_encode($notification->getMessage())); + } catch (OCMProviderException) { + return false; + } if ($response->getStatusCode() === Http::STATUS_CREATED) { $result = json_decode($response->getBody(), true); return (is_array($result)) ? $result : []; @@ -189,13 +181,9 @@ public function sendNotification($url, ICloudFederationNotification $notificatio * @throws OCMProviderException */ public function sendCloudNotification(string $url, ICloudFederationNotification $notification): IResponse { - $ocmProvider = $this->discoveryService->discover($url); - $client = $this->httpClientService->newClient(); try { - return $client->post($ocmProvider->getEndPoint() . '/notifications', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($notification->getMessage()), - ])); + return $this->postOcmPayload($url, '/notifications', json_encode($notification->getMessage()), $client); } catch (\Throwable $e) { $this->logger->error('Error while sending notification to federation server: ' . $e->getMessage(), ['exception' => $e]); try { @@ -215,16 +203,52 @@ public function isReady() { return $this->appManager->isEnabledForUser('cloud_federation_api'); } + /** + * @param string $cloudId + * @param string $uri + * @param string $payload + * + * @return IResponse + * @throws OCMProviderException + */ + private function postOcmPayload(string $cloudId, string $uri, string $payload, ?IClient $client = null): IResponse { + $ocmProvider = $this->discoveryService->discover($cloudId); + $uri = $ocmProvider->getEndPoint() . '/' . ltrim($uri, '/'); + $client = $client ?? $this->httpClientService->newClient(); + return $client->post($uri, $this->prepareOcmPayload($uri, $payload)); + } + + /** + * @param string $uri + * @param string $payload + * + * @return array + */ + private function prepareOcmPayload(string $uri, string $payload): array { + $payload = array_merge($this->getDefaultRequestOptions(), ['body' => $payload]); + + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true) && + $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) { + return $payload; + } + + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $payload, + 'post', $uri + ); + } + + return $signedPayload ?? $payload; + } + private function getDefaultRequestOptions(): array { - $options = [ + return [ 'headers' => ['content-type' => 'application/json'], 'timeout' => 10, 'connect_timeout' => 10, + 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false), ]; - - if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates')) { - $options['verify'] = false; - } - return $options; } } diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index 73002ae668de1..32068efe3eb16 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -9,6 +9,7 @@ namespace OC\OCM\Model; +use NCU\Security\Signature\Model\Signatory; use OCP\EventDispatcher\IEventDispatcher; use OCP\OCM\Events\ResourceTypeRegisterEvent; use OCP\OCM\Exceptions\OCMArgumentException; @@ -25,7 +26,7 @@ class OCMProvider implements IOCMProvider { private string $endPoint = ''; /** @var IOCMResource[] */ private array $resourceTypes = []; - + private ?Signatory $signatory = null; private bool $emittedEvent = false; public function __construct( @@ -152,6 +153,14 @@ public function extractProtocolEntry(string $resourceName, string $protocol): st throw new OCMArgumentException('resource not found'); } + public function setSignatory(Signatory $signatory): void { + $this->signatory = $signatory; + } + + public function getSignatory(): ?Signatory { + return $this->signatory; + } + /** * import data from an array * @@ -163,7 +172,7 @@ public function extractProtocolEntry(string $resourceName, string $protocol): st */ public function import(array $data): static { $this->setEnabled(is_bool($data['enabled'] ?? '') ? $data['enabled'] : false) - ->setApiVersion((string)($data['apiVersion'] ?? '')) + ->setApiVersion((string)($data['version'] ?? '')) ->setEndPoint($data['endPoint'] ?? ''); $resources = []; @@ -173,6 +182,12 @@ public function import(array $data): static { } $this->setResourceTypes($resources); + // import details about the remote request signing public key, if available + $signatory = new Signatory($data['publicKey']['keyId'] ?? '', $data['publicKey']['publicKeyPem'] ?? ''); + if ($signatory->getKeyId() !== '' && $signatory->getPublicKey() !== '') { + $this->setSignatory($signatory); + } + if (!$this->looksValid()) { throw new OCMProviderException('remote provider does not look valid'); } @@ -188,18 +203,19 @@ private function looksValid(): bool { return ($this->getApiVersion() !== '' && $this->getEndPoint() !== ''); } - /** * @return array{ - * enabled: bool, - * apiVersion: string, - * endPoint: string, - * resourceTypes: list, - * protocols: array - * }>, - * } + * enabled: bool, + * apiVersion: '1.0-proposal1', + * endPoint: string, + * publicKey: Signatory|null, + * resourceTypes: list, + * protocols: array + * }>, + * version: string + * } */ public function jsonSerialize(): array { $resourceTypes = []; @@ -209,8 +225,10 @@ public function jsonSerialize(): array { return [ 'enabled' => $this->isEnabled(), - 'apiVersion' => $this->getApiVersion(), + 'apiVersion' => '1.0-proposal1', // deprecated, but keep it to stay compatible with old version + 'version' => $this->getApiVersion(), // informative but real version 'endPoint' => $this->getEndPoint(), + 'publicKey' => $this->getSignatory(), 'resourceTypes' => $resourceTypes ]; } diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 279162c76f283..30e7909a6aae0 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -25,12 +25,6 @@ */ class OCMDiscoveryService implements IOCMDiscoveryService { private ICache $cache; - private array $supportedAPIVersion = - [ - '1.0-proposal1', - '1.0', - '1.1' - ]; public function __construct( ICacheFactory $cacheFactory, @@ -52,13 +46,19 @@ public function __construct( */ public function discover(string $remote, bool $skipCache = false): IOCMProvider { $remote = rtrim($remote, '/'); + if (!str_starts_with($remote, 'http://') && !str_starts_with($remote, 'https://')) { + // if scheme not specified, we test both; + try { + return $this->discover('https://' . $remote, $skipCache); + } catch (OCMProviderException) { + return $this->discover('http://' . $remote, $skipCache); + } + } if (!$skipCache) { try { $this->provider->import(json_decode($this->cache->get($remote) ?? '', true, 8, JSON_THROW_ON_ERROR) ?? []); - if ($this->supportedAPIVersion($this->provider->getApiVersion())) { - return $this->provider; // if cache looks valid, we use it - } + return $this->provider; } catch (JsonException|OCMProviderException $e) { // we ignore cache on issues } @@ -73,10 +73,7 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) { $options['verify'] = false; } - $response = $client->get( - $remote . '/ocm-provider/', - $options, - ); + $response = $client->get($remote . '/ocm-provider/', $options); if ($response->getStatusCode() === Http::STATUS_OK) { $body = $response->getBody(); @@ -94,30 +91,6 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider throw new OCMProviderException('error while requesting remote ocm provider'); } - if (!$this->supportedAPIVersion($this->provider->getApiVersion())) { - throw new OCMProviderException('API version not supported'); - } - return $this->provider; } - - /** - * Check the version from remote is supported. - * The minor version of the API will be ignored: - * 1.0.1 is identified as 1.0 - * - * @param string $version - * - * @return bool - */ - private function supportedAPIVersion(string $version): bool { - $dot1 = strpos($version, '.'); - $dot2 = strpos($version, '.', $dot1 + 1); - - if ($dot2 > 0) { - $version = substr($version, 0, $dot2); - } - - return (in_array($version, $this->supportedAPIVersion)); - } } diff --git a/lib/private/OCM/OCMSignatoryManager.php b/lib/private/OCM/OCMSignatoryManager.php new file mode 100644 index 0000000000000..ca0a295468f57 --- /dev/null +++ b/lib/private/OCM/OCMSignatoryManager.php @@ -0,0 +1,154 @@ +appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) { + $identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true); + $keyId = 'https://' . $identity . '/ocm#signature'; + } else { + $keyId = $this->generateKeyId(); + } + + if (!$this->identityProofManager->hasAppKey('core', 'ocm_external')) { + $this->identityProofManager->generateAppKey('core', 'ocm_external', [ + 'algorithm' => 'rsa', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + } + $keyPair = $this->identityProofManager->getAppKey('core', 'ocm_external'); + + return new Signatory($keyId, $keyPair->getPublic(), $keyPair->getPrivate(), local: true); + } + + /** + * - tries to generate a keyId using global configuration (from signature manager) if available + * - generate a keyId using the current route to ocm shares + * + * @return string + * @throws IdentityNotFoundException + */ + private function generateKeyId(): string { + try { + return $this->signatureManager->generateKeyIdFromConfig('/ocm#signature'); + } catch (IdentityNotFoundException) { + } + + $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); + $identity = $this->signatureManager->extractIdentityFromUri($url); + + // catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#signature + $path = parse_url($url, PHP_URL_PATH); + $pos = strpos($path, '/ocm/shares'); + $sub = ($pos) ? substr($path, 0, $pos) : ''; + + return 'https://' . $identity . $sub . '/ocm#signature'; + } + + /** + * @inheritDoc + * + * @param string $remote + * + * @return Signatory|null must be NULL if no signatory is found + * @since 31.0.0 + */ + public function getRemoteSignatory(string $remote): ?Signatory { + try { + return $this->getRemoteSignatoryFromHost($remote); + } catch (OCMProviderException $e) { + $this->logger->warning('fail to get remote signatory', ['exception' => $e, 'remote' => $remote]); + return null; + } + } + + /** + * As host is enough to generate signatory using OCMDiscoveryService + * + * @param string $host + * + * @return Signatory|null + * @throws OCMProviderException on fail to discover ocm services + * @since 31.0.0 + */ + public function getRemoteSignatoryFromHost(string $host): ?Signatory { + $ocmProvider = $this->ocmDiscoveryService->discover($host, true); + $signatory = $ocmProvider->getSignatory(); + + return $signatory?->setType(SignatoryType::TRUSTED); + } +} diff --git a/lib/private/Security/IdentityProof/Manager.php b/lib/private/Security/IdentityProof/Manager.php index 0ce760ccc63b3..935c18bb81dc7 100644 --- a/lib/private/Security/IdentityProof/Manager.php +++ b/lib/private/Security/IdentityProof/Manager.php @@ -10,6 +10,7 @@ use OC\Files\AppData\Factory; use OCP\Files\IAppData; +use OCP\Files\NotFoundException; use OCP\IConfig; use OCP\IUser; use OCP\Security\ICrypto; @@ -31,18 +32,20 @@ public function __construct( * Calls the openssl functions to generate a public and private key. * In a separate function for unit testing purposes. * + * @param array $options config options to generate key {@see openssl_csr_new} + * * @return array [$publicKey, $privateKey] * @throws \RuntimeException */ - protected function generateKeyPair(): array { + protected function generateKeyPair(array $options = []): array { $config = [ - 'digest_alg' => 'sha512', - 'private_key_bits' => 2048, + 'digest_alg' => $options['algorithm'] ?? 'sha512', + 'private_key_bits' => $options['bits'] ?? 2048, + 'private_key_type' => $options['type'] ?? OPENSSL_KEYTYPE_RSA, ]; // Generate new key $res = openssl_pkey_new($config); - if ($res === false) { $this->logOpensslError(); throw new \RuntimeException('OpenSSL reported a problem'); @@ -65,15 +68,17 @@ protected function generateKeyPair(): array { * Note: If a key already exists it will be overwritten * * @param string $id key id + * @param array $options config options to generate key {@see openssl_csr_new} + * * @throws \RuntimeException */ - protected function generateKey(string $id): Key { - [$publicKey, $privateKey] = $this->generateKeyPair(); + protected function generateKey(string $id, array $options = []): Key { + [$publicKey, $privateKey] = $this->generateKeyPair($options); // Write the private and public key to the disk try { $this->appData->newFolder($id); - } catch (\Exception $e) { + } catch (\Exception) { } $folder = $this->appData->getFolder($id); $folder->newFile('private') @@ -125,6 +130,38 @@ public function getSystemKey(): Key { return $this->retrieveKey('system-' . $instanceId); } + public function hasAppKey(string $app, string $name): bool { + $id = $this->generateAppKeyId($app, $name); + try { + $folder = $this->appData->getFolder($id); + return ($folder->fileExists('public') && $folder->fileExists('private')); + } catch (NotFoundException) { + return false; + } + } + + public function getAppKey(string $app, string $name): Key { + return $this->retrieveKey($this->generateAppKeyId($app, $name)); + } + + public function generateAppKey(string $app, string $name, array $options = []): Key { + return $this->generateKey($this->generateAppKeyId($app, $name), $options); + } + + public function deleteAppKey(string $app, string $name): bool { + try { + $folder = $this->appData->getFolder($this->generateAppKeyId($app, $name)); + $folder->delete(); + return true; + } catch (NotFoundException) { + return false; + } + } + + private function generateAppKeyId(string $app, string $name): string { + return 'app-' . $app . '-' . $name; + } + private function logOpensslError(): void { $errors = []; while ($error = openssl_error_string()) { diff --git a/lib/private/Security/Signature/Db/SignatoryMapper.php b/lib/private/Security/Signature/Db/SignatoryMapper.php new file mode 100644 index 0000000000000..47b7932054824 --- /dev/null +++ b/lib/private/Security/Signature/Db/SignatoryMapper.php @@ -0,0 +1,114 @@ + + */ +class SignatoryMapper extends QBMapper { + public const TABLE = 'sec_signatory'; + + public function __construct( + IDBConnection $db, + ) { + parent::__construct($db, self::TABLE, Signatory::class); + } + + /** + * + */ + public function getByHost(string $host, string $account = ''): Signatory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('host', $qb->createNamedParameter($host))) + ->andWhere($qb->expr()->eq('account', $qb->createNamedParameter($account))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + throw new SignatoryNotFoundException('no signatory found'); + } + } + + /** + */ + public function getByKeyId(string $keyId): Signatory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + throw new SignatoryNotFoundException('no signatory found'); + } + } + + /** + * @param string $keyId + * + * @return int + * @throws Exception + */ + public function deleteByKeyId(string $keyId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + return $qb->executeStatement(); + } + + /** + * @param Signatory $signatory + * + * @return int + */ + public function updateMetadata(Signatory $signatory): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('metadata', $qb->createNamedParameter(json_encode($signatory->getMetadata()))) + ->set('last_updated', $qb->createNamedParameter(time())); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId())))); + + return $qb->executeStatement(); + } + + /** + * @param Signatory $signator + */ + public function updatePublicKey(Signatory $signatory): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('signatory', $qb->createNamedParameter($signatory->getPublicKey())) + ->set('last_updated', $qb->createNamedParameter(time())); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId())))); + + return $qb->executeStatement(); + } + + /** + * returns a hash version for keyId for better index in the database + * + * @param string $keyId + * + * @return string + */ + private function hashKeyId(string $keyId): string { + return hash('sha256', $keyId); + } +} diff --git a/lib/private/Security/Signature/Model/IncomingSignedRequest.php b/lib/private/Security/Signature/Model/IncomingSignedRequest.php new file mode 100644 index 0000000000000..a2bf9fd0bcce4 --- /dev/null +++ b/lib/private/Security/Signature/Model/IncomingSignedRequest.php @@ -0,0 +1,209 @@ +verifyHeadersFromRequest(); + $this->extractSignatureHeaderFromRequest(); + } + + /** + * confirm that: + * + * - date is available in the header and its value is less than 5 minutes old + * - content-length is available and is the same as the payload size + * - digest is available and fit the checksum of the payload + * + * @throws IncomingRequestException + * @throws SignatureNotFoundException + */ + private function verifyHeadersFromRequest(): void { + // confirm presence of date, content-length, digest and Signature + $date = $this->getRequest()->getHeader('date'); + if ($date === '') { + throw new SignatureNotFoundException('missing date in header'); + } + $contentLength = $this->getRequest()->getHeader('content-length'); + if ($contentLength === '') { + throw new SignatureNotFoundException('missing content-length in header'); + } + $digest = $this->getRequest()->getHeader('digest'); + if ($digest === '') { + throw new SignatureNotFoundException('missing digest in header'); + } + if ($this->getRequest()->getHeader('Signature') === '') { + throw new SignatureNotFoundException('missing Signature in header'); + } + + // confirm date + try { + $dTime = new \DateTime($date); + $requestTime = $dTime->getTimestamp(); + } catch (\Exception) { + throw new IncomingRequestException('datetime exception'); + } + if ($requestTime < (time() - ($this->options['ttl'] ?? SignatureManager::DATE_TTL))) { + throw new IncomingRequestException('object is too old'); + } + + // confirm validity of content-length + if (strlen($this->getBody()) !== (int)$contentLength) { + throw new IncomingRequestException('inexact content-length in header'); + } + + // confirm digest value, based on body + if ($digest !== $this->getDigest()) { + throw new IncomingRequestException('invalid value for digest in header'); + } + } + + /** + * extract data from the header entry 'Signature' and convert its content from string to an array + * also confirm that it contains the minimum mandatory information + * + * @throws IncomingRequestException + */ + private function extractSignatureHeaderFromRequest(): void { + $sign = []; + foreach (explode(',', $this->getRequest()->getHeader('Signature')) as $entry) { + if ($entry === '' || !strpos($entry, '=')) { + continue; + } + + [$k, $v] = explode('=', $entry, 2); + preg_match('/"([^"]+)"/', $v, $var); + if ($var[0] !== '') { + $v = trim($var[0], '"'); + } + $sign[$k] = $v; + } + + $this->setSignatureElements($sign); + + try { + // confirm keys are in the Signature header + $this->getSignatureElement('keyId'); + $this->getSignatureElement('headers'); + $this->setSignedSignature($this->getSignatureElement('signature')); + } catch (SignatureElementNotFoundException $e) { + throw new IncomingRequestException($e->getMessage()); + } + } + + /** + * @inheritDoc + * + * @return IRequest + * @since 31.0.0 + */ + public function getRequest(): IRequest { + return $this->request; + } + + /** + * @inheritDoc + * + * @param Signatory $signatory + * + * @return $this + * @throws IdentityNotFoundException + * @throws IncomingRequestException + * @throws SignatoryException + * @since 31.0.0 + */ + public function setSignatory(Signatory $signatory): self { + $identity = \OCP\Server::get(ISignatureManager::class)->extractIdentityFromUri($signatory->getKeyId()); + if ($identity !== $this->getOrigin()) { + throw new SignatoryException('keyId from provider is different from the one from signed request'); + } + + parent::setSignatory($signatory); + return $this; + } + + /** + * @inheritDoc + * + * @param string $origin + * @return IIncomingSignedRequest + * @since 31.0.0 + */ + public function setOrigin(string $origin): IIncomingSignedRequest { + $this->origin = $origin; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @throws IncomingRequestException + * @since 31.0.0 + */ + public function getOrigin(): string { + if ($this->origin === '') { + throw new IncomingRequestException('empty origin'); + } + return $this->origin; + } + + /** + * returns the keyId extracted from the signature headers. + * keyId is a mandatory entry in the headers of a signed request. + * + * @return string + * @throws SignatureElementNotFoundException + * @since 31.0.0 + */ + public function getKeyId(): string { + return $this->getSignatureElement('keyId'); + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'options' => $this->options, + 'origin' => $this->origin, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php new file mode 100644 index 0000000000000..611a968d1d23a --- /dev/null +++ b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php @@ -0,0 +1,172 @@ + $headerList */ + private array $headerList = []; + private SignatureAlgorithm $algorithm; + public function __construct( + string $body, + ISignatoryManager $signatoryManager, + private readonly string $identity, + private readonly string $method, + private readonly string $path, + ) { + parent::__construct($body); + + $options = $signatoryManager->getOptions(); + $this->setHost($identity) + ->setAlgorithm(SignatureAlgorithm::from($options['algorithm'] ?? 'sha256')) + ->setSignatory($signatoryManager->getLocalSignatory()); + + $headers = array_merge([ + '(request-target)' => strtolower($method) . ' ' . $path, + 'content-length' => strlen($this->getBody()), + 'date' => gmdate($options['dateHeader'] ?? SignatureManager::DATE_HEADER), + 'digest' => $this->getDigest(), + 'host' => $this->getHost() + ], $options['extraSignatureHeaders'] ?? []); + + $signing = $headerList = []; + foreach ($headers as $element => $value) { + $signing[] = $element . ': ' . $value; + $headerList[] = $element; + if ($element !== '(request-target)') { + $this->addHeader($element, $value); + } + } + + $this->setHeaderList($headerList) + ->setClearSignature(implode("\n", $signing)); + } + + /** + * @inheritDoc + * + * @param string $host + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setHost(string $host): IOutgoingSignedRequest { + $this->host = $host; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getHost(): string { + return $this->host; + } + + /** + * @inheritDoc + * + * @param string $key + * @param string|int|float $value + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function addHeader(string $key, string|int|float $value): IOutgoingSignedRequest { + $this->headers[$key] = $value; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getHeaders(): array { + return $this->headers; + } + + /** + * set the ordered list of used headers in the Signature + * + * @param list $list + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setHeaderList(array $list): IOutgoingSignedRequest { + $this->headerList = $list; + return $this; + } + + /** + * returns ordered list of used headers in the Signature + * + * @return list + * @since 31.0.0 + */ + public function getHeaderList(): array { + return $this->headerList; + } + + /** + * @inheritDoc + * + * @param SignatureAlgorithm $algorithm + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setAlgorithm(SignatureAlgorithm $algorithm): IOutgoingSignedRequest { + $this->algorithm = $algorithm; + return $this; + } + + /** + * @inheritDoc + * + * @return SignatureAlgorithm + * @since 31.0.0 + */ + public function getAlgorithm(): SignatureAlgorithm { + return $this->algorithm; + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'host' => $this->host, + 'headers' => $this->headers, + 'algorithm' => $this->algorithm->value, + 'method' => $this->method, + 'identity' => $this->identity, + 'path' => $this->path, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/SignedRequest.php b/lib/private/Security/Signature/Model/SignedRequest.php new file mode 100644 index 0000000000000..e974942a9252d --- /dev/null +++ b/lib/private/Security/Signature/Model/SignedRequest.php @@ -0,0 +1,187 @@ +digest = 'SHA-256=' . base64_encode(hash('sha256', utf8_encode($body), true)); + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getBody(): string { + return $this->body; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getDigest(): string { + return $this->digest; + } + + /** + * @inheritDoc + * + * @param array $elements + * + * @return ISignedRequest + * @since 31.0.0 + */ + public function setSignatureElements(array $elements): ISignedRequest { + $this->signatureElements = $elements; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getSignatureElements(): array { + return $this->signatureElements; + } + + /** + * @param string $key + * + * @return string + * @throws SignatureElementNotFoundException + * @since 31.0.0 + * + */ + public function getSignatureElement(string $key): string { + if (!array_key_exists($key, $this->signatureElements)) { + throw new SignatureElementNotFoundException('missing element ' . $key . ' in Signature header'); + } + + return $this->signatureElements[$key]; + } + + /** + * @inheritDoc + * + * @param string $clearSignature + * + * @return ISignedRequest + * @since 31.0.0 + */ + public function setClearSignature(string $clearSignature): ISignedRequest { + $this->clearSignature = $clearSignature; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getClearSignature(): string { + return $this->clearSignature; + } + + /** + * @inheritDoc + * + * @param string $signedSignature + * @return ISignedRequest + * @since 31.0.0 + */ + public function setSignedSignature(string $signedSignature): ISignedRequest { + $this->signedSignature = $signedSignature; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getSignedSignature(): string { + return $this->signedSignature; + } + + /** + * @inheritDoc + * + * @param Signatory $signatory + * @return ISignedRequest + * @since 31.0.0 + */ + public function setSignatory(Signatory $signatory): ISignedRequest { + $this->signatory = $signatory; + return $this; + } + + /** + * @inheritDoc + * + * @return Signatory + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function getSignatory(): Signatory { + if ($this->signatory === null) { + throw new SignatoryNotFoundException(); + } + + return $this->signatory; + } + + /** + * @inheritDoc + * + * @return bool + * @since 31.0.0 + */ + public function hasSignatory(): bool { + return ($this->signatory !== null); + } + + public function jsonSerialize(): array { + return [ + 'body' => $this->body, + 'digest' => $this->digest, + 'signatureElements' => $this->signatureElements, + 'clearSignature' => $this->clearSignature, + 'signedSignature' => $this->signedSignature, + 'signatory' => $this->signatory ?? false, + ]; + } +} diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php new file mode 100644 index 0000000000000..e4f4a2ed1a612 --- /dev/null +++ b/lib/private/Security/Signature/SignatureManager.php @@ -0,0 +1,560 @@ +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 { + // we set origin based on the keyId defined in the Signature header of the request + $signedRequest->setOrigin($this->extractIdentityFromUri($signedRequest->getSignatureElement('keyId'))); + } catch (IdentityNotFoundException $e) { + throw new IncomingRequestException($e->getMessage()); + } + + try { + // confirm the validity of content and identity of the incoming request + $this->generateExpectedClearSignatureFromRequest($signedRequest, $options['extraSignatureHeaders'] ?? []); + $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; + } + + /** + * generating the expected signature (clear version) sent by the remote instance + * based on the data available in the Signature header. + * + * @param IIncomingSignedRequest $signedRequest + * @param array $extraSignatureHeaders + * + * @throws SignatureException + */ + private function generateExpectedClearSignatureFromRequest( + IIncomingSignedRequest $signedRequest, + array $extraSignatureHeaders = [], + ): void { + $request = $signedRequest->getRequest(); + $usedHeaders = explode(' ', $signedRequest->getSignatureElement('headers')); + $neededHeaders = array_merge(['date', 'host', 'content-length', 'digest'], array_keys($extraSignatureHeaders)); + + $missingHeaders = array_diff($neededHeaders, $usedHeaders); + if ($missingHeaders !== []) { + throw new SignatureException('missing entries in Signature.headers: ' . json_encode($missingHeaders)); + } + + $estimated = ['(request-target): ' . strtolower($request->getMethod()) . ' ' . $request->getRequestUri()]; + foreach ($usedHeaders as $key) { + if ($key === '(request-target)') { + continue; + } + $value = (strtolower($key) === 'host') ? $request->getServerHost() : $request->getHeader($key); + if ($value === '') { + throw new SignatureException('missing header ' . $key . ' in request'); + } + + $estimated[] = $key . ': ' . $value; + } + + $signedRequest->setClearSignature(implode("\n", $estimated)); + } + + /** + * 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); + $this->verifySignedRequest($signedRequest); + } 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); + $this->verifySignedRequest($signedRequest); + $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); + $this->verifySignedRequest($signedRequest); + $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) ?? '/' + ); + + $this->signOutgoingRequest($signedRequest); + + return $signedRequest; + } + + /** + * signing clear version of the Signature header + * + * @param IOutgoingSignedRequest $signedRequest + * + * @throws SignatoryException + * @throws SignatoryNotFoundException + */ + private function signOutgoingRequest(IOutgoingSignedRequest $signedRequest): void { + $clear = $signedRequest->getClearSignature(); + $signed = $this->signString($clear, $signedRequest->getSignatory()->getPrivateKey(), $signedRequest->getAlgorithm()); + + $signatory = $signedRequest->getSignatory(); + $signatureElements = [ + 'keyId="' . $signatory->getKeyId() . '"', + 'algorithm="' . $signedRequest->getAlgorithm()->value . '"', + 'headers="' . implode(' ', $signedRequest->getHeaderList()) . '"', + 'signature="' . $signed . '"' + ]; + + $signedRequest->setSignedSignature($signed); + $signedRequest->addHeader('Signature', implode(',', $signatureElements)); + } + + /** + * @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 { + $identity = parse_url($uri, PHP_URL_HOST); + $port = parse_url($uri, PHP_URL_PORT); + if ($identity === null || $identity === false) { + throw new IdentityNotFoundException('cannot extract identity from ' . $uri); + } + + if ($port !== null && $port !== false) { + $identity .= ':' . $port; + } + + return $identity; + } + + /** + * 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 IIncomingSignedRequest $signedRequest + * + * @throws SignatureException + * @throws SignatoryNotFoundException + */ + private function verifySignedRequest(IIncomingSignedRequest $signedRequest): void { + $publicKey = $signedRequest->getSignatory()->getPublicKey(); + if ($publicKey === '') { + throw new SignatoryNotFoundException('empty public key'); + } + + try { + $this->verifyString( + $signedRequest->getClearSignature(), + $signedRequest->getSignedSignature(), + $publicKey, + SignatureAlgorithm::tryFrom($signedRequest->getSignatureElement('algorithm')) ?? SignatureAlgorithm::SHA256 + ); + } catch (InvalidSignatureException $e) { + $this->logger->debug('signature issue', ['signed' => $signedRequest, 'exception' => $e]); + throw $e; + } + } + + /** + * @param string $clear + * @param string $privateKey + * @param SignatureAlgorithm $algorithm + * + * @return string + * @throws SignatoryException + */ + private function signString(string $clear, string $privateKey, SignatureAlgorithm $algorithm): string { + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + openssl_sign($clear, $signed, $privateKey, $algorithm->value); + + return base64_encode($signed); + } + + /** + * @param string $clear + * @param string $encoded + * @param string $publicKey + * @param SignatureAlgorithm $algorithm + * + * @throws InvalidSignatureException + */ + private function verifyString( + string $clear, + string $encoded, + string $publicKey, + SignatureAlgorithm $algorithm = SignatureAlgorithm::SHA256, + ): void { + $signed = base64_decode($encoded); + if (openssl_verify($clear, $signed, $publicKey, $algorithm->value) !== 1) { + throw new InvalidSignatureException('signature issue'); + } + } + + /** + * @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 + * @throws DBException + */ + private function insertSignatory(Signatory $signatory): void { + $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); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index d57ddf61c0378..a20c37732a76e 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -8,6 +8,7 @@ use bantu\IniGetWrapper\IniGetWrapper; use NCU\Config\IUserConfig; +use NCU\Security\Signature\ISignatureManager; use OC\Accounts\AccountManager; use OC\App\AppManager; use OC\App\AppStore\Bundles\BundleFetcher; @@ -103,6 +104,7 @@ use OC\Security\Ip\RemoteAddress; use OC\Security\RateLimiting\Limiter; use OC\Security\SecureRandom; +use OC\Security\Signature\SignatureManager; use OC\Security\TrustedDomainHelper; use OC\Security\VerificationToken\VerificationToken; use OC\Session\CryptoWrapper; @@ -1180,18 +1182,7 @@ public function __construct($webRoot, \OC\Config $config) { }); $this->registerAlias(\OCP\GlobalScale\IConfig::class, \OC\GlobalScale\Config::class); - - $this->registerService(ICloudFederationProviderManager::class, function (ContainerInterface $c) { - return new CloudFederationProviderManager( - $c->get(\OCP\IConfig::class), - $c->get(IAppManager::class), - $c->get(IClientService::class), - $c->get(ICloudIdManager::class), - $c->get(IOCMDiscoveryService::class), - $c->get(LoggerInterface::class) - ); - }); - + $this->registerAlias(ICloudFederationProviderManager::class, CloudFederationProviderManager::class); $this->registerService(ICloudFederationFactory::class, function (Server $c) { return new CloudFederationFactory(); }); @@ -1297,6 +1288,8 @@ public function __construct($webRoot, \OC\Config $config) { $this->registerAlias(IRichTextFormatter::class, \OC\RichObjectStrings\RichTextFormatter::class); + $this->registerAlias(ISignatureManager::class, SignatureManager::class); + $this->connectDispatcher(); } diff --git a/lib/public/OCM/IOCMProvider.php b/lib/public/OCM/IOCMProvider.php index ba2ab6ce759ba..cd2a59ebd5e0a 100644 --- a/lib/public/OCM/IOCMProvider.php +++ b/lib/public/OCM/IOCMProvider.php @@ -6,10 +6,10 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCP\OCM; use JsonSerializable; +use NCU\Security\Signature\Model\Signatory; use OCP\OCM\Exceptions\OCMArgumentException; use OCP\OCM\Exceptions\OCMProviderException; @@ -120,6 +120,22 @@ public function getResourceTypes(): array; */ public function extractProtocolEntry(string $resourceName, string $protocol): string; + /** + * store signatory (public/private key pair) to sign outgoing/incoming request + * + * @param Signatory $signatory + * @since 31.0.0 + */ + public function setSignatory(Signatory $signatory): void; + + /** + * signatory (public/private key pair) used to sign outgoing/incoming request + * + * @return Signatory|null returns null if no Signatory available + * @since 31.0.0 + */ + public function getSignatory(): ?Signatory; + /** * import data from an array * @@ -134,13 +150,15 @@ public function import(array $data): static; /** * @return array{ * enabled: bool, - * apiVersion: string, + * apiVersion: '1.0-proposal1', * endPoint: string, + * publicKey: Signatory|null, * resourceTypes: list, * protocols: array * }>, + * version: string * } * @since 28.0.0 */ diff --git a/lib/unstable/Security/Signature/Enum/SignatoryStatus.php b/lib/unstable/Security/Signature/Enum/SignatoryStatus.php new file mode 100644 index 0000000000000..9c77cf9bbc2b1 --- /dev/null +++ b/lib/unstable/Security/Signature/Enum/SignatoryStatus.php @@ -0,0 +1,25 @@ + $list + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setHeaderList(array $list): IOutgoingSignedRequest; + + /** + * returns ordered list of used headers in the Signature + * + * @return list + * @since 31.0.0 + */ + public function getHeaderList(): array; + + /** + * set algorithm to be used to sign the signature + * + * @param SignatureAlgorithm $algorithm + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setAlgorithm(SignatureAlgorithm $algorithm): IOutgoingSignedRequest; + + /** + * returns the algorithm set to sign the signature + * + * @return SignatureAlgorithm + * @since 31.0.0 + */ + public function getAlgorithm(): SignatureAlgorithm; +} diff --git a/lib/unstable/Security/Signature/ISignatoryManager.php b/lib/unstable/Security/Signature/ISignatoryManager.php new file mode 100644 index 0000000000000..20133de4c9ce3 --- /dev/null +++ b/lib/unstable/Security/Signature/ISignatoryManager.php @@ -0,0 +1,71 @@ + 10000, + * 'ttl' => 300, + * 'ttlSignatory' => 86400*3, + * 'extraSignatureHeaders' => [], + * 'algorithm' => 'sha256', + * 'dateHeader' => "D, d M Y H:i:s T", + * ] + * + * @return array + * @since 31.0.0 + */ + public function getOptions(): array; + + /** + * generate and returns local signatory including private and public key pair. + * + * Used to sign outgoing request + * + * @return Signatory + * @since 31.0.0 + */ + public function getLocalSignatory(): Signatory; + + /** + * retrieve details and generate signatory from remote instance. + * If signatory cannot be found, returns NULL. + * + * Used to confirm authenticity of incoming request. + * + * @param string $remote + * + * @return Signatory|null must be NULL if no signatory is found + * @since 31.0.0 + */ + public function getRemoteSignatory(string $remote): ?Signatory; +} diff --git a/lib/unstable/Security/Signature/ISignatureManager.php b/lib/unstable/Security/Signature/ISignatureManager.php new file mode 100644 index 0000000000000..c614a16cd92aa --- /dev/null +++ b/lib/unstable/Security/Signature/ISignatureManager.php @@ -0,0 +1,127 @@ +addType('providerId', 'string'); + $this->addType('host', 'string'); + $this->addType('account', 'string'); + $this->addType('keyId', 'string'); + $this->addType('keyIdSum', 'string'); + $this->addType('publicKey', 'string'); + $this->addType('metadata', 'json'); + $this->addType('type', 'integer'); + $this->addType('status', 'integer'); + $this->addType('creation', 'integer'); + $this->addType('lastUpdated', 'integer'); + + $this->setKeyId($keyId); + } + + /** + * @param string $keyId + * + * @since 31.0.0 + */ + public function setKeyId(string $keyId): void { + // if set as local (for current instance), we apply some filters. + if ($this->local) { + // to avoid conflict with duplicate key pairs (ie generated url from the occ command), we enforce https as prefix + if (str_starts_with($keyId, 'http://')) { + $keyId = 'https://' . substr($keyId, 7); + } + + // removing /index.php from generated url + $path = parse_url($keyId, PHP_URL_PATH); + if (str_starts_with($path, '/index.php/')) { + $pos = strpos($keyId, '/index.php'); + if ($pos !== false) { + $keyId = substr_replace($keyId, '', $pos, 10); + } + } + } + $this->keyId = $keyId; + $this->keyIdSum = hash('sha256', $keyId); + } + + /** + * @param SignatoryType $type + * @since 31.0.0 + */ + public function setType(SignatoryType $type): void { + $this->type = $type->value; + } + + /** + * @return SignatoryType + * @since 31.0.0 + */ + public function getType(): SignatoryType { + return SignatoryType::from($this->type); + } + + /** + * @param SignatoryStatus $status + * @since 31.0.0 + */ + public function setStatus(SignatoryStatus $status): void { + $this->status = $status->value; + } + + /** + * @return SignatoryStatus + * @since 31.0.0 + */ + public function getStatus(): SignatoryStatus { + return SignatoryStatus::from($this->status); + } + + /** + * update an entry in metadata + * + * @param string $key + * @param string|int|float|bool|array $value + * @since 31.0.0 + */ + public function setMetaValue(string $key, string|int|float|bool|array $value): void { + $this->metadata[$key] = $value; + } + + /** + * @return array + * @since 31.0.0 + */ + public function jsonSerialize(): array { + return [ + 'keyId' => $this->getKeyId(), + 'publicKeyPem' => $this->getPublicKey() + ]; + } +} diff --git a/version.php b/version.php index 1b1c154f4a6fe..e87981f0ec244 100644 --- a/version.php +++ b/version.php @@ -9,7 +9,7 @@ // between betas, final and RCs. This is _not_ the public version number. Reset minor/patch level // when updating major/minor version number. -$OC_Version = [31, 0, 0, 5]; +$OC_Version = [31, 0, 0, 6]; // The human-readable string $OC_VersionString = '31.0.0 dev';