diff --git a/apps/cloud_federation_api/lib/Capabilities.php b/apps/cloud_federation_api/lib/Capabilities.php index ca4ea928cb823..1910a03233791 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\PublicPrivateKeyPairs\Exceptions\KeyPairException; +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|KeyPairException $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..db7f81d559675 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -5,6 +5,13 @@ */ namespace OCA\CloudFederationAPI\Controller; +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\ISignatureManager; +use NCU\Security\Signature\Model\IIncomingSignedRequest; +use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; use OCA\CloudFederationAPI\ResponseDefinitions; use OCP\AppFramework\Controller; @@ -22,11 +29,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 +60,12 @@ public function __construct( private IURLGenerator $urlGenerator, private ICloudFederationProviderManager $cloudFederationProviderManager, private Config $config, + 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 +95,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 +231,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 || @@ -286,4 +319,124 @@ 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 { + return $this->signatureManager->getIncomingSignedRequest($this->signatoryManager); + } catch (SignatureNotFoundException|SignatoryNotFoundException $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->notice('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->searchSignatory($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 from different instance'); + } + } + + + /** + * 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 + * + * @return void + * @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); + $entry = $share->getSharedWith(); + + $instance = $this->getHostFromFederationId($entry); + if ($signedRequest === null) { + try { + $this->signatureManager->searchSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } elseif ($instance !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('token sharedWith from different instance'); + } + } + + /** + * @param string $entry + * @return string + * @throws IncomingRequestException + */ + private function getHostFromFederationId(string $entry): string { + if (!str_contains($entry, '@')) { + throw new IncomingRequestException('entry does not contains @'); + } + [, $rightPart] = explode('@', $entry, 2); + + $host = parse_url($rightPart, PHP_URL_HOST); + $port = parse_url($rightPart, PHP_URL_PORT); + if ($port !== null && $port !== false) { + $host .= ':' . $port; + } + + if (is_string($host) && $host !== '') { + return $host; + } + + throw new IncomingRequestException('host is empty'); + } } 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/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/federation_features/cleanup-remote-storage.feature b/build/integration/federation_features/cleanup-remote-storage.feature index 6339edb60b66e..a017b59bcf4cf 100644 --- a/build/integration/federation_features/cleanup-remote-storage.feature +++ b/build/integration/federation_features/cleanup-remote-storage.feature @@ -35,6 +35,9 @@ Feature: cleanup-remote-storage # 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 sending "GET" to "/apps/files_sharing/api/v1/shares" + And the list of returned shares has 1 shares And Using server "LOCAL" # Accept and download the file to ensure that a storage is created for the # federated share 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 13fb25ab303f5..cce9c7353aecb 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -7,6 +7,30 @@ return array( 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Exceptions\\KeyPairConflictException' => $baseDir . '/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairConflictException.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Exceptions\\KeyPairException' => $baseDir . '/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairException.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Exceptions\\KeyPairNotFoundException' => $baseDir . '/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairNotFoundException.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\IKeyPairManager' => $baseDir . '/lib/unstable/Security/PublicPrivateKeyPairs/IKeyPairManager.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Model\\IKeyPair' => $baseDir . '/lib/unstable/Security/PublicPrivateKeyPairs/Model/IKeyPair.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\\IncomingRequestNotFoundException' => $baseDir . '/lib/unstable/Security/Signature/Exceptions/IncomingRequestNotFoundException.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\\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\\ISignatoryManager' => $baseDir . '/lib/unstable/Security/Signature/ISignatoryManager.php', + 'NCU\\Security\\Signature\\ISignatureManager' => $baseDir . '/lib/unstable/Security/Signature/ISignatureManager.php', + 'NCU\\Security\\Signature\\Model\\IIncomingSignedRequest' => $baseDir . '/lib/unstable/Security/Signature/Model/IIncomingSignedRequest.php', + 'NCU\\Security\\Signature\\Model\\IOutgoingSignedRequest' => $baseDir . '/lib/unstable/Security/Signature/Model/IOutgoingSignedRequest.php', + 'NCU\\Security\\Signature\\Model\\ISignatory' => $baseDir . '/lib/unstable/Security/Signature/Model/ISignatory.php', + 'NCU\\Security\\Signature\\Model\\ISignedRequest' => $baseDir . '/lib/unstable/Security/Signature/Model/ISignedRequest.php', + 'NCU\\Security\\Signature\\Model\\SignatoryStatus' => $baseDir . '/lib/unstable/Security/Signature/Model/SignatoryStatus.php', + 'NCU\\Security\\Signature\\Model\\SignatoryType' => $baseDir . '/lib/unstable/Security/Signature/Model/SignatoryType.php', + 'NCU\\Security\\Signature\\SignatureAlgorithm' => $baseDir . '/lib/unstable/Security/Signature/SignatureAlgorithm.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', @@ -1386,6 +1410,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\\Version31000Date20241018063111' => $baseDir . '/core/Migrations/Version31000Date20241018063111.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php', @@ -1725,6 +1750,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', @@ -1894,6 +1920,8 @@ 'OC\\Security\\Ip\\Range' => $baseDir . '/lib/private/Security/Ip/Range.php', 'OC\\Security\\Ip\\RemoteAddress' => $baseDir . '/lib/private/Security/Ip/RemoteAddress.php', 'OC\\Security\\Normalizer\\IpAddress' => $baseDir . '/lib/private/Security/Normalizer/IpAddress.php', + 'OC\\Security\\PublicPrivateKeyPairs\\KeyPairManager' => $baseDir . '/lib/private/Security/PublicPrivateKeyPairs/KeyPairManager.php', + 'OC\\Security\\PublicPrivateKeyPairs\\Model\\KeyPair' => $baseDir . '/lib/private/Security/PublicPrivateKeyPairs/Model/KeyPair.php', 'OC\\Security\\RateLimiting\\Backend\\DatabaseBackend' => $baseDir . '/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php', 'OC\\Security\\RateLimiting\\Backend\\IBackend' => $baseDir . '/lib/private/Security/RateLimiting/Backend/IBackend.php', 'OC\\Security\\RateLimiting\\Backend\\MemoryCacheBackend' => $baseDir . '/lib/private/Security/RateLimiting/Backend/MemoryCacheBackend.php', @@ -1901,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\\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\\Signatory' => $baseDir . '/lib/private/Security/Signature/Model/Signatory.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 526782636fbb5..bd7e92bb0712c 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -48,6 +48,30 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 public static $classMap = array ( 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Exceptions\\KeyPairConflictException' => __DIR__ . '/../../..' . '/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairConflictException.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Exceptions\\KeyPairException' => __DIR__ . '/../../..' . '/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairException.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Exceptions\\KeyPairNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairNotFoundException.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\IKeyPairManager' => __DIR__ . '/../../..' . '/lib/unstable/Security/PublicPrivateKeyPairs/IKeyPairManager.php', + 'NCU\\Security\\PublicPrivateKeyPairs\\Model\\IKeyPair' => __DIR__ . '/../../..' . '/lib/unstable/Security/PublicPrivateKeyPairs/Model/IKeyPair.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\\IncomingRequestNotFoundException' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Exceptions/IncomingRequestNotFoundException.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\\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\\ISignatoryManager' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignatoryManager.php', + 'NCU\\Security\\Signature\\ISignatureManager' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/ISignatureManager.php', + 'NCU\\Security\\Signature\\Model\\IIncomingSignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/IIncomingSignedRequest.php', + 'NCU\\Security\\Signature\\Model\\IOutgoingSignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/IOutgoingSignedRequest.php', + 'NCU\\Security\\Signature\\Model\\ISignatory' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/ISignatory.php', + 'NCU\\Security\\Signature\\Model\\ISignedRequest' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/ISignedRequest.php', + 'NCU\\Security\\Signature\\Model\\SignatoryStatus' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/SignatoryStatus.php', + 'NCU\\Security\\Signature\\Model\\SignatoryType' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/Model/SignatoryType.php', + 'NCU\\Security\\Signature\\SignatureAlgorithm' => __DIR__ . '/../../..' . '/lib/unstable/Security/Signature/SignatureAlgorithm.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', @@ -1427,6 +1451,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\\Version31000Date20241018063111' => __DIR__ . '/../../..' . '/core/Migrations/Version31000Date20241018063111.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php', @@ -1766,6 +1791,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', @@ -1935,6 +1961,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Security\\Ip\\Range' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/Range.php', 'OC\\Security\\Ip\\RemoteAddress' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/RemoteAddress.php', 'OC\\Security\\Normalizer\\IpAddress' => __DIR__ . '/../../..' . '/lib/private/Security/Normalizer/IpAddress.php', + 'OC\\Security\\PublicPrivateKeyPairs\\KeyPairManager' => __DIR__ . '/../../..' . '/lib/private/Security/PublicPrivateKeyPairs/KeyPairManager.php', + 'OC\\Security\\PublicPrivateKeyPairs\\Model\\KeyPair' => __DIR__ . '/../../..' . '/lib/private/Security/PublicPrivateKeyPairs/Model/KeyPair.php', 'OC\\Security\\RateLimiting\\Backend\\DatabaseBackend' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php', 'OC\\Security\\RateLimiting\\Backend\\IBackend' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Backend/IBackend.php', 'OC\\Security\\RateLimiting\\Backend\\MemoryCacheBackend' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Backend/MemoryCacheBackend.php', @@ -1942,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\\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\\Signatory' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/Signatory.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..eeb161c3b2514 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; @@ -18,6 +20,7 @@ use OCP\Federation\ICloudIdManager; 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 +40,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, ) { } @@ -106,9 +112,17 @@ public function sendShare(ICloudFederationShare $share) { $client = $this->httpClientService->newClient(); try { - $response = $client->post($ocmProvider->getEndPoint() . '/shares', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($share->getShare()), - ])); + // signing the payload using OCMSignatoryManager before initializing the request + $uri = $ocmProvider->getEndPoint() . '/shares'; + $payload = array_merge($this->getDefaultRequestOptions(), ['body' => json_encode($share->getShare())]); + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $payload, + 'post', $uri + ); + } + $response = $client->post($uri, $signedPayload ?? $payload); if ($response->getStatusCode() === Http::STATUS_CREATED) { $result = json_decode($response->getBody(), true); @@ -139,9 +153,18 @@ public function sendCloudShare(ICloudFederationShare $share): IResponse { $client = $this->httpClientService->newClient(); try { - return $client->post($ocmProvider->getEndPoint() . '/shares', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($share->getShare()), - ])); + // signing the payload using OCMSignatoryManager before initializing the request + $uri = $ocmProvider->getEndPoint() . '/shares'; + $payload = array_merge($this->getDefaultRequestOptions(), ['body' => json_encode($share->getShare())]); + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $payload, + 'post', $uri + ); + } + + return $client->post($uri, $signedPayload ?? $payload); } catch (\Throwable $e) { $this->logger->error('Error while sending share to federation server: ' . $e->getMessage(), ['exception' => $e]); try { @@ -167,9 +190,19 @@ public function sendNotification($url, ICloudFederationNotification $notificatio $client = $this->httpClientService->newClient(); try { - $response = $client->post($ocmProvider->getEndPoint() . '/notifications', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($notification->getMessage()), - ])); + + // signing the payload using OCMSignatoryManager before initializing the request + $uri = $ocmProvider->getEndPoint() . '/notifications'; + $payload = array_merge($this->getDefaultRequestOptions(), ['body' => json_encode($notification->getMessage())]); + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $payload, + 'post', $uri + ); + } + $response = $client->post($uri, $signedPayload ?? $payload); + if ($response->getStatusCode() === Http::STATUS_CREATED) { $result = json_decode($response->getBody(), true); return (is_array($result)) ? $result : []; @@ -193,9 +226,17 @@ public function sendCloudNotification(string $url, ICloudFederationNotification $client = $this->httpClientService->newClient(); try { - return $client->post($ocmProvider->getEndPoint() . '/notifications', array_merge($this->getDefaultRequestOptions(), [ - 'body' => json_encode($notification->getMessage()), - ])); + // signing the payload using OCMSignatoryManager before initializing the request + $uri = $ocmProvider->getEndPoint() . '/notifications'; + $payload = array_merge($this->getDefaultRequestOptions(), ['body' => json_encode($notification->getMessage())]); + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( + $this->signatoryManager, + $payload, + 'post', $uri + ); + } + return $client->post($uri, $signedPayload ?? $payload); } catch (\Throwable $e) { $this->logger->error('Error while sending notification to federation server: ' . $e->getMessage(), ['exception' => $e]); try { @@ -216,15 +257,11 @@ public function isReady() { } 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/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php index 10670d6331a0e..597b3f4748888 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -64,6 +64,7 @@ class DAV extends Common { protected $httpClientService; /** @var ICertificateManager */ protected $certManager; + protected bool $verify = true; protected LoggerInterface $logger; protected IEventLogger $eventLogger; protected IMimeTypeDetector $mimeTypeDetector; @@ -103,6 +104,7 @@ public function __construct(array $parameters) { if (isset($parameters['authType'])) { $this->authType = $parameters['authType']; } + $this->verify = (($parameters['verify'] ?? true) !== false); if (isset($parameters['secure'])) { if (is_string($parameters['secure'])) { $this->secure = ($parameters['secure'] === 'true'); @@ -162,6 +164,11 @@ protected function init(): void { } } + if (!$this->verify) { + $this->client->addCurlSetting(CURLOPT_SSL_VERIFYHOST, 0); + $this->client->addCurlSetting(CURLOPT_SSL_VERIFYPEER, false); + } + $lastRequestStart = 0; $this->client->on('beforeRequest', function (RequestInterface $request) use (&$lastRequestStart) { $this->logger->debug('sending dav ' . $request->getMethod() . ' request to external storage: ' . $request->getAbsoluteUrl(), ['app' => 'dav']); diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index 73002ae668de1..cd4e9c49c3b29 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -9,6 +9,8 @@ namespace OC\OCM\Model; +use NCU\Security\Signature\Model\ISignatory; +use OC\Security\Signature\Model\Signatory; use OCP\EventDispatcher\IEventDispatcher; use OCP\OCM\Events\ResourceTypeRegisterEvent; use OCP\OCM\Exceptions\OCMArgumentException; @@ -25,7 +27,7 @@ class OCMProvider implements IOCMProvider { private string $endPoint = ''; /** @var IOCMResource[] */ private array $resourceTypes = []; - + private ?ISignatory $signatory = null; private bool $emittedEvent = false; public function __construct( @@ -152,6 +154,14 @@ public function extractProtocolEntry(string $resourceName, string $protocol): st throw new OCMArgumentException('resource not found'); } + public function setSignatory(ISignatory $signatory): void { + $this->signatory = $signatory; + } + + public function getSignatory(): ?ISignatory { + return $this->signatory; + } + /** * import data from an array * @@ -163,7 +173,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 +183,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 +204,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: ISignatory|null, + * resourceTypes: array{ + * name: string, + * shareTypes: list, + * protocols: array + * }[], + * version: string + * } */ public function jsonSerialize(): array { $resourceTypes = []; @@ -209,8 +226,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..f39d0e2382177 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, @@ -56,9 +50,7 @@ public function discover(string $remote, bool $skipCache = false): IOCMProvider 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 } @@ -94,30 +86,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..1508c1db1ef1b --- /dev/null +++ b/lib/private/OCM/OCMSignatoryManager.php @@ -0,0 +1,149 @@ +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(); + } + + try { + $keyPair = $this->keyPairManager->getKeyPair('core', 'ocm_external'); + } catch (KeyPairNotFoundException) { + $keyPair = $this->keyPairManager->generateKeyPair('core', 'ocm_external'); + } + + return new Signatory($keyId, $keyPair->getPublicKey(), $keyPair->getPrivateKey(), 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 IIncomingSignedRequest $signedRequest + * + * @return ISignatory|null must be NULL if no signatory is found + * @throws OCMProviderException on fail to discover ocm services + * @since 31.0.0 + */ + public function getRemoteSignatory(IIncomingSignedRequest $signedRequest): ?ISignatory { + return $this->getRemoteSignatoryFromHost($signedRequest->getOrigin()); + } + + /** + * As host is enough to generate signatory using OCMDiscoveryService + * + * @param string $host + * + * @return ISignatory|null + * @throws OCMProviderException on fail to discover ocm services + * @since 31.0.0 + */ + public function getRemoteSignatoryFromHost(string $host): ?ISignatory { + $ocmProvider = $this->ocmDiscoveryService->discover($host, true); + $signatory = $ocmProvider->getSignatory(); + + return $signatory?->setType(SignatoryType::TRUSTED); + } +} diff --git a/lib/private/Security/PublicPrivateKeyPairs/KeyPairManager.php b/lib/private/Security/PublicPrivateKeyPairs/KeyPairManager.php new file mode 100644 index 0000000000000..0af960b3a30a8 --- /dev/null +++ b/lib/private/Security/PublicPrivateKeyPairs/KeyPairManager.php @@ -0,0 +1,182 @@ +hasKeyPair($app, $name)) { + throw new KeyPairConflictException('key pair already exist'); + } + + $keyPair = new KeyPair($app, $name); + + [$publicKey, $privateKey] = $this->generateKeys($options); + $keyPair->setPublicKey($publicKey) + ->setPrivateKey($privateKey) + ->setOptions($options); + + $this->appConfig->setValueArray( + $app, $this->generateAppConfigKey($name), + [ + 'public' => $keyPair->getPublicKey(), + 'private' => $keyPair->getPrivateKey(), + 'options' => $keyPair->getOptions() + ], + lazy: true, + sensitive: true + ); + + return $keyPair; + } + + /** + * @inheritDoc + * + * @param string $app appId + * @param string $name key name + * + * @return bool TRUE if key pair exists in database + * @since 31.0.0 + */ + public function hasKeyPair(string $app, string $name): bool { + $key = $this->generateAppConfigKey($name); + return $this->appConfig->hasKey($app, $key, lazy: true); + } + + /** + * @inheritDoc + * + * @param string $app appId + * @param string $name key name + * + * @return IKeyPair + * @throws KeyPairNotFoundException if key pair is not known + * @since 31.0.0 + */ + public function getKeyPair(string $app, string $name): IKeyPair { + if (!$this->hasKeyPair($app, $name)) { + throw new KeyPairNotFoundException('unknown key pair'); + } + + $key = $this->generateAppConfigKey($name); + $stored = $this->appConfig->getValueArray($app, $key, lazy: true); + if (!array_key_exists('public', $stored) || + !array_key_exists('private', $stored)) { + throw new KeyPairNotFoundException('corrupted key pair'); + } + + $keyPair = new KeyPair($app, $name); + return $keyPair->setPublicKey($stored['public']) + ->setPrivateKey($stored['private']) + ->setOptions($stored['options'] ?? []); + } + + /** + * @inheritDoc + * + * @param string $app appid + * @param string $name key name + * + * @since 31.0.0 + */ + public function deleteKeyPair(string $app, string $name): void { + $this->appConfig->deleteKey('core', $this->generateAppConfigKey($name)); + } + + /** + * @inheritDoc + * + * @param IKeyPair $keyPair keypair to test + * + * @return bool + * @since 31.0.0 + */ + public function testKeyPair(IKeyPair $keyPair): bool { + $clear = md5((string)time()); + + // signing with private key + openssl_sign($clear, $signed, $keyPair->getPrivateKey(), OPENSSL_ALGO_SHA256); + $encoded = base64_encode($signed); + + // verify with public key + $signed = base64_decode($encoded); + return (openssl_verify($clear, $signed, $keyPair->getPublicKey(), 'sha256') === 1); + } + + /** + * return appconfig key based on name of the key pair + * + * @param string $name + * + * @return string + */ + private function generateAppConfigKey(string $name): string { + return self::CONFIG_PREFIX . $name; + } + + /** + * generate the key pair, based on $options with the following default values: + * [ + * 'algorithm' => 'rsa', + * 'bits' => 2048, + * 'type' => OPENSSL_KEYTYPE_RSA + * ] + * + * @param array $options + * + * @return array + */ + private function generateKeys(array $options = []): array { + $res = openssl_pkey_new( + [ + 'digest_alg' => $options['algorithm'] ?? 'rsa', + 'private_key_bits' => $options['bits'] ?? 2048, + 'private_key_type' => $options['type'] ?? OPENSSL_KEYTYPE_RSA, + ] + ); + + openssl_pkey_export($res, $privateKey); + $publicKey = openssl_pkey_get_details($res)['key']; + + return [$publicKey, $privateKey]; + } +} diff --git a/lib/private/Security/PublicPrivateKeyPairs/Model/KeyPair.php b/lib/private/Security/PublicPrivateKeyPairs/Model/KeyPair.php new file mode 100644 index 0000000000000..523f7c1c38083 --- /dev/null +++ b/lib/private/Security/PublicPrivateKeyPairs/Model/KeyPair.php @@ -0,0 +1,114 @@ +app; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getName(): string { + return $this->name; + } + + /** + * @inheritDoc + * + * @param string $publicKey + * @return IKeyPair + * @since 31.0.0 + */ + public function setPublicKey(string $publicKey): IKeyPair { + $this->publicKey = $publicKey; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getPublicKey(): string { + return $this->publicKey; + } + + /** + * @inheritDoc + * + * @param string $privateKey + * @return IKeyPair + * @since 31.0.0 + */ + public function setPrivateKey(string $privateKey): IKeyPair { + $this->privateKey = $privateKey; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getPrivateKey(): string { + return $this->privateKey; + } + + /** + * @inheritDoc + * + * @param array $options + * @return IKeyPair + * @since 31.0.0 + */ + public function setOptions(array $options): IKeyPair { + $this->options = $options; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getOptions(): array { + return $this->options; + } +} diff --git a/lib/private/Security/Signature/Model/IncomingSignedRequest.php b/lib/private/Security/Signature/Model/IncomingSignedRequest.php new file mode 100644 index 0000000000000..8fe83a7b09bd2 --- /dev/null +++ b/lib/private/Security/Signature/Model/IncomingSignedRequest.php @@ -0,0 +1,170 @@ +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 IRequest $request + * @return IIncomingSignedRequest + * @since 31.0.0 + */ + public function setRequest(IRequest $request): IIncomingSignedRequest { + $this->request = $request; + return $this; + } + + /** + * @inheritDoc + * + * @return IRequest + * @throws IncomingRequestNotFoundException + * @since 31.0.0 + */ + public function getRequest(): IRequest { + if ($this->request === null) { + throw new IncomingRequestNotFoundException(); + } + return $this->request; + } + + /** + * @inheritDoc + * + * @param int $time + * @return IIncomingSignedRequest + * @since 31.0.0 + */ + public function setTime(int $time): IIncomingSignedRequest { + $this->time = $time; + return $this; + } + + /** + * @inheritDoc + * + * @return int + * @since 31.0.0 + */ + public function getTime(): int { + return $this->time; + } + + /** + * @inheritDoc + * + * @param string $origin + * @return IIncomingSignedRequest + * @since 31.0.0 + */ + public function setOrigin(string $origin): IIncomingSignedRequest { + $this->origin = $origin; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getOrigin(): string { + 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 + * @since 31.0.0 + */ + public function getKeyId(): string { + return $this->getSignatureHeader()['keyId'] ?? ''; + } + + /** + * @inheritDoc + * + * @param string $signature + * @return IIncomingSignedRequest + * @since 31.0.0 + */ + public function setEstimatedSignature(string $signature): IIncomingSignedRequest { + $this->estimatedSignature = $signature; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getEstimatedSignature(): string { + return $this->estimatedSignature; + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'body' => $this->getBody(), + 'time' => $this->getTime(), + 'incomingRequest' => $this->request ?? false, + 'origin' => $this->getOrigin(), + 'keyId' => $this->getKeyId(), + 'estimatedSignature' => $this->getEstimatedSignature(), + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php new file mode 100644 index 0000000000000..04efcf8bfe188 --- /dev/null +++ b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php @@ -0,0 +1,131 @@ +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|bool|array $value + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function addHeader(string $key, string|int|float|bool|array $value): IOutgoingSignedRequest { + $this->headers[$key] = $value; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getHeaders(): array { + return $this->headers; + } + + /** + * @inheritDoc + * + * @param string $estimated + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setClearSignature(string $estimated): IOutgoingSignedRequest { + $this->clearSignature = $estimated; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getClearSignature(): string { + return $this->clearSignature; + } + + /** + * @inheritDoc + * + * @param string $algorithm + * + * @return IOutgoingSignedRequest + * @since 31.0.0 + */ + public function setAlgorithm(string $algorithm): IOutgoingSignedRequest { + $this->algorithm = $algorithm; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getAlgorithm(): string { + return $this->algorithm; + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'headers' => $this->headers, + 'host' => $this->getHost(), + 'clearSignature' => $this->getClearSignature(), + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/Signatory.php b/lib/private/Security/Signature/Model/Signatory.php new file mode 100644 index 0000000000000..b28d2c0415f09 --- /dev/null +++ b/lib/private/Security/Signature/Model/Signatory.php @@ -0,0 +1,147 @@ +keyId = $keyId; + } + } + + public function setProviderId(string $providerId): self { + $this->providerId = $providerId; + return $this; + } + + public function getProviderId(): string { + return $this->providerId; + } + + public function setAccount(string $account): self { + $this->account = $account; + return $this; + } + + public function getAccount(): string { + return $this->account; + } + + public function getKeyId(): string { + return $this->keyId; + } + + public function getPublicKey(): string { + return $this->publicKey; + } + + public function getPrivateKey(): string { + return $this->privateKey; + } + + public function setMetadata(array $metadata): self { + $this->metadata = $metadata; + return $this; + } + + public function getMetadata(): array { + return $this->metadata; + } + + public function setMetaValue(string $key, string|int $value): self { + $this->metadata[$key] = $value; + return $this; + } + + public function setType(SignatoryType $type): self { + $this->type = $type; + return $this; + } + public function getType(): SignatoryType { + return $this->type; + } + + public function setStatus(SignatoryStatus $status): self { + $this->status = $status; + return $this; + } + + public function getStatus(): SignatoryStatus { + return $this->status; + } + + public function setCreation(int $creation): self { + $this->creation = $creation; + return $this; + } + + public function getCreation(): int { + return $this->creation; + } + + public function setLastUpdated(int $lastUpdated): self { + $this->lastUpdated = $lastUpdated; + return $this; + } + + public function getLastUpdated(): int { + return $this->lastUpdated; + } + + public function importFromDatabase(array $row): self { + $this->setProviderId($row['provider_id'] ?? '') + ->setAccount($row['account'] ?? '') + ->setMetadata(json_decode($row['metadata'], true) ?? []) + ->setType(SignatoryType::from($row['type'] ?? 9)) + ->setStatus(SignatoryStatus::from($row['status'] ?? 1)) + ->setCreation($row['creation'] ?? 0) + ->setLastUpdated($row['last_updated'] ?? 0); + return $this; + } + + public function jsonSerialize(): array { + return [ + 'keyId' => $this->getKeyId(), + 'publicKeyPem' => $this->getPublicKey() + ]; + } +} diff --git a/lib/private/Security/Signature/Model/SignedRequest.php b/lib/private/Security/Signature/Model/SignedRequest.php new file mode 100644 index 0000000000000..1587da9d63149 --- /dev/null +++ b/lib/private/Security/Signature/Model/SignedRequest.php @@ -0,0 +1,143 @@ +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 $signatureHeader + * @return ISignedRequest + * @since 31.0.0 + */ + public function setSignatureHeader(array $signatureHeader): ISignedRequest { + $this->signatureHeader = $signatureHeader; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getSignatureHeader(): array { + return $this->signatureHeader; + } + + /** + * @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 ISignatory $signatory + * @return ISignedRequest + * @since 31.0.0 + */ + public function setSignatory(ISignatory $signatory): ISignedRequest { + $this->signatory = $signatory; + return $this; + } + + /** + * @inheritDoc + * + * @return ISignatory + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function getSignatory(): ISignatory { + 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->getBody(), + 'signatureHeader' => $this->getSignatureHeader(), + 'signedSignature' => $this->getSignedSignature(), + '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..d087e8ebdeb0c --- /dev/null +++ b/lib/private/Security/Signature/SignatureManager.php @@ -0,0 +1,828 @@ + self::BODY_MAXSIZE) { + throw new IncomingRequestException('content of request is too big'); + } + + $signedRequest = new IncomingSignedRequest($body); + $signedRequest->setRequest($this->request); + $options = $signatoryManager->getOptions(); + + try { + $this->verifyIncomingRequestTime($signedRequest, $options['ttl'] ?? self::DATE_TTL); + $this->verifyIncomingRequestContent($signedRequest); + $this->prepIncomingSignatureHeader($signedRequest); + $this->verifyIncomingSignatureHeader($signedRequest); + $this->prepEstimatedSignature($signedRequest, $options['extraSignatureHeaders'] ?? []); + $this->verifyIncomingRequestSignature( + $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; + } + + /** + * @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 + * @since 31.0.0 + */ + public function getOutgoingSignedRequest( + ISignatoryManager $signatoryManager, + string $content, + string $method, + string $uri, + ): IOutgoingSignedRequest { + $signedRequest = new OutgoingSignedRequest($content); + $options = $signatoryManager->getOptions(); + + $signedRequest->setHost($this->getHostFromUri($uri)) + ->setAlgorithm($options['algorithm'] ?? 'sha256') + ->setSignatory($signatoryManager->getLocalSignatory()); + + $this->setOutgoingSignatureHeader( + $signedRequest, + strtolower($method), + parse_url($uri, PHP_URL_PATH) ?? '/', + $options['dateHeader'] ?? self::DATE_HEADER + ); + $this->setOutgoingClearSignature($signedRequest); + $this->setOutgoingSignedSignature($signedRequest); + $this->signingOutgoingRequest($signedRequest); + + 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 ISignatory + * @throws SignatoryNotFoundException if entry does not exist in local database + * @since 31.0.0 + */ + public function searchSignatory(string $host, string $account = ''): ISignatory { + $qb = $this->connection->getQueryBuilder(); + $qb->select( + 'id', 'provider_id', 'host', 'account', 'key_id', 'key_id_sum', 'public_key', 'metadata', 'type', + 'status', 'creation', 'last_updated' + ); + $qb->from(self::TABLE_SIGNATORIES); + $qb->where($qb->expr()->eq('host', $qb->createNamedParameter($host))); + $qb->andWhere($qb->expr()->eq('account', $qb->createNamedParameter($account))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if (!$row) { + throw new SignatoryNotFoundException('no signatory found'); + } + + $signature = new Signatory($row['key_id'], $row['public_key']); + + return $signature->importFromDatabase($row); + } + + + /** + * @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; + } + + /** + * using the requested 'date' entry from header to confirm request is not older than ttl + * + * @param IIncomingSignedRequest $signedRequest + * @param int $ttl + * + * @throws IncomingRequestException + * @throws SignatureNotFoundException + */ + private function verifyIncomingRequestTime(IIncomingSignedRequest $signedRequest, int $ttl): void { + $request = $signedRequest->getRequest(); + $date = $request->getHeader('date'); + if ($date === '') { + throw new SignatureNotFoundException('missing date in header'); + } + + try { + $dTime = new \DateTime($date); + $signedRequest->setTime($dTime->getTimestamp()); + } catch (\Exception $e) { + $this->logger->warning( + 'datetime exception', ['exception' => $e, 'header' => $request->getHeader('date')] + ); + throw new IncomingRequestException('datetime exception'); + } + + if ($signedRequest->getTime() < (time() - $ttl)) { + throw new IncomingRequestException('object is too old'); + } + } + + + /** + * confirm the values of 'content-length' and 'digest' from header + * is related to request content + * + * @param IIncomingSignedRequest $signedRequest + * + * @throws IncomingRequestException + * @throws SignatureNotFoundException + */ + private function verifyIncomingRequestContent(IIncomingSignedRequest $signedRequest): void { + $request = $signedRequest->getRequest(); + $contentLength = $request->getHeader('content-length'); + if ($contentLength === '') { + throw new SignatureNotFoundException('missing content-length in header'); + } + + if (strlen($signedRequest->getBody()) !== (int)$request->getHeader('content-length')) { + throw new IncomingRequestException( + 'inexact content-length in header: ' . strlen($signedRequest->getBody()) . ' vs ' + . (int)$request->getHeader('content-length') + ); + } + + $digest = $request->getHeader('digest'); + if ($digest === '') { + throw new SignatureNotFoundException('missing digest in header'); + } + + if ($digest !== $signedRequest->getDigest()) { + throw new IncomingRequestException('invalid value for digest in header'); + } + } + + /** + * preparing a clear version of the signature based on list of metadata from the + * Signature entry in header + * + * @param IIncomingSignedRequest $signedRequest + * + * @throws SignatureNotFoundException + */ + private function prepIncomingSignatureHeader(IIncomingSignedRequest $signedRequest): void { + $sign = []; + $request = $signedRequest->getRequest(); + $signature = $request->getHeader('Signature'); + if ($signature === '') { + throw new SignatureNotFoundException('missing Signature in header'); + } + + foreach (explode(',', $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; + } + + $signedRequest->setSignatureHeader($sign); + } + + + /** + * @param IIncomingSignedRequest $signedRequest + * + * @throws IncomingRequestException + * @throws InvalidKeyOriginException + */ + private function verifyIncomingSignatureHeader(IIncomingSignedRequest $signedRequest): void { + $data = $signedRequest->getSignatureHeader(); + if (!array_key_exists('keyId', $data) || !array_key_exists('headers', $data) + || !array_key_exists('signature', $data)) { + throw new IncomingRequestException('missing keys in signature headers: ' . json_encode($data)); + } + + try { + $signedRequest->setOrigin($this->getHostFromUri($data['keyId'])); + } catch (\Exception) { + throw new InvalidKeyOriginException('cannot retrieve origin from ' . $data['keyId']); + } + + $signedRequest->setSignedSignature($data['signature']); + } + + + /** + * @param IIncomingSignedRequest $signedRequest + * @param array $extraSignatureHeaders + * + * @throws IncomingRequestException + */ + private function prepEstimatedSignature( + IIncomingSignedRequest $signedRequest, + array $extraSignatureHeaders = [], + ): void { + $request = $signedRequest->getRequest(); + $headers = explode(' ', $signedRequest->getSignatureHeader()['headers'] ?? []); + + $enforceHeaders = array_merge( + ['date', 'host', 'content-length', 'digest'], + $extraSignatureHeaders + ); + + $missingHeaders = array_diff($enforceHeaders, $headers); + if ($missingHeaders !== []) { + throw new IncomingRequestException( + 'missing elements in headers: ' . json_encode($missingHeaders) + ); + } + + $target = strtolower($request->getMethod()) . ' ' . $request->getRequestUri(); + $estimated = ['(request-target): ' . $target]; + + foreach ($headers as $key) { + $value = $request->getHeader($key); + if (strtolower($key) === 'host') { + $value = $request->getServerHost(); + } + if ($value === '') { + throw new IncomingRequestException('empty elements in header ' . $key); + } + + $estimated[] = $key . ': ' . $value; + } + + $signedRequest->setEstimatedSignature(implode("\n", $estimated)); + } + + + /** + * @param IIncomingSignedRequest $signedRequest + * @param ISignatoryManager $signatoryManager + * + * @throws SignatoryNotFoundException + * @throws SignatureException + */ + private function verifyIncomingRequestSignature( + IIncomingSignedRequest $signedRequest, + ISignatoryManager $signatoryManager, + int $ttlSignatory, + ): void { + $knownSignatory = null; + try { + $knownSignatory = $this->getStoredSignatory($signedRequest->getKeyId()); + if ($ttlSignatory > 0 && $knownSignatory->getLastUpdated() < (time() - $ttlSignatory)) { + $signatory = $this->getSafeRemoteSignatory($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|SignatureException) { + try { + $signatory = $this->getSafeRemoteSignatory($signatoryManager, $signedRequest); + } catch (SignatoryNotFoundException $e) { + $this->manageDeprecatedSignatory($knownSignatory); + throw $e; + } + + $signedRequest->setSignatory($signatory); + $this->storeSignatory($signatory); + $this->verifySignedRequest($signedRequest); + } + } + + + /** + * @param ISignatoryManager $signatoryManager + * @param IIncomingSignedRequest $signedRequest + * + * @return ISignatory + * @throws InvalidKeyOriginException + * @throws SignatoryNotFoundException + */ + private function getSafeRemoteSignatory( + ISignatoryManager $signatoryManager, + IIncomingSignedRequest $signedRequest, + ): ISignatory { + $signatory = $signatoryManager->getRemoteSignatory($signedRequest); + if ($signatory === null) { + throw new SignatoryNotFoundException('empty result from getRemoteSignatory'); + } + if ($signatory->getKeyId() !== $signedRequest->getKeyId()) { + throw new InvalidKeyOriginException('keyId from signatory not related to the one from request'); + } + + return $signatory->setProviderId($signatoryManager->getProviderId()); + } + + private function setOutgoingSignatureHeader( + IOutgoingSignedRequest $signedRequest, + string $method, + string $path, + string $dateHeader, + ): void { + $header = [ + '(request-target)' => $method . ' ' . $path, + 'content-length' => strlen($signedRequest->getBody()), + 'date' => gmdate($dateHeader), + 'digest' => $signedRequest->getDigest(), + 'host' => $signedRequest->getHost() + ]; + + $signedRequest->setSignatureHeader($header); + } + + + /** + * @param IOutgoingSignedRequest $signedRequest + */ + private function setOutgoingClearSignature(IOutgoingSignedRequest $signedRequest): void { + $signing = []; + $header = $signedRequest->getSignatureHeader(); + foreach (array_keys($header) as $element) { + $value = $header[$element]; + $signing[] = $element . ': ' . $value; + if ($element !== '(request-target)') { + $signedRequest->addHeader($element, $value); + } + } + + $signedRequest->setClearSignature(implode("\n", $signing)); + } + + + private function setOutgoingSignedSignature(IOutgoingSignedRequest $signedRequest): void { + $clear = $signedRequest->getClearSignature(); + $signed = $this->signString( + $clear, $signedRequest->getSignatory()->getPrivateKey(), $signedRequest->getAlgorithm() + ); + $signedRequest->setSignedSignature($signed); + } + + private function signingOutgoingRequest(IOutgoingSignedRequest $signedRequest): void { + $signatureHeader = $signedRequest->getSignatureHeader(); + $headers = array_diff(array_keys($signatureHeader), ['(request-target)']); + $signatory = $signedRequest->getSignatory(); + $signatureElements = [ + 'keyId="' . $signatory->getKeyId() . '"', + 'algorithm="' . $this->getChosenEncryption($signedRequest->getAlgorithm()) . '"', + 'headers="' . implode(' ', $headers) . '"', + 'signature="' . $signedRequest->getSignedSignature() . '"' + ]; + + $signedRequest->addHeader('Signature', implode(',', $signatureElements)); + } + + + /** + * @param IIncomingSignedRequest $signedRequest + * + * @return void + * @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->getEstimatedSignature(), + $signedRequest->getSignedSignature(), + $publicKey, + $this->getUsedEncryption($signedRequest) + ); + } catch (InvalidSignatureException $e) { + $this->logger->debug('signature issue', ['signed' => $signedRequest, 'exception' => $e]); + throw $e; + } + } + + + private function getUsedEncryption(IIncomingSignedRequest $signedRequest): SignatureAlgorithm { + $data = $signedRequest->getSignatureHeader(); + + return match ($data['algorithm']) { + 'rsa-sha512' => SignatureAlgorithm::SHA512, + default => SignatureAlgorithm::SHA256, + }; + } + + private function getChosenEncryption(string $algorithm): string { + return match ($algorithm) { + 'sha512' => 'ras-sha512', + default => 'ras-sha256', + }; + } + + public function getOpenSSLAlgo(string $algorithm): int { + return match ($algorithm) { + 'sha512' => OPENSSL_ALGO_SHA512, + default => OPENSSL_ALGO_SHA256, + }; + } + + + /** + * @param string $clear + * @param string $privateKey + * @param string $algorithm + * + * @return string + * @throws SignatoryException + */ + private function signString(string $clear, string $privateKey, string $algorithm): string { + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + openssl_sign($clear, $signed, $privateKey, $this->getOpenSSLAlgo($algorithm)); + + return base64_encode($signed); + } + + /** + * @param string $clear + * @param string $encoded + * @param string $publicKey + * @param SignatureAlgorithm $algo + * + * @return void + * @throws InvalidSignatureException + */ + private function verifyString( + string $clear, + string $encoded, + string $publicKey, + SignatureAlgorithm $algo = SignatureAlgorithm::SHA256, + ): void { + $signed = base64_decode($encoded); + if (openssl_verify($clear, $signed, $publicKey, $algo->value) !== 1) { + throw new InvalidSignatureException('signature issue'); + } + } + + /** + * @param string $keyId + * + * @return ISignatory + * @throws SignatoryNotFoundException + */ + private function getStoredSignatory(string $keyId): ISignatory { + $qb = $this->connection->getQueryBuilder(); + $qb->select( + 'id', 'provider_id', 'host', 'account', 'key_id', 'key_id_sum', 'public_key', 'metadata', 'type', + 'status', 'creation', 'last_updated' + ); + $qb->from(self::TABLE_SIGNATORIES); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if (!$row) { + throw new SignatoryNotFoundException('no signatory found in local'); + } + + $signature = new Signatory($row['key_id'], $row['public_key']); + $signature->importFromDatabase($row); + + return $signature; + } + + /** + * @param ISignatory $signatory + */ + private function storeSignatory(ISignatory $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]); + } + } + } + + private function insertSignatory(ISignatory $signatory): void { + $qb = $this->connection->getQueryBuilder(); + $qb->insert(self::TABLE_SIGNATORIES) + ->setValue('provider_id', $qb->createNamedParameter($signatory->getProviderId())) + ->setValue('host', $qb->createNamedParameter($this->getHostFromUri($signatory->getKeyId()))) + ->setValue('account', $qb->createNamedParameter($signatory->getAccount())) + ->setValue('key_id', $qb->createNamedParameter($signatory->getKeyId())) + ->setValue('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId()))) + ->setValue('public_key', $qb->createNamedParameter($signatory->getPublicKey())) + ->setValue('metadata', $qb->createNamedParameter(json_encode($signatory->getMetadata()))) + ->setValue('type', $qb->createNamedParameter($signatory->getType()->value)) + ->setValue('status', $qb->createNamedParameter($signatory->getStatus()->value)) + ->setValue('creation', $qb->createNamedParameter(time())) + ->setValue('last_updated', $qb->createNamedParameter(time())); + + $qb->executeStatement(); + } + + /** + * @param ISignatory $signatory + * + * @throws SignatoryNotFoundException + * @throws SignatoryConflictException + */ + private function updateKnownSignatory(ISignatory $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(); + break; + + case SignatoryType::STATIC: + // TODO: send warning to admin + throw new SignatoryConflictException(); + break; + } + } + + /** + * This is called when a remote signatory does not exist anymore + * + * @param ISignatory|null $knownSignatory NULL is not known + * + * @throws SignatoryConflictException + * @throws SignatoryNotFoundException + */ + private function manageDeprecatedSignatory(?ISignatory $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(); + + case SignatoryType::TRUSTED: + case SignatoryType::STATIC: + // TODO: send warning to admin + throw new SignatoryConflictException(); + } + } + + + private function updateSignatoryPublicKey(ISignatory $signatory): void { + $qb = $this->connection->getQueryBuilder(); + $qb->update(self::TABLE_SIGNATORIES) + ->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()))) + ); + $qb->executeStatement(); + } + + private function updateSignatoryMetadata(ISignatory $signatory): void { + $qb = $this->connection->getQueryBuilder(); + $qb->update(self::TABLE_SIGNATORIES) + ->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()))) + ); + $qb->executeStatement(); + } + + private function deleteSignatory(string $keyId): void { + $qb = $this->connection->getQueryBuilder(); + $qb->delete(self::TABLE_SIGNATORIES) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + $qb->executeStatement(); + } + + + /** + * @param string $uri + * + * @return string + * @throws InvalidKeyOriginException + */ + private function getHostFromUri(string $uri): string { + $host = parse_url($uri, PHP_URL_HOST); + $port = parse_url($uri, PHP_URL_PORT); + if ($port !== null && $port !== false) { + $host .= ':' . $port; + } + + if (is_string($host) && $host !== '') { + return $host; + } + + throw new \Exception('invalid/empty uri'); + } + + private function hashKeyId(string $keyId): string { + return hash('sha256', $keyId); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index 27a5f2662f822..6f7a56b6344c5 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -7,6 +7,8 @@ namespace OC; use bantu\IniGetWrapper\IniGetWrapper; +use NCU\Security\PublicPrivateKeyPairs\IKeyPairManager; +use NCU\Security\Signature\ISignatureManager; use OC\Accounts\AccountManager; use OC\App\AppManager; use OC\App\AppStore\Bundles\BundleFetcher; @@ -100,8 +102,10 @@ use OC\Security\CSRF\TokenStorage\SessionStorage; use OC\Security\Hasher; use OC\Security\Ip\RemoteAddress; +use OC\Security\PublicPrivateKeyPairs\KeyPairManager; 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; @@ -1178,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(); }); @@ -1295,6 +1288,9 @@ public function __construct($webRoot, \OC\Config $config) { $this->registerAlias(IRichTextFormatter::class, \OC\RichObjectStrings\RichTextFormatter::class); + $this->registerAlias(IKeyPairManager::class, KeyPairManager::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..789462efd7828 100644 --- a/lib/public/OCM/IOCMProvider.php +++ b/lib/public/OCM/IOCMProvider.php @@ -10,6 +10,7 @@ namespace OCP\OCM; use JsonSerializable; +use NCU\Security\Signature\Model\ISignatory; use OCP\OCM\Exceptions\OCMArgumentException; use OCP\OCM\Exceptions\OCMProviderException; @@ -120,6 +121,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 ISignatory $signatory + * @since 31.0.0 + */ + public function setSignatory(ISignatory $signatory): void; + + /** + * signatory (public/private key pair) used to sign outgoing/incoming request + * + * @return ISignatory|null returns null if no ISignatory available + * @since 31.0.0 + */ + public function getSignatory(): ?ISignatory; + /** * import data from an array * @@ -134,13 +151,15 @@ public function import(array $data): static; /** * @return array{ * enabled: bool, - * apiVersion: string, + * apiVersion: '1.0-proposal1', * endPoint: string, - * resourceTypes: list, * protocols: array - * }>, + * }[], + * version: string * } * @since 28.0.0 */ diff --git a/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairConflictException.php b/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairConflictException.php new file mode 100644 index 0000000000000..b80834264dc16 --- /dev/null +++ b/lib/unstable/Security/PublicPrivateKeyPairs/Exceptions/KeyPairConflictException.php @@ -0,0 +1,18 @@ + 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 ISignatory + * @since 31.0.0 + */ + public function getLocalSignatory(): ISignatory; + + /** + * retrieve details and generate signatory from remote instance. + * If signatory cannot be found, returns NULL. + * + * Used to confirm authenticity of incoming request. + * + * @param IIncomingSignedRequest $signedRequest + * + * @return ISignatory|null must be NULL if no signatory is found + * @since 31.0.0 + */ + public function getRemoteSignatory(IIncomingSignedRequest $signedRequest): ?ISignatory; +} diff --git a/lib/unstable/Security/Signature/ISignatureManager.php b/lib/unstable/Security/Signature/ISignatureManager.php new file mode 100644 index 0000000000000..cc0297224dc58 --- /dev/null +++ b/lib/unstable/Security/Signature/ISignatureManager.php @@ -0,0 +1,129 @@ +