Merge pull request #60136 from nextcloud/kano-dual-stack-rfc-9421-http-sig
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, main, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, guests_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, main, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / changes (push) Waiting to run
Psalm static code analysis / static-code-analysis (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-security (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ocp (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-ncu (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-strict (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis-summary (push) Blocked by required conditions

feat(http-sig): Dual stack http-sig
This commit is contained in:
Micke Nordin 2026-05-27 15:59:52 +02:00 committed by GitHub
commit 5ffde0370b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3795 additions and 111 deletions

@ -1 +1 @@
Subproject commit 16dd9453d0d94a90f886b55ca26ddd190f2cd5a0
Subproject commit f7176e8becad6b9ef000422fc6039025b58dd2c9

View file

@ -12,7 +12,6 @@ use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent;
use OCA\CloudFederationAPI\ResponseDefinitions;
use OCA\FederatedFileSharing\AddressHandler;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@ -38,11 +37,8 @@ use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Security\Signature\ISignatureManager;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
@ -69,12 +65,10 @@ class RequestHandlerController extends Controller {
private Config $config,
private IEventDispatcher $dispatcher,
private FederatedInviteMapper $federatedInviteMapper,
private readonly AddressHandler $addressHandler,
private readonly IAppConfig $appConfig,
private ICloudFederationFactory $factory,
private ICloudIdManager $cloudIdManager,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
private readonly ISignatureManager $signatureManager,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
@ -440,6 +434,8 @@ class RequestHandlerController extends Controller {
* If request is not signed, we still verify that the hostname from the extracted value does,
* actually, not support signed request
*
* Delegates to {@see IOCMDiscoveryService::confirmRequestOrigin()}.
*
* @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
@ -447,21 +443,13 @@ class RequestHandlerController extends Controller {
* @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 . ']');
if ($signedRequest !== null) {
$body = json_decode($signedRequest->getBody(), true) ?? [];
$entry = trim(($body[$key] ?? ''), '@');
} else {
$entry = trim($value, '@');
}
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $entry);
}
/**
@ -498,48 +486,6 @@ class RequestHandlerController extends Controller {
throw new IncomingRequestException($e->getMessage(), previous: $e);
}
$this->confirmNotificationEntry($signedRequest, $identity);
}
/**
* @param IIncomingSignedRequest|null $signedRequest
* @param string $entry
*
* @return void
* @throws IncomingRequestException
*/
private function confirmNotificationEntry(?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('remote instance ' . $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 contain @');
}
$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);
}
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
}
}

View file

@ -13,7 +13,6 @@ use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Controller\RequestHandlerController;
use OCA\CloudFederationAPI\Db\FederatedInvite;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
use OCA\FederatedFileSharing\AddressHandler;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Utility\ITimeFactory;
@ -28,7 +27,6 @@ use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
@ -43,13 +41,11 @@ class RequestHandlerControllerTest extends TestCase {
private Config&MockObject $config;
private IEventDispatcher&MockObject $eventDispatcher;
private FederatedInviteMapper&MockObject $federatedInviteMapper;
private AddressHandler&MockObject $addressHandler;
private IAppConfig&MockObject $appConfig;
private ICloudFederationFactory&MockObject $cloudFederationFactory;
private ICloudIdManager&MockObject $cloudIdManager;
private IOCMDiscoveryService&MockObject $discoveryService;
private ISignatureManager&MockObject $signatureManager;
private ITimeFactory&MockObject $timeFactory;
private RequestHandlerController $requestHandlerController;
@ -66,12 +62,10 @@ class RequestHandlerControllerTest extends TestCase {
$this->config = $this->createMock(Config::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->federatedInviteMapper = $this->createMock(FederatedInviteMapper::class);
$this->addressHandler = $this->createMock(AddressHandler::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->discoveryService = $this->createMock(IOCMDiscoveryService::class);
$this->signatureManager = $this->createMock(ISignatureManager::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->requestHandlerController = new RequestHandlerController(
@ -85,12 +79,10 @@ class RequestHandlerControllerTest extends TestCase {
$this->config,
$this->eventDispatcher,
$this->federatedInviteMapper,
$this->addressHandler,
$this->appConfig,
$this->cloudFederationFactory,
$this->cloudIdManager,
$this->discoveryService,
$this->signatureManager,
$this->timeFactory,
);
}

View file

@ -58,7 +58,7 @@ class PhpModules implements ISetupCheck {
protected function getRecommendedModuleDescription(string $module): string {
return match($module) {
'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'),
'sodium' => $this->l10n->t('for Argon2 for password hashing'),
'sodium' => $this->l10n->t('for Argon2 for password hashing and Ed25519 signature verification for RFC 9421 http message signatures'),
'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'),
'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'),
default => '',

12
build/stubs/openssl.php Normal file
View file

@ -0,0 +1,12 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// ext-openssl padding mode constants for psalm. PSS omitted: PHP 8.5+ only.
const OPENSSL_PKCS1_PADDING = 1;
const OPENSSL_SSLV23_PADDING = 2;
const OPENSSL_NO_PADDING = 3;
const OPENSSL_PKCS1_OAEP_PADDING = 4;

13
build/stubs/sodium.php Normal file
View file

@ -0,0 +1,13 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// ext-sodium Ed25519 size constants for psalm.
const SODIUM_CRYPTO_SIGN_BYTES = 64;
const SODIUM_CRYPTO_SIGN_SEEDBYTES = 32;
const SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES = 32;
const SODIUM_CRYPTO_SIGN_SECRETKEYBYTES = 64;
const SODIUM_CRYPTO_SIGN_KEYPAIRBYTES = 96;

View file

@ -47,6 +47,9 @@
"ext-zip": "*",
"ext-zlib": "*"
},
"suggest": {
"ext-sodium": "Argon2 password hashing and Ed25519 signature verification for RFC 9421 HTTP message signatures."
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.4"
},

View file

@ -23,6 +23,7 @@ use OC\Core\Listener\BeforeTemplateRenderedListener;
use OC\Core\Listener\PasswordUpdatedListener;
use OC\Core\Notification\CoreNotifier;
use OC\OCM\OCMDiscoveryHandler;
use OC\OCM\OCMJwksHandler;
use OC\TagManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
@ -88,6 +89,7 @@ class Application extends App implements IBootstrap {
$context->registerConfigLexicon(ConfigLexicon::class);
$context->registerWellKnownHandler(OCMDiscoveryHandler::class);
$context->registerWellKnownHandler(OCMJwksHandler::class);
$context->registerCapability(Capabilities::class);
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\OCM;
use OC\Core\Command\Base;
use OC\OCM\OCMSignatoryManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ActivateKey extends Base {
public function __construct(
private readonly OCMSignatoryManager $signatoryManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('ocm:keys:activate')
->setDescription('promote the staged JWKS key to active; the previous active key moves to retiring');
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$this->signatoryManager->activateStagedJwksKey();
} catch (\RuntimeException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return self::FAILURE;
}
$output->writeln('<info>Staged key promoted to active.</info>');
$output->writeln('Run <info>occ ocm:keys:retire</info> once any in-flight signatures using the previous key have been verified.');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\OCM;
use OC\Core\Command\Base;
use OC\OCM\OCMSignatoryManager;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ListKeys extends Base {
public function __construct(
private readonly OCMSignatoryManager $signatoryManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('ocm:keys:list')
->setDescription('list JWKS-published signing keys');
parent::configure();
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$keys = $this->signatoryManager->listJwksKeys();
$format = $input->getOption('output');
if ($format === self::OUTPUT_FORMAT_JSON || $format === self::OUTPUT_FORMAT_JSON_PRETTY) {
$output->writeln(json_encode($keys, $format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0));
return self::SUCCESS;
}
if ($keys === []) {
$output->writeln('<comment>No JWKS keys yet; one will be generated on first OCM request.</comment>');
return self::SUCCESS;
}
$table = new Table($output);
$table->setHeaders(['Pool', 'Slot', 'Key ID']);
foreach ($keys as $key) {
$table->addRow([$key['poolId'], $key['slot'] ?? '-', $key['kid']]);
}
$table->render();
return self::SUCCESS;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\OCM;
use OC\Core\Command\Base;
use OC\OCM\OCMSignatoryManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RetireKey extends Base {
public function __construct(
private readonly OCMSignatoryManager $signatoryManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('ocm:keys:retire')
->setDescription('delete the retiring JWKS key; signatures that referenced its kid can no longer be verified');
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$this->signatoryManager->retireJwksKey();
} catch (\RuntimeException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return self::FAILURE;
}
$output->writeln('<info>Retiring key deleted.</info>');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Core\Command\OCM;
use OC\Core\Command\Base;
use OC\OCM\OCMSignatoryManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class StageKey extends Base {
public function __construct(
private readonly OCMSignatoryManager $signatoryManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('ocm:keys:stage')
->setDescription('generate a new JWKS key and advertise it via JWKS without using it for signing yet');
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$signatory = $this->signatoryManager->stageJwksKey();
} catch (\RuntimeException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return self::FAILURE;
}
$output->writeln('Staged new JWKS key: <info>' . $signatory->getKeyId() . '</info>');
$output->writeln('Wait for federated peers to refresh their JWKS cache before activating.');
return self::SUCCESS;
}
}

View file

@ -74,6 +74,10 @@ use OC\Core\Command\Memcache\DistributedDelete;
use OC\Core\Command\Memcache\DistributedGet;
use OC\Core\Command\Memcache\DistributedSet;
use OC\Core\Command\Memcache\RedisCommand;
use OC\Core\Command\OCM\ActivateKey as OCMActivateKey;
use OC\Core\Command\OCM\ListKeys as OCMListKeys;
use OC\Core\Command\OCM\RetireKey as OCMRetireKey;
use OC\Core\Command\OCM\StageKey as OCMStageKey;
use OC\Core\Command\Preview\Generate;
use OC\Core\Command\Preview\ResetRenderedTexts;
use OC\Core\Command\Router\ListRoutes;
@ -251,6 +255,11 @@ if ($config->getSystemValueBool('installed', false)) {
$application->add(Server::get(SnowflakeDecodeId::class));
$application->add(Server::get(Get::class));
$application->add(Server::get(OCMListKeys::class));
$application->add(Server::get(OCMStageKey::class));
$application->add(Server::get(OCMActivateKey::class));
$application->add(Server::get(OCMRetireKey::class));
$application->add(Server::get(GetCommand::class));
$application->add(Server::get(EnabledCommand::class));
$application->add(Server::get(Command\TaskProcessing\ListCommand::class));

View file

@ -1408,6 +1408,10 @@ return array(
'OC\\Core\\Command\\Memcache\\DistributedGet' => $baseDir . '/core/Command/Memcache/DistributedGet.php',
'OC\\Core\\Command\\Memcache\\DistributedSet' => $baseDir . '/core/Command/Memcache/DistributedSet.php',
'OC\\Core\\Command\\Memcache\\RedisCommand' => $baseDir . '/core/Command/Memcache/RedisCommand.php',
'OC\\Core\\Command\\OCM\\ActivateKey' => $baseDir . '/core/Command/OCM/ActivateKey.php',
'OC\\Core\\Command\\OCM\\ListKeys' => $baseDir . '/core/Command/OCM/ListKeys.php',
'OC\\Core\\Command\\OCM\\RetireKey' => $baseDir . '/core/Command/OCM/RetireKey.php',
'OC\\Core\\Command\\OCM\\StageKey' => $baseDir . '/core/Command/OCM/StageKey.php',
'OC\\Core\\Command\\Preview\\Cleanup' => $baseDir . '/core/Command/Preview/Cleanup.php',
'OC\\Core\\Command\\Preview\\Generate' => $baseDir . '/core/Command/Preview/Generate.php',
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => $baseDir . '/core/Command/Preview/ResetRenderedTexts.php',
@ -1953,7 +1957,9 @@ return array(
'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php',
'OC\\OCM\\OCMDiscoveryHandler' => $baseDir . '/lib/private/OCM/OCMDiscoveryHandler.php',
'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php',
'OC\\OCM\\OCMJwksHandler' => $baseDir . '/lib/private/OCM/OCMJwksHandler.php',
'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php',
'OC\\OCM\\Rfc9421SignatoryManager' => $baseDir . '/lib/private/OCM/Rfc9421SignatoryManager.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',
@ -2157,7 +2163,13 @@ return array(
'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\\Rfc9421IncomingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php',
'OC\\Security\\Signature\\Model\\Rfc9421OutgoingSignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php',
'OC\\Security\\Signature\\Model\\SignedRequest' => $baseDir . '/lib/private/Security/Signature/Model/SignedRequest.php',
'OC\\Security\\Signature\\Rfc9421\\Algorithm' => $baseDir . '/lib/private/Security/Signature/Rfc9421/Algorithm.php',
'OC\\Security\\Signature\\Rfc9421\\ContentDigest' => $baseDir . '/lib/private/Security/Signature/Rfc9421/ContentDigest.php',
'OC\\Security\\Signature\\Rfc9421\\IJwkResolvingSignatoryManager' => $baseDir . '/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php',
'OC\\Security\\Signature\\Rfc9421\\SignatureBase' => $baseDir . '/lib/private/Security/Signature/Rfc9421/SignatureBase.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',

View file

@ -1449,6 +1449,10 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Command\\Memcache\\DistributedGet' => __DIR__ . '/../../..' . '/core/Command/Memcache/DistributedGet.php',
'OC\\Core\\Command\\Memcache\\DistributedSet' => __DIR__ . '/../../..' . '/core/Command/Memcache/DistributedSet.php',
'OC\\Core\\Command\\Memcache\\RedisCommand' => __DIR__ . '/../../..' . '/core/Command/Memcache/RedisCommand.php',
'OC\\Core\\Command\\OCM\\ActivateKey' => __DIR__ . '/../../..' . '/core/Command/OCM/ActivateKey.php',
'OC\\Core\\Command\\OCM\\ListKeys' => __DIR__ . '/../../..' . '/core/Command/OCM/ListKeys.php',
'OC\\Core\\Command\\OCM\\RetireKey' => __DIR__ . '/../../..' . '/core/Command/OCM/RetireKey.php',
'OC\\Core\\Command\\OCM\\StageKey' => __DIR__ . '/../../..' . '/core/Command/OCM/StageKey.php',
'OC\\Core\\Command\\Preview\\Cleanup' => __DIR__ . '/../../..' . '/core/Command/Preview/Cleanup.php',
'OC\\Core\\Command\\Preview\\Generate' => __DIR__ . '/../../..' . '/core/Command/Preview/Generate.php',
'OC\\Core\\Command\\Preview\\ResetRenderedTexts' => __DIR__ . '/../../..' . '/core/Command/Preview/ResetRenderedTexts.php',
@ -1994,7 +1998,9 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\OCM\\Model\\OCMResource' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMResource.php',
'OC\\OCM\\OCMDiscoveryHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryHandler.php',
'OC\\OCM\\OCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryService.php',
'OC\\OCM\\OCMJwksHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMJwksHandler.php',
'OC\\OCM\\OCMSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMSignatoryManager.php',
'OC\\OCM\\Rfc9421SignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/Rfc9421SignatoryManager.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',
@ -2198,7 +2204,13 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'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\\Rfc9421IncomingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php',
'OC\\Security\\Signature\\Model\\Rfc9421OutgoingSignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php',
'OC\\Security\\Signature\\Model\\SignedRequest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Model/SignedRequest.php',
'OC\\Security\\Signature\\Rfc9421\\Algorithm' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/Algorithm.php',
'OC\\Security\\Signature\\Rfc9421\\ContentDigest' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/ContentDigest.php',
'OC\\Security\\Signature\\Rfc9421\\IJwkResolvingSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php',
'OC\\Security\\Signature\\Rfc9421\\SignatureBase' => __DIR__ . '/../../..' . '/lib/private/Security/Signature/Rfc9421/SignatureBase.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',

View file

@ -18,6 +18,7 @@ use OC\OCM\Model\OCMProvider;
use OCP\AppFramework\Attribute\Consumable;
use OCP\AppFramework\Http;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudIdManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
@ -63,6 +64,7 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
private IURLGenerator $urlGenerator,
private readonly ISignatureManager $signatureManager,
private readonly OCMSignatoryManager $signatoryManager,
private readonly ICloudIdManager $cloudIdManager,
private LoggerInterface $logger,
) {
$this->cache = $cacheFactory->createDistributed('ocm-discovery');
@ -199,10 +201,15 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
return $provider;
}
$signingEnabled = !$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true);
$provider->setEnabled(true);
$provider->setApiVersion(self::API_VERSION);
$provider->setEndPoint(substr($url, 0, $pos));
$provider->setCapabilities(['invite-accepted', 'notifications', 'shares']);
if ($signingEnabled) {
$provider->setCapabilities(['http-sig']);
}
// The inviteAcceptDialog is available from the contacts app, if this config value is set
$inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG);
@ -217,9 +224,8 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
$provider->addResourceType($resource);
if ($fullDetails) {
// Adding a public key to the ocm discovery
try {
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
if ($signingEnabled) {
/**
* @experimental 31.0.0
* @psalm-suppress UndefinedInterfaceMethod
@ -273,6 +279,49 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
return null;
}
/**
* @inheritDoc
*
* @since 34.0.0
*/
#[\Override]
public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void {
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
return;
}
$instance = $this->getHostFromOcmAddress($ocmAddress);
if ($signedOrigin === null) {
try {
$this->signatureManager->getSignatory($instance);
} catch (SignatoryNotFoundException) {
return;
}
throw new IncomingRequestException('instance is supposed to sign its request');
}
if ($instance !== $signedOrigin) {
throw new IncomingRequestException(
'claimed origin ' . $instance . ' does not match signed origin ' . $signedOrigin
);
}
}
/**
* @throws IncomingRequestException on malformed address or unresolvable host
*/
private function getHostFromOcmAddress(string $entry): string {
try {
$cloudId = $this->cloudIdManager->resolveCloudId(trim($entry, '@'));
return $this->signatureManager->extractIdentityFromUri($cloudId->getRemote());
} catch (\InvalidArgumentException $e) {
throw new IncomingRequestException('invalid OCM address: ' . $entry, previous: $e);
} catch (IdentityNotFoundException $e) {
throw new IncomingRequestException('invalid host within OCM address: ' . $entry, previous: $e);
}
}
/**
* @inheritDoc
*
@ -342,10 +391,11 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
}
/**
* add entries to the payload to auth the whole request
* Sign the outgoing payload using the scheme the remote advertises
* (RFC 9421 if `http-sig`, else cavage if a `publicKey` is present).
* APPCONFIG_SIGN_ENFORCED / APPCONFIG_SIGN_DISABLED still apply.
*
* @throws OCMProviderException
* @return array
*/
private function prepareOcmPayload(string $uri, string $method, array $options, string $payload, bool $signed): array {
$payload = array_merge($this->generateRequestOptions($options), ['body' => $payload]);
@ -353,20 +403,31 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
return $payload;
}
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)
&& $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) {
$origin = $this->signatureManager->extractIdentityFromUri($uri);
$ocmProvider = $this->discover($origin);
$useRfc9421 = $ocmProvider->hasCapability('http-sig');
$hasPublicKey = $this->signatoryManager->getRemoteSignatory($origin) !== null;
if (!$useRfc9421 && !$hasPublicKey
&& $this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
throw new OCMProviderException('remote endpoint does not support signed request');
}
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
$signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload(
$this->signatoryManager,
$payload,
$method, $uri
);
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
return $payload;
}
return $signedPayload ?? $payload;
$signatoryManager = $useRfc9421
? new Rfc9421SignatoryManager($this->signatoryManager)
: $this->signatoryManager;
return $this->signatureManager->signOutgoingRequestIClientPayload(
$signatoryManager,
$payload,
$method,
$uri,
);
}
private function generateRequestOptions(array $options): array {

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\OCM;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Http\WellKnown\GenericResponse;
use OCP\Http\WellKnown\IHandler;
use OCP\Http\WellKnown\IRequestContext;
use OCP\Http\WellKnown\IResponse;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
use Throwable;
/** Serves `/.well-known/jwks.json` (RFC 7517) with the OCM signing keys. */
class OCMJwksHandler implements IHandler {
public function __construct(
private readonly IAppConfig $appConfig,
private readonly OCMSignatoryManager $signatoryManager,
private readonly LoggerInterface $logger,
) {
}
#[\Override]
public function handle(string $service, IRequestContext $context, ?IResponse $previousResponse): ?IResponse {
if ($service !== 'jwks.json') {
return $previousResponse;
}
$keys = [];
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
try {
foreach ($this->signatoryManager->getLocalJwks() as $jwk) {
$keys[] = $jwk;
}
} catch (Throwable $e) {
$this->logger->warning('failed to build local JWKs', ['exception' => $e]);
}
}
return new GenericResponse(new JSONResponse(['keys' => $keys]));
}
}

View file

@ -9,21 +9,31 @@ declare(strict_types=1);
namespace OC\OCM;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use JsonException;
use OC\Security\IdentityProof\Manager;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatoryType;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\ISignatureManager;
use OCP\Security\Signature\Model\Signatory;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* @inheritDoc
@ -33,19 +43,41 @@ use Psr\Log\LoggerInterface;
*
* @since 31.0.0
*/
class OCMSignatoryManager implements ISignatoryManager {
class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
public const PROVIDER_ID = 'ocm';
public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external';
public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled';
public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced';
private const APPKEY_CAVAGE = 'ocm_external';
private const KEYID_FRAGMENT_CAVAGE = 'signature';
private const KEYID_FRAGMENT_JWKS = 'ecdsa-p256-sha256';
/** JWKS-published keypairs live in numbered pool appkeys; slots point to them by id. */
private const APPKEY_JWKS_POOL_PREFIX = 'ocm_jwks_pool_';
private const APPCONFIG_JWKS_POOL_COUNTER = 'ocm_jwks_pool_counter';
private const APPCONFIG_JWKS_POOL_KID_PREFIX = 'ocm_jwks_pool_kid_';
/** Stable kid identity portion, reused across rotations so kids stay on one hostname. */
private const APPCONFIG_JWKS_KID_BASE = 'ocm_jwks_kid_base';
public const SLOT_ACTIVE = 'active';
public const SLOT_PENDING = 'pending';
public const SLOT_RETIRING = 'retiring';
/** All slots in advertise order. */
public const JWKS_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING];
/** Remote JWKS cache TTL (seconds). */
private const JWKS_CACHE_TTL = 3600;
private readonly ICache $jwksCache;
public function __construct(
private readonly IAppConfig $appConfig,
private readonly ISignatureManager $signatureManager,
private readonly IURLGenerator $urlGenerator,
private readonly Manager $identityProofManager,
private readonly IClientService $clientService,
private readonly IConfig $config,
ICacheFactory $cacheFactory,
private readonly LoggerInterface $logger,
) {
$this->jwksCache = $cacheFactory->createDistributed('ocm-jwks');
}
/**
@ -91,21 +123,16 @@ class OCMSignatoryManager implements ISignatoryManager {
* TODO: manage multiple identity (external, internal, ...) to allow a limitation
* based on the requested interface (ie. only accept shares from globalscale)
*/
if ($this->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();
}
$keyId = $this->buildLocalKeyId(self::KEYID_FRAGMENT_CAVAGE);
if (!$this->identityProofManager->hasAppKey('core', 'ocm_external')) {
$this->identityProofManager->generateAppKey('core', 'ocm_external', [
if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_CAVAGE)) {
$this->identityProofManager->generateAppKey('core', self::APPKEY_CAVAGE, [
'algorithm' => 'rsa',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
}
$keyPair = $this->identityProofManager->getAppKey('core', 'ocm_external');
$keyPair = $this->identityProofManager->getAppKey('core', self::APPKEY_CAVAGE);
$signatory = new Signatory(true);
$signatory->setKeyId($keyId);
@ -115,28 +142,263 @@ class OCMSignatoryManager implements ISignatoryManager {
}
/** Active JWKS-published signing key (ECDSA P-256), lazily provisioned. */
public function getLocalJwksSignatory(): ?Signatory {
$poolId = $this->getSlotPool(self::SLOT_ACTIVE);
if ($poolId === null) {
$poolId = $this->generatePool($this->nextPoolKid());
$this->setSlotPool(self::SLOT_ACTIVE, $poolId);
}
return $this->signatoryFromPool($poolId);
}
/**
* - tries to generate a keyId using global configuration (from signature manager) if available
* - generate a keyId using the current route to ocm shares
* JWKs for the active/pending/retiring slots, in advertise order. The
* active slot is provisioned if missing so first-hit returns a key.
*
* @return list<array<string, string>>
*/
public function getLocalJwks(): array {
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
$this->getLocalJwksSignatory();
}
$jwks = [];
foreach (self::JWKS_SLOTS as $slot) {
$poolId = $this->getSlotPool($slot);
if ($poolId === null) {
continue;
}
$signatory = $this->signatoryFromPool($poolId);
if ($signatory !== null) {
$jwks[] = self::buildEcdsaP256JwkArray($signatory->getPublicKey(), $signatory->getKeyId());
}
}
return $jwks;
}
/**
* Generate a pending keypair (advertised in JWKS, not yet used for
* outbound signing).
*
* @throws \RuntimeException if pending is already populated
*/
public function stageJwksKey(): Signatory {
if ($this->getSlotPool(self::SLOT_PENDING) !== null) {
throw new \RuntimeException('a pending JWKS key already exists; activate or retire it first');
}
// Need an active key first; staging a next from nothing makes no sense.
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
$this->getLocalJwksSignatory();
}
$poolId = $this->generatePool($this->nextPoolKid());
$this->setSlotPool(self::SLOT_PENDING, $poolId);
$signatory = $this->signatoryFromPool($poolId);
if ($signatory === null) {
throw new \RuntimeException('failed to materialise newly staged JWKS key');
}
return $signatory;
}
/**
* pending -> active, previous active -> retiring. The retiring slot
* stays in JWKS until {@see retireJwksKey} is run.
*
* @throws \RuntimeException if no pending key is staged, or retiring is occupied
*/
public function activateStagedJwksKey(): void {
$pending = $this->getSlotPool(self::SLOT_PENDING);
if ($pending === null) {
throw new \RuntimeException('no pending JWKS key to activate; run `ocm:keys:stage` first');
}
if ($this->getSlotPool(self::SLOT_RETIRING) !== null) {
throw new \RuntimeException('a retiring JWKS key still exists; retire it before activating a new one');
}
$active = $this->getSlotPool(self::SLOT_ACTIVE);
$this->setSlotPool(self::SLOT_ACTIVE, $pending);
$this->clearSlot(self::SLOT_PENDING);
if ($active !== null) {
$this->setSlotPool(self::SLOT_RETIRING, $active);
}
}
/**
* Delete the retiring key. In-flight signatures referencing its kid
* stop verifying after this returns.
*
* @throws \RuntimeException if retiring is empty
*/
public function retireJwksKey(): void {
$poolId = $this->getSlotPool(self::SLOT_RETIRING);
if ($poolId === null) {
throw new \RuntimeException('no retiring JWKS key to remove');
}
$this->identityProofManager->deleteAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId);
$this->appConfig->deleteKey('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId);
$this->clearSlot(self::SLOT_RETIRING);
}
/**
* Diagnostics snapshot. `slot` is null for orphaned pools.
*
* @return list<array{poolId: int, kid: string, slot: ?string}>
*/
public function listJwksKeys(): array {
$bySlot = [];
foreach (self::JWKS_SLOTS as $slot) {
$id = $this->getSlotPool($slot);
if ($id !== null) {
$bySlot[$id] = $slot;
}
}
$max = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0);
$entries = [];
for ($id = 1; $id <= $max; $id++) {
if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $id)) {
continue;
}
$entries[] = [
'poolId' => $id,
'kid' => $this->canonicalKid(
$this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $id, ''),
),
'slot' => $bySlot[$id] ?? null,
];
}
return $entries;
}
/**
* Generate keypair into a new pool. Kid is canonicalised through
* {@see Signatory::setKeyId} so admin output and wire form agree.
*/
private function generatePool(string $kid): int {
$poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1;
$this->appConfig->setValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, $poolId);
$this->identityProofManager->generateEcdsaP256AppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId);
$this->appConfig->setValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid));
return $poolId;
}
/** Canonical wire-form via a transient {@see Signatory::setKeyId} round-trip. */
private function canonicalKid(string $kid): string {
$probe = new Signatory(true);
$probe->setKeyId($kid);
return $probe->getKeyId();
}
/**
* Build the next kid. Identity portion is derived once and persisted so
* CLI-triggered rotations stay on the same hostname.
*
* @throws \RuntimeException if no instance identity can be derived
*/
private function nextPoolKid(): string {
$base = $this->resolveKidBase();
$next = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1;
return $base . '-' . $next;
}
/**
* Stable identity portion (before the `-N` suffix). Resolution order:
* stored APPCONFIG_JWKS_KID_BASE > active pool's kid sans suffix >
* fresh from {@see buildLocalKeyId}. Persisted so CLI rotations stay
* on one hostname.
*
* @throws \RuntimeException if no instance identity can be derived
*/
private function resolveKidBase(): string {
$base = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_KID_BASE, '');
if ($base !== '') {
return $base;
}
$activePool = $this->getSlotPool(self::SLOT_ACTIVE);
if ($activePool !== null) {
$kid = $this->canonicalKid(
$this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $activePool, ''),
);
$pos = strrpos($kid, '-');
if ($pos !== false) {
$base = substr($kid, 0, $pos);
}
}
if ($base === '') {
try {
$base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_JWKS));
} catch (IdentityNotFoundException $e) {
throw new \RuntimeException('cannot derive instance identity for JWKS kid', 0, $e);
}
}
$this->appConfig->setValueString('core', self::APPCONFIG_JWKS_KID_BASE, $base);
return $base;
}
private function getSlotPool(string $slot): ?int {
$key = 'ocm_jwks_slot_' . $slot;
if (!$this->appConfig->hasKey('core', $key)) {
return null;
}
$value = $this->appConfig->getValueInt('core', $key, 0);
return $value > 0 ? $value : null;
}
private function setSlotPool(string $slot, int $poolId): void {
$this->appConfig->setValueInt('core', 'ocm_jwks_slot_' . $slot, $poolId);
}
private function clearSlot(string $slot): void {
$this->appConfig->deleteKey('core', 'ocm_jwks_slot_' . $slot);
}
/** Returns null if the underlying appkey was manually deleted. */
private function signatoryFromPool(int $poolId): ?Signatory {
$appKey = self::APPKEY_JWKS_POOL_PREFIX . $poolId;
if (!$this->identityProofManager->hasAppKey('core', $appKey)) {
return null;
}
$kid = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, '');
if ($kid === '') {
return null;
}
$keyPair = $this->identityProofManager->getAppKey('core', $appKey);
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($keyPair->getPublic());
$signatory->setPrivateKey($keyPair->getPrivate());
return $signatory;
}
/**
* @param string $fragment URL fragment (e.g. 'signature' for cavage, 'ecdsa-p256-sha256' for the JWKS-published key)
* @return string
* @throws IdentityNotFoundException
*/
private function generateKeyId(): string {
private function buildLocalKeyId(string $fragment): string {
if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) {
$identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true);
return 'https://' . $identity . '/ocm#' . $fragment;
}
try {
return $this->signatureManager->generateKeyIdFromConfig('/ocm#signature');
return $this->signatureManager->generateKeyIdFromConfig('/ocm#' . $fragment);
} 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
// catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#<fragment>'
$path = parse_url($url, PHP_URL_PATH);
$pos = strpos($path, '/ocm/shares');
$sub = ($pos) ? substr($path, 0, $pos) : '';
return 'https://' . $identity . $sub . '/ocm#signature';
return 'https://' . $identity . $sub . '/ocm#' . $fragment;
}
/**
@ -163,4 +425,133 @@ class OCMSignatoryManager implements ISignatoryManager {
return null;
}
}
/**
* Resolve a peer's JWK by kid. Cached per-origin for {@see JWKS_CACHE_TTL}s
* with a single refetch on cache-hit-but-kid-missing so rotations propagate.
*/
#[\Override]
public function getRemoteKey(string $origin, string $keyId): ?Key {
$keys = $this->readCachedJwks($origin);
$fromCache = $keys !== null;
if (!$fromCache) {
$keys = $this->fetchJwks($origin);
if ($keys !== null) {
$this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL);
}
}
$key = $this->findKid($keys, $keyId);
if ($key !== null) {
return $key;
}
// Only refetch when the miss came from cache; fresh is authoritative.
if (!$fromCache) {
return null;
}
$keys = $this->fetchJwks($origin);
if ($keys === null) {
return null;
}
$this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL);
return $this->findKid($keys, $keyId);
}
/** @return list<array<string, mixed>>|null null on cold/corrupt cache */
private function readCachedJwks(string $origin): ?array {
$cached = $this->jwksCache->get($origin);
if (!is_string($cached)) {
return null;
}
try {
$decoded = json_decode($cached, true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (!is_array($decoded)) {
return null;
}
/** @var list<array<string, mixed>> $decoded */
return array_values(array_filter($decoded, 'is_array'));
}
/**
* @return list<array<string, mixed>>|null
*/
private function fetchJwks(string $origin): ?array {
$url = 'https://' . $origin . '/.well-known/jwks.json';
$options = [
'timeout' => 10,
'connect_timeout' => 10,
];
if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) {
$options['verify'] = false;
}
try {
$response = $this->clientService->newClient()->get($url, $options);
} catch (Throwable $e) {
$this->logger->warning('failed to fetch remote JWKS', ['exception' => $e, 'url' => $url]);
return null;
}
try {
$decoded = json_decode((string)$response->getBody(), true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->logger->warning('remote JWKS is not valid JSON', ['exception' => $e, 'url' => $url]);
return null;
}
if (!is_array($decoded) || !is_array($decoded['keys'] ?? null)) {
return null;
}
return array_values(array_filter($decoded['keys'], 'is_array'));
}
/**
* @param list<array<string, mixed>>|null $keys
*/
private function findKid(?array $keys, string $keyId): ?Key {
if ($keys === null) {
return null;
}
foreach ($keys as $entry) {
if (($entry['kid'] ?? null) !== $keyId) {
continue;
}
try {
return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry));
} catch (Throwable $e) {
$this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]);
return null;
}
}
return null;
}
/**
* Build an EC P-256 JWK (RFC 7518 §6.2) from a PEM public key. The raw x/y
* coordinates from openssl are zero-padded to 32 bytes per RFC 7518 §6.2.1.2.
*
* @return array<string, string>
*/
private static function buildEcdsaP256JwkArray(string $publicKeyPem, string $kid): array {
$details = openssl_pkey_get_details(openssl_pkey_get_public($publicKeyPem) ?: throw new \RuntimeException('invalid EC public key'));
if ($details === false || !isset($details['ec']['x'], $details['ec']['y'])) {
throw new \RuntimeException('invalid EC public key');
}
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
return [
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'use' => 'sig',
'x' => JWT::urlsafeB64Encode($x),
'y' => JWT::urlsafeB64Encode($y),
];
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\OCM;
use Firebase\JWT\Key;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Model\Signatory;
/**
* Per-call wrapper around {@see OCMSignatoryManager} that swaps in the
* JWKS-published signatory and sets `rfc9421.format`. Wrapping (vs mutating)
* keeps the underlying DI-managed instance stateless across requests.
*/
final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager {
public function __construct(
private readonly OCMSignatoryManager $delegate,
) {
}
#[\Override]
public function getProviderId(): string {
return $this->delegate->getProviderId();
}
#[\Override]
public function getOptions(): array {
return array_merge($this->delegate->getOptions(), ['rfc9421.format' => true]);
}
#[\Override]
public function getLocalSignatory(): Signatory {
$signatory = $this->delegate->getLocalJwksSignatory();
if ($signatory === null) {
throw new IdentityNotFoundException('no JWKS-published signatory available');
}
return $signatory;
}
#[\Override]
public function getRemoteSignatory(string $remote): ?Signatory {
return $this->delegate->getRemoteSignatory($remote);
}
#[\Override]
public function getRemoteKey(string $origin, string $keyId): ?Key {
return $this->delegate->getRemoteKey($origin, $keyId);
}
}

View file

@ -178,6 +178,47 @@ class Manager {
return $this->generateKey($this->generateAppKeyId($app, $name), $options);
}
/**
* Generate an ECDSA P-256 (prime256v1, SECG/JOSE ES256 curve) keypair via
* openssl. Returns PEM private + PEM public. Overwrites if already
* present. Private key is encrypted on disk.
*
* @throws \RuntimeException
*/
public function generateEcdsaP256AppKey(string $app, string $name): Key {
$res = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
if ($res === false) {
$this->logOpensslError();
throw new \RuntimeException('OpenSSL reported a problem');
}
if (openssl_pkey_export($res, $privateKey) === false) {
$this->logOpensslError();
throw new \RuntimeException('OpenSSL reported a problem');
}
$details = openssl_pkey_get_details($res);
if ($details === false || !isset($details['key'])) {
$this->logOpensslError();
throw new \RuntimeException('OpenSSL reported a problem');
}
$publicKey = $details['key'];
$id = $this->generateAppKeyId($app, $name);
try {
$this->appData->newFolder($id);
} catch (\Exception) {
}
$folder = $this->appData->getFolder($id);
$folder->newFile('private')
->putContent($this->crypto->encrypt($privateKey));
$folder->newFile('public')
->putContent($publicKey);
return new Key($publicKey, $privateKey);
}
public function deleteAppKey(string $app, string $name): bool {
try {
$folder = $this->appData->getFolder($this->generateAppKeyId($app, $name));

View file

@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Model;
use Firebase\JWT\Key;
use gapple\StructuredFields\Bytes;
use gapple\StructuredFields\InnerList;
use gapple\StructuredFields\Item;
use gapple\StructuredFields\Parameters;
use gapple\StructuredFields\ParseException;
use gapple\StructuredFields\Parser;
use gapple\StructuredFields\Token;
use JsonSerializable;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\ContentDigest;
use OC\Security\Signature\Rfc9421\SignatureBase;
use OC\Security\Signature\SignatureManager;
use OCP\IRequest;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\InvalidSignatureException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\Exceptions\SignatureException;
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Security\Signature\Model\Signatory;
/**
* RFC 9421 implementation of {@see IIncomingSignedRequest}. Parses the
* inbound Signature-Input / Signature dictionaries, picks the OCM-labeled
* entry (RFC 9421 §3.2 lets verifiers scope by policy), and rebuilds the
* signature base per RFC 9421 §2.5. Crypto is deferred to {@see verify()},
* which needs a {@see Key} attached via {@see setKey()}. Body integrity
* (RFC 9530 content-digest) is checked before verify() if covered.
*/
class Rfc9421IncomingSignedRequest extends SignedRequest implements
IIncomingSignedRequest,
JsonSerializable {
/** Baseline cover for OCM. Override via `rfc9421.requiredComponents`. */
private const DEFAULT_REQUIRED_COMPONENTS = [
'@method',
'@target-uri',
'content-digest',
'content-length',
'date',
];
/** Max clock skew (seconds) for `created`. Override via `rfc9421.maxClockSkew`. */
private const DEFAULT_MAX_FUTURE_SKEW = 60;
private string $origin = '';
/** @var list<string> */
private array $components;
/** @var array<string, scalar> */
private array $signatureParams;
private string $signatureBaseString;
private string $rawSignature;
private ?Key $key = null;
/**
* @throws IncomingRequestException if anything looks wrong with the request structure
* @throws SignatureNotFoundException if the request is not signed
* @throws SignatureException if signature metadata is malformed or covered components reference missing fields
*/
public function __construct(
string $body,
private readonly IRequest $request,
private readonly array $options = [],
) {
parent::__construct($body);
$signatureInputHeader = $request->getHeader('Signature-Input');
$signatureHeader = $request->getHeader('Signature');
if ($signatureInputHeader === '') {
throw new SignatureNotFoundException('missing Signature-Input header');
}
if ($signatureHeader === '') {
throw new SignatureNotFoundException('missing Signature header');
}
$inputs = self::parseSignatureInput($signatureInputHeader);
$signatures = self::parseSignature($signatureHeader);
// OCM policy (stricter than RFC 8941 §4.2 last-wins): a duplicate
// `ocm` entry is ambiguous; the entire request MUST be rejected.
if (self::countLabel($signatureInputHeader, 'ocm') > 1
|| self::countLabel($signatureHeader, 'ocm') > 1) {
throw new IncomingRequestException(
'multiple "' . 'ocm' . '" entries in signature headers'
);
}
if (!isset($inputs['ocm'])) {
throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature-Input');
}
if (!isset($signatures['ocm'])) {
throw new SignatureNotFoundException('missing "' . 'ocm' . '" entry in Signature');
}
$entry = $inputs['ocm'];
$this->components = $entry['components'];
$this->signatureParams = $entry['params'];
$this->rawSignature = $signatures['ocm'];
$this->verifyRequiredComponents();
$this->verifyTimestamps();
$this->verifyContentDigestIfCovered($body);
$this->verifyContentLengthIfCovered($body);
$keyId = $this->signatureParams['keyid'] ?? null;
if (!is_string($keyId) || $keyId === '') {
throw new IncomingRequestException('missing keyid in Signature-Input');
}
try {
$this->origin = Signatory::extractIdentityFromUri($keyId);
} catch (IdentityNotFoundException) {
// keyid may follow the OCM convention `<fqdn>#<id>`; the OCM layer
// derives origin from the message body in that case.
$this->origin = '';
}
$paramsLine = SignatureBase::serializeSignatureParams($this->components, $this->signatureParams);
$this->signatureBaseString = SignatureBase::build(
$request->getMethod(),
$this->reconstructTargetUri(),
$this->collectHeaders(),
$this->components,
$paramsLine,
);
$this->setSigningElements([
'label' => 'ocm',
'keyId' => $keyId,
'algorithm' => isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : '',
'created' => isset($this->signatureParams['created']) ? (string)$this->signatureParams['created'] : '',
'components' => implode(' ', $this->components),
'params' => $paramsLine,
'signature' => base64_encode($this->rawSignature),
]);
$this->setSignature(base64_encode($this->rawSignature));
$this->setSignatureData([$this->signatureBaseString]);
}
#[\Override]
public function getRequest(): IRequest {
return $this->request;
}
#[\Override]
public function getOrigin(): string {
if ($this->origin === '') {
throw new IncomingRequestException('empty origin');
}
return $this->origin;
}
#[\Override]
public function getKeyId(): string {
return $this->getSigningElement('keyId');
}
/** Required before {@see verify()} is called. */
public function setKey(Key $key): self {
$this->key = $key;
return $this;
}
public function getKey(): ?Key {
return $this->key;
}
/** Signature-Input `alg` if present, else null (RFC 9421 §3.3.7 omitted-alg path). */
public function getAlgorithm(): ?string {
return isset($this->signatureParams['alg']) ? (string)$this->signatureParams['alg'] : null;
}
/**
* @return array<string, scalar>
*/
public function getSignatureParams(): array {
return $this->signatureParams;
}
/**
* @return list<string>
*/
public function getCoveredComponents(): array {
return $this->components;
}
public function getSignatureBaseString(): string {
return $this->signatureBaseString;
}
#[\Override]
public function verify(): void {
if ($this->key === null) {
throw new SignatoryNotFoundException('no JWK set for verification');
}
try {
$ok = Algorithm::verify(
$this->signatureBaseString,
$this->rawSignature,
$this->key,
$this->getAlgorithm(),
);
} catch (SignatureException $e) {
throw new InvalidSignatureException($e->getMessage(), 0, $e);
}
if (!$ok) {
throw new InvalidSignatureException('signature verification failed');
}
}
/** @throws IncomingRequestException if the signature doesn't cover the OCM-required components */
private function verifyRequiredComponents(): void {
/** @var list<string> $required */
$required = $this->options['rfc9421.requiredComponents'] ?? self::DEFAULT_REQUIRED_COMPONENTS;
$missing = array_values(array_diff($required, $this->components));
if ($missing !== []) {
throw new IncomingRequestException(
'signature does not cover required components: ' . implode(', ', $missing)
);
}
}
/** @throws IncomingRequestException on stale, future-dated, or missing `created` */
private function verifyTimestamps(): void {
$ttl = (int)($this->options['ttl'] ?? SignatureManager::DATE_TTL);
$skew = (int)($this->options['rfc9421.maxClockSkew'] ?? self::DEFAULT_MAX_FUTURE_SKEW);
$now = time();
if (!isset($this->signatureParams['created'])) {
throw new IncomingRequestException('signature missing required `created` parameter');
}
$created = (int)$this->signatureParams['created'];
if ($created > $now + $skew) {
throw new IncomingRequestException('signature `created` is too far in the future');
}
if ($ttl > 0 && $created < $now - $ttl) {
throw new IncomingRequestException('signature is too old');
}
if (isset($this->signatureParams['expires'])) {
$expires = (int)$this->signatureParams['expires'];
if ($expires < $now) {
throw new IncomingRequestException('signature has expired');
}
}
}
private function verifyContentDigestIfCovered(string $body): void {
if (!in_array('content-digest', $this->components, true)) {
return;
}
$header = $this->request->getHeader('Content-Digest');
if ($header === '') {
throw new IncomingRequestException('content-digest covered but missing from request');
}
if (!ContentDigest::verify($header, $body)) {
throw new IncomingRequestException('content-digest does not match body');
}
}
private function verifyContentLengthIfCovered(string $body): void {
if (!in_array('content-length', $this->components, true)) {
return;
}
$header = $this->request->getHeader('Content-Length');
if ($header === '') {
throw new IncomingRequestException('content-length covered but missing from request');
}
if ((int)$header !== strlen($body)) {
throw new IncomingRequestException('content-length does not match body size');
}
}
private function reconstructTargetUri(): string {
$scheme = $this->request->getServerProtocol();
$host = $this->request->getServerHost();
$path = $this->request->getRequestUri();
return $scheme . '://' . $host . $path;
}
/**
* Collect the HTTP request fields covered by the signature, keyed by their
* lowercased name. Derived components (`@*`) are produced inside
* {@see SignatureBase}; we only collect plain fields here.
*
* @return array<lowercase-string, non-empty-string>
*/
private function collectHeaders(): array {
$out = [];
foreach ($this->components as $component) {
if (str_starts_with($component, '@')) {
continue;
}
$value = $this->request->getHeader($component);
if ($value === '' && strtolower($component) === 'host') {
$value = $this->request->getServerHost();
}
if ($value === '') {
throw new IncomingRequestException('covered header is missing or empty: ' . $component);
}
$out[strtolower($component)] = $value;
}
return $out;
}
#[\Override]
public function jsonSerialize(): array {
return array_merge(
parent::jsonSerialize(),
[
'origin' => $this->origin,
'label' => 'ocm',
'components' => $this->components,
'signatureParams' => $this->signatureParams,
'signatureBase' => $this->signatureBaseString,
]
);
}
/**
* @return array<string, array{components: list<string>, params: array<string, scalar>}>
* @throws SignatureException
*/
private static function parseSignatureInput(string $header): array {
try {
$dict = Parser::parseDictionary($header);
} catch (ParseException $e) {
throw new SignatureException('malformed Signature-Input: ' . $e->getMessage(), 0, $e);
}
$out = [];
foreach ($dict as $label => $entry) {
if (!$entry instanceof InnerList) {
throw new SignatureException('Signature-Input value for ' . $label . ' is not an inner list');
}
$components = [];
foreach ($entry->getValue() as $item) {
$value = $item->getValue();
if (!is_string($value)) {
throw new SignatureException('component identifier in Signature-Input must be a string');
}
$components[] = $value;
}
$parameters = $entry->getParameters();
if (!$parameters instanceof Parameters) {
throw new SignatureException('Signature-Input parameters for ' . $label . ' are not iterable');
}
$out[$label] = [
'components' => $components,
'params' => self::normalizeParameters($parameters),
];
}
return $out;
}
/**
* @return array<string, string> raw signature bytes keyed by label
* @throws SignatureException
*/
private static function parseSignature(string $header): array {
try {
$dict = Parser::parseDictionary($header);
} catch (ParseException $e) {
throw new SignatureException('malformed Signature: ' . $e->getMessage(), 0, $e);
}
$out = [];
foreach ($dict as $label => $entry) {
if (!$entry instanceof Item || !$entry->getValue() instanceof Bytes) {
throw new SignatureException('Signature value for ' . $label . ' is not a byte sequence');
}
$out[$label] = (string)$entry->getValue();
}
return $out;
}
/**
* @param iterable<string, mixed> $parameters
* @return array<string, scalar>
*/
private static function normalizeParameters(iterable $parameters): array {
$out = [];
foreach ($parameters as $name => $value) {
$out[(string)$name] = match (true) {
is_string($value), is_int($value), is_bool($value) => $value,
$value instanceof Token => (string)$value,
default => throw new SignatureException('unsupported parameter type for ' . $name),
};
}
return $out;
}
/** Count $label occurrences in a dictionary header (gapple collapses dups per RFC 8941 §4.2). */
private static function countLabel(string $header, string $label): int {
$count = 0;
$len = strlen($header);
$i = 0;
while ($i < $len) {
while ($i < $len && ($header[$i] === ' ' || $header[$i] === "\t")) {
$i++;
}
$start = $i;
while ($i < $len) {
$c = $header[$i];
if (!ctype_lower($c) && !ctype_digit($c) && $c !== '*' && $c !== '_' && $c !== '-' && $c !== '.') {
break;
}
$i++;
}
if ($i === $start) {
break;
}
if (substr($header, $start, $i - $start) === $label) {
$count++;
}
// Skip to next top-level comma; track strings, byte-sequences, parens.
$inString = false;
$inByteSeq = false;
$depth = 0;
while ($i < $len) {
$c = $header[$i];
if ($inString) {
if ($c === '\\' && $i + 1 < $len) {
$i += 2;
continue;
}
if ($c === '"') {
$inString = false;
}
$i++;
continue;
}
if ($inByteSeq) {
if ($c === ':') {
$inByteSeq = false;
}
$i++;
continue;
}
if ($c === '"') {
$inString = true;
} elseif ($c === ':') {
$inByteSeq = true;
} elseif ($c === '(') {
$depth++;
} elseif ($c === ')') {
$depth--;
} elseif ($c === ',' && $depth === 0) {
$i++;
break;
}
$i++;
}
}
return $count;
}
}

View file

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Model;
use JsonSerializable;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\ContentDigest;
use OC\Security\Signature\Rfc9421\SignatureBase;
use OC\Security\Signature\SignatureManager;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\SignatoryException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\IOutgoingSignedRequest;
use OCP\Security\Signature\ISignatoryManager;
/**
* RFC 9421 implementation of {@see IOutgoingSignedRequest}, sibling to the
* draft-cavage {@see OutgoingSignedRequest}. Default ECDSA P-256 (`ES256`)
* with the `alg` parameter omitted (RFC 9421 §3.3.7); verifier resolves it
* from the JWK.
*
* Options from {@see ISignatoryManager::getOptions()}: `rfc9421.signingAlgorithm`,
* `rfc9421.coveredComponents`, `rfc9421.contentDigestAlgorithm`,
* `rfc9421.includeAlgParameter`, `dateHeader`.
*/
class Rfc9421OutgoingSignedRequest extends SignedRequest implements
IOutgoingSignedRequest,
JsonSerializable {
private const DEFAULT_COMPONENTS = ['@method', '@target-uri', 'content-digest', 'content-length', 'date'];
private string $host = '';
private array $headers = [];
/** @var list<string> $headerList */
private array $headerList = [];
private SignatureAlgorithm $algorithm;
private string $signingAlgorithm;
/** @var array<string, scalar> */
private array $signatureParams;
private string $signatureBaseString;
public function __construct(
string $body,
ISignatoryManager $signatoryManager,
private readonly string $identity,
private readonly string $method,
private readonly string $uri,
) {
parent::__construct($body);
$options = $signatoryManager->getOptions();
$this->setHost($identity)
->setAlgorithm($options['algorithm'] ?? SignatureAlgorithm::RSA_SHA256)
->setSignatory($signatoryManager->getLocalSignatory())
->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256);
$this->signingAlgorithm = (string)($options['rfc9421.signingAlgorithm'] ?? 'ecdsa-p256-sha256');
$contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256);
/** @var list<string> $components */
$components = $options['rfc9421.coveredComponents'] ?? self::DEFAULT_COMPONENTS;
$includeAlg = (bool)($options['rfc9421.includeAlgParameter'] ?? false);
$dateHeaderFormat = (string)($options['dateHeader'] ?? SignatureManager::DATE_HEADER);
$this->addHeader('Content-Digest', ContentDigest::compute($body, $contentDigestAlgorithm))
->addHeader('Content-Length', strlen($body))
->addHeader('Date', gmdate($dateHeaderFormat));
if (in_array('host', $components, true)) {
$this->addHeader('Host', $this->host);
}
$this->setHeaderList($components);
$this->signatureParams = [
'created' => time(),
'keyid' => $this->getSignatory()->getKeyId(),
];
if ($includeAlg) {
// Off by default per RFC 9421 §3.3.7 (verifier resolves alg from JWK).
$this->signatureParams['alg'] = $this->signingAlgorithm;
}
$this->signatureBaseString = SignatureBase::build(
$this->method,
$this->uri,
$this->headersByLowercaseName(),
$this->headerList,
SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams)
);
$this->setSignatureData([$this->signatureBaseString]);
}
#[\Override]
public function setHost(string $host): self {
$this->host = $host;
return $this;
}
#[\Override]
public function getHost(): string {
return $this->host;
}
#[\Override]
public function addHeader(string $key, string|int|float $value): self {
$this->headers[$key] = $value;
return $this;
}
#[\Override]
public function getHeaders(): array {
return $this->headers;
}
#[\Override]
public function setHeaderList(array $list): self {
$this->headerList = $list;
return $this;
}
#[\Override]
public function getHeaderList(): array {
return $this->headerList;
}
#[\Override]
public function setAlgorithm(SignatureAlgorithm $algorithm): self {
$this->algorithm = $algorithm;
return $this;
}
#[\Override]
public function getAlgorithm(): SignatureAlgorithm {
return $this->algorithm;
}
/** RFC 9421 alg name (e.g. `ecdsa-p256-sha256`). Distinct from cavage's {@see getAlgorithm()}. */
public function getSigningAlgorithm(): string {
return $this->signingAlgorithm;
}
public function getSignatureBaseString(): string {
return $this->signatureBaseString;
}
#[\Override]
public function sign(): self {
$privateKey = $this->getSignatory()->getPrivateKey();
if ($privateKey === '') {
throw new SignatoryException('empty private key');
}
$rawSignature = Algorithm::sign(
$this->signatureBaseString,
$privateKey,
$this->signingAlgorithm,
);
$this->setSignature(base64_encode($rawSignature));
$paramsLine = SignatureBase::serializeSignatureParams($this->headerList, $this->signatureParams);
$this->addHeader('Signature-Input', 'ocm=' . $paramsLine);
$this->addHeader('Signature', 'ocm=:' . base64_encode($rawSignature) . ':');
$this->setSigningElements([
'label' => 'ocm',
'components' => implode(' ', $this->headerList),
'params' => $paramsLine,
'signature' => $this->getSignature(),
]);
return $this;
}
/**
* @return array<string, string>
*/
private function headersByLowercaseName(): array {
$out = [];
foreach ($this->headers as $name => $value) {
$out[strtolower($name)] = (string)$value;
}
return $out;
}
/**
* @throws SignatoryNotFoundException
*/
#[\Override]
public function jsonSerialize(): array {
return array_merge(
parent::jsonSerialize(),
[
'host' => $this->host,
'headers' => $this->headers,
'algorithm' => $this->algorithm->value,
'signingAlgorithm' => $this->signingAlgorithm,
'method' => $this->method,
'identity' => $this->identity,
'uri' => $this->uri,
'components' => $this->headerList,
'signatureBase' => $this->signatureBaseString,
'signatureParams' => $this->signatureParams,
]
);
}
}

View file

@ -157,7 +157,7 @@ class SignedRequest implements ISignedRequest, JsonSerializable {
* @return self
* @since 31.0.0
*/
protected function setSignature(string $signature): self {
public function setSignature(string $signature): self {
$this->signature = $signature;
return $this;
}

View file

@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use InvalidArgumentException;
use OCP\Security\Signature\Exceptions\SignatureException;
use Throwable;
/**
* RFC 9421 §3.3 sign/verify primitives.
*
* Sign supports asymmetric algorithms reachable via ext-openssl: RSA-PKCS1-v1_5
* (SHA-256/384/512) and ECDSA P-256 / P-384. JOSE aliases (RFC 7518 / RFC 8037)
* accepted per RFC 9421 §3.3.7. RSA-PSS is rejected: OPENSSL_PKCS1_PSS_PADDING
* needs PHP 8.5 and we still support 8.2-8.4.
*
* Verify additionally accepts Ed25519 when ext-sodium is loaded; without sodium
* an Ed25519 signature throws {@see SignatureException}. Sodium is used directly
* because firebase/php-jwt's `validateEdDSAKey` base64url-decodes the key
* material, which mangles the raw sodium bytes.
*
* Sign delegates to {@see JWT::sign}. Verify takes a {@see Key} parsed by
* firebase/php-jwt (which has already validated the JWK's kty/crv/alg
* consistency) and only enforces the cross-source agreement between the JWK
* `alg` and the Signature-Input `alg` parameter (RFC 9421 §3.2 step 6).
*/
final class Algorithm {
public const NATIVE = [
'rsa-v1_5-sha256',
'rsa-v1_5-sha384',
'rsa-v1_5-sha512',
'ecdsa-p256-sha256',
'ecdsa-p384-sha384',
'ed25519',
];
/**
* $privateKey is a PEM private key. Returns raw signature bytes (R||S for
* ECDSA). Ed25519 is verify-only and is rejected here.
*
* @throws SignatureException
*/
public static function sign(string $signatureBase, string $privateKey, string $algorithm): string {
$normalized = self::normalize($algorithm);
if ($normalized === 'ed25519') {
throw new SignatureException('Ed25519 signing is not supported; use ECDSA P-256 or RSA');
}
try {
return JWT::sign($signatureBase, $privateKey, self::nativeToJose($normalized));
} catch (Throwable $e) {
throw new SignatureException('signing failed for ' . $normalized . ': ' . $e->getMessage(), 0, $e);
}
}
/**
* @param string $signature raw signature bytes (already base64-decoded)
* @param string|null $algorithm algorithm hint from Signature-Input `alg=`
* @throws SignatureException
*/
public static function verify(string $signatureBase, string $signature, Key $key, ?string $algorithm): bool {
$resolved = self::normalize($key->getAlgorithm());
if ($algorithm !== null && $algorithm !== '') {
$hintNative = self::normalize($algorithm);
if ($hintNative !== $resolved) {
throw new SignatureException(
'algorithm sources disagree: Signature-Input alg says ' . $hintNative . ', JWK alg says ' . $resolved
);
}
}
$material = $key->getKeyMaterial();
if ($resolved === 'ed25519') {
if (!function_exists('sodium_crypto_sign_verify_detached')) {
throw new SignatureException('verifying Ed25519 signatures requires ext-sodium');
}
if (strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
return false;
}
// parseKey hands OKP material as plain base64 of the 32 raw bytes.
$rawPublic = base64_decode((string)$material, true);
if ($rawPublic === false || strlen($rawPublic) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
return false;
}
return sodium_crypto_sign_verify_detached($signature, $signatureBase, $rawPublic);
}
[$opensslAlgo, $encoding] = self::opensslParametersForAlgorithm($resolved);
if ($encoding === 'ecdsa') {
$signature = self::ecdsaRawToDer($signature, self::ecdsaCoordinateSize($resolved));
if ($signature === null) {
return false;
}
}
return openssl_verify($signatureBase, $signature, $material, $opensslAlgo) === 1;
}
/**
* Map a JOSE alg (RFC 7518/8037) to the RFC 9421 native identifier.
* Pass-through if already native.
*
* @throws SignatureException
*/
public static function normalize(string $algorithm): string {
$lower = strtolower($algorithm);
if (in_array($lower, self::NATIVE, true)) {
return $lower;
}
return match ($algorithm) {
'EdDSA' => 'ed25519',
'ES256' => 'ecdsa-p256-sha256',
'ES384' => 'ecdsa-p384-sha384',
'RS256' => 'rsa-v1_5-sha256',
'RS384' => 'rsa-v1_5-sha384',
'RS512' => 'rsa-v1_5-sha512',
default => throw new SignatureException('unsupported signature algorithm: ' . $algorithm),
};
}
/**
* Default JOSE alg for {@see \Firebase\JWT\JWK::parseKey} when the JWK has
* no `alg` (RFC 7517 leaves it optional). Null if kty/crv don't pin one
* down (e.g. RSA, where the hash isn't determined).
*
* @param array<string, mixed> $jwk
*/
public static function deriveJoseAlgFromJwk(array $jwk): ?string {
return match ($jwk['kty'] ?? '') {
'OKP' => match ($jwk['crv'] ?? '') {
'Ed25519' => 'EdDSA',
default => null,
},
'EC' => match ($jwk['crv'] ?? '') {
'P-256' => 'ES256',
'P-384' => 'ES384',
default => null,
},
default => null,
};
}
private static function nativeToJose(string $native): string {
return match ($native) {
'ecdsa-p256-sha256' => 'ES256',
'ecdsa-p384-sha384' => 'ES384',
'rsa-v1_5-sha256' => 'RS256',
'rsa-v1_5-sha384' => 'RS384',
'rsa-v1_5-sha512' => 'RS512',
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
};
}
/**
* @return array{0: int, 1: string} [openssl digest, wire encoding]
*/
private static function opensslParametersForAlgorithm(string $native): array {
return match ($native) {
'rsa-v1_5-sha256' => [OPENSSL_ALGO_SHA256, 'raw'],
'rsa-v1_5-sha384' => [OPENSSL_ALGO_SHA384, 'raw'],
'rsa-v1_5-sha512' => [OPENSSL_ALGO_SHA512, 'raw'],
'ecdsa-p256-sha256' => [OPENSSL_ALGO_SHA256, 'ecdsa'],
'ecdsa-p384-sha384' => [OPENSSL_ALGO_SHA384, 'ecdsa'],
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
};
}
private static function ecdsaCoordinateSize(string $native): int {
return match ($native) {
'ecdsa-p256-sha256' => 32,
'ecdsa-p384-sha384' => 48,
default => throw new InvalidArgumentException('not an ECDSA algorithm: ' . $native),
};
}
/**
* Raw R||S (RFC 9421 §3.3.4 wire form) to DER for openssl_verify.
* firebase/php-jwt has the inverse but keeps it private.
*/
public static function ecdsaRawToDer(string $raw, int $coordinateSize): ?string {
if (strlen($raw) !== $coordinateSize * 2) {
return null;
}
$r = ltrim(substr($raw, 0, $coordinateSize), "\x00");
$s = ltrim(substr($raw, $coordinateSize), "\x00");
// DER INTEGER must be positive; pad if high bit is set.
if ($r === '' || (ord($r[0]) & 0x80) !== 0) {
$r = "\x00" . $r;
}
if ($s === '' || (ord($s[0]) & 0x80) !== 0) {
$s = "\x00" . $s;
}
$rEncoded = "\x02" . self::derLength(strlen($r)) . $r;
$sEncoded = "\x02" . self::derLength(strlen($s)) . $s;
$body = $rEncoded . $sEncoded;
return "\x30" . self::derLength(strlen($body)) . $body;
}
private static function derLength(int $length): string {
if ($length < 0x80) {
return chr($length);
}
$bytes = '';
while ($length > 0) {
$bytes = chr($length & 0xff) . $bytes;
$length >>= 8;
}
return chr(0x80 | strlen($bytes)) . $bytes;
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use InvalidArgumentException;
/** RFC 9530 `Content-Digest` helpers; covered by RFC 9421 §7.2.5 in OCM signatures. */
final class ContentDigest {
public const ALGO_SHA256 = 'sha-256';
public const ALGO_SHA512 = 'sha-512';
public static function compute(string $body, string $algorithm = self::ALGO_SHA256): string {
$hashAlgorithm = self::hashAlgorithmFor($algorithm);
return $algorithm . '=:' . base64_encode(hash($hashAlgorithm, $body, true)) . ':';
}
/**
* True iff at least one recognised algorithm matches and none mismatch.
* Stricter than RFC 9530 §2's "any-match"; OCM treats mismatches as an
* attack on the weaker algorithm.
*/
public static function verify(string $header, string $body): bool {
$matched = false;
foreach (self::parse($header) as $algorithm => $digest) {
try {
$hashAlgorithm = self::hashAlgorithmFor($algorithm);
} catch (InvalidArgumentException) {
continue;
}
if (!hash_equals(hash($hashAlgorithm, $body, true), $digest)) {
return false;
}
$matched = true;
}
return $matched;
}
/** @return array<string, string> [algorithm => raw bytes] */
public static function parse(string $header): array {
$out = [];
foreach (explode(',', $header) as $entry) {
$entry = trim($entry);
if ($entry === '') {
continue;
}
if (!preg_match('#^([a-z0-9-]+)=:([A-Za-z0-9+/=]*):$#', $entry, $m)) {
continue;
}
$decoded = base64_decode($m[2], true);
if ($decoded === false) {
continue;
}
$out[strtolower($m[1])] = $decoded;
}
return $out;
}
private static function hashAlgorithmFor(string $algorithm): string {
return match (strtolower($algorithm)) {
self::ALGO_SHA256 => 'sha256',
self::ALGO_SHA512 => 'sha512',
default => throw new InvalidArgumentException('unsupported content-digest algorithm: ' . $algorithm),
};
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use Firebase\JWT\Key;
use OCP\Security\Signature\ISignatoryManager;
/**
* Capability bit for {@see ISignatoryManager} implementations that can resolve
* a remote JWK for RFC 9421 verification. {@see \OC\Security\Signature\SignatureManager}
* checks this via instanceof on the RFC 9421 path; cavage doesn't need it.
*/
interface IJwkResolvingSignatoryManager extends ISignatoryManager {
/**
* Resolve the JWK identified by $keyId for the remote at $origin and
* return it as a parsed {@see Key}. Null when no matching JWK is found.
*
* @param string $origin host of the remote that signed the request
* @param string $keyId raw `keyid` from Signature-Input; matched against JWK `kid`
*/
public function getRemoteKey(string $origin, string $keyId): ?Key;
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature\Rfc9421;
use InvalidArgumentException;
use OCP\Security\Signature\Exceptions\SignatureException;
/**
* RFC 9421 §2.5 signature base construction. Implements the derived
* components OCM uses (`@method`, `@target-uri`, `@authority`, `@scheme`,
* `@path`, `@query`, `@request-target`) plus plain HTTP fields.
*/
final class SignatureBase {
/**
* @param array<string,string> $headers headers keyed by lowercase name
* @param list<string> $components covered component identifiers, in order
* @param string $signatureParamsLine `(...);params...` for `@signature-params`
* @throws SignatureException when a covered field is missing from $headers
*/
public static function build(
string $method,
string $uri,
array $headers,
array $components,
string $signatureParamsLine,
): string {
$lines = [];
foreach ($components as $component) {
$lines[] = '"' . $component . '": ' . self::componentValue($component, $method, $uri, $headers);
}
$lines[] = '"@signature-params": ' . $signatureParamsLine;
return implode("\n", $lines);
}
/**
* Serialize `(comp...)` + `;k=v` parameters for `@signature-params` and
* Signature-Input dictionary entries.
*
* @param list<string> $components
* @param array<string, scalar> $params
*/
public static function serializeSignatureParams(array $components, array $params): string {
$inner = array_map(static fn (string $c): string => '"' . $c . '"', $components);
$out = '(' . implode(' ', $inner) . ')';
foreach ($params as $name => $value) {
$out .= ';' . $name . '=' . self::serializeBareItem($value);
}
return $out;
}
/**
* @param scalar $value
*/
public static function serializeBareItem(mixed $value): string {
if (is_string($value)) {
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
if (is_int($value)) {
return (string)$value;
}
if (is_bool($value)) {
return $value ? '?1' : '?0';
}
throw new InvalidArgumentException('unsupported parameter value type');
}
private static function componentValue(string $component, string $method, string $uri, array $headers): string {
if (str_starts_with($component, '@')) {
return self::derivedValue($component, $method, $uri);
}
$lower = strtolower($component);
if (!array_key_exists($lower, $headers)) {
throw new SignatureException('missing field for signature: ' . $component);
}
return self::normalizeFieldValue($headers[$lower]);
}
private static function derivedValue(string $component, string $method, string $uri): string {
$parts = parse_url($uri);
if ($parts === false) {
throw new SignatureException('cannot parse target URI');
}
return match ($component) {
'@method' => strtoupper($method),
'@target-uri' => $uri,
'@authority' => self::authority($parts),
'@scheme' => strtolower($parts['scheme'] ?? ''),
'@path' => $parts['path'] ?? '/',
'@query' => isset($parts['query']) ? '?' . $parts['query'] : '',
'@request-target' => ($parts['path'] ?? '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''),
default => throw new SignatureException('unsupported derived component: ' . $component),
};
}
private static function authority(array $parts): string {
$host = strtolower((string)($parts['host'] ?? ''));
if ($host === '') {
return '';
}
$port = $parts['port'] ?? null;
$scheme = strtolower((string)($parts['scheme'] ?? ''));
// RFC 9421 §2.2.3: default ports are omitted.
if ($port !== null && !self::isDefaultPort($scheme, (int)$port)) {
return $host . ':' . $port;
}
return $host;
}
private static function isDefaultPort(string $scheme, int $port): bool {
return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80);
}
private static function normalizeFieldValue(string $value): string {
// RFC 9421 §2.1: strip OWS, collapse internal whitespace.
return preg_replace('/[ \t]+/', ' ', trim($value)) ?? '';
}
}

View file

@ -11,6 +11,9 @@ namespace OC\Security\Signature;
use OC\Security\Signature\Db\SignatoryMapper;
use OC\Security\Signature\Model\IncomingSignedRequest;
use OC\Security\Signature\Model\OutgoingSignedRequest;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\DB\Exception as DBException;
use OCP\IAppConfig;
use OCP\IRequest;
@ -101,6 +104,11 @@ class SignatureManager implements ISignatureManager {
throw new IncomingRequestException('content of request is too big');
}
// `Signature-Input` is unique to RFC 9421; cavage uses `Signature` only.
if ($this->request->getHeader('Signature-Input') !== '') {
return $this->getRfc9421IncomingSignedRequest($signatoryManager, $body, $options);
}
// generate IncomingSignedRequest based on body and request
$signedRequest = new IncomingSignedRequest($body, $this->request, $options);
@ -121,6 +129,45 @@ class SignatureManager implements ISignatureManager {
return $signedRequest;
}
/**
* RFC 9421 inbound path. Requires {@see IJwkResolvingSignatoryManager}.
*
* @throws IncomingRequestException
* @throws SignatureException
* @throws SignatureNotFoundException
*/
private function getRfc9421IncomingSignedRequest(
ISignatoryManager $signatoryManager,
string $body,
array $options,
): IIncomingSignedRequest {
if (!($signatoryManager instanceof IJwkResolvingSignatoryManager)) {
throw new IncomingRequestException('RFC 9421 inbound is not supported by ' . get_class($signatoryManager));
}
$signedRequest = new Rfc9421IncomingSignedRequest($body, $this->request, $options);
try {
$key = $signatoryManager->getRemoteKey($signedRequest->getOrigin(), $signedRequest->getKeyId());
if ($key === null) {
throw new SignatoryNotFoundException('no JWK resolved for keyid ' . $signedRequest->getKeyId());
}
$signedRequest->setKey($key);
$signedRequest->verify();
} catch (SignatureException $e) {
$this->logger->warning(
'RFC 9421 signature could not be verified', [
'exception' => $e,
'signedRequest' => $signedRequest,
'signatoryManager' => get_class($signatoryManager),
]
);
throw $e;
}
return $signedRequest;
}
/**
* confirm that the Signature is signed using the correct private key, using
* clear version of the Signature and the public key linked to the keyId
@ -199,13 +246,22 @@ class SignatureManager implements ISignatureManager {
string $method,
string $uri,
): IOutgoingSignedRequest {
$signedRequest = new OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
parse_url($uri, PHP_URL_PATH) ?? '/'
);
$options = $signatoryManager->getOptions();
$signedRequest = ($options['rfc9421.format'] ?? false)
? new Rfc9421OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
$uri,
)
: new OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
parse_url($uri, PHP_URL_PATH) ?? '/',
);
$signedRequest->sign();

View file

@ -65,6 +65,21 @@ interface IOCMDiscoveryService {
*/
public function getIncomingSignedRequest(): ?IIncomingSignedRequest;
/**
* Confirm that the host portion of $ocmAddress matches $signedOrigin
* under the current local signing policy.
*
* @param string|null $signedOrigin verified origin of the signed request,
* typically taken from {@see IIncomingSignedRequest::getOrigin()} or
* from {@see \OCP\OCM\Events\OCMEndpointRequestEvent::getRemote()}.
* NULL if the request was not signed.
* @param string $ocmAddress in `user@host` or `user@https://host` form
*
* @throws IncomingRequestException on mismatch or malformed address
* @since 34.0.0
*/
public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void;
/**
* Request a remote OCM endpoint.
*

View file

@ -116,9 +116,11 @@
<file name="build/stubs/SensitiveParameter.phpstub"/>
<file name="build/stubs/ldap.php"/>
<file name="build/stubs/memcached.php"/>
<file name="build/stubs/openssl.php"/>
<file name="build/stubs/redis.php"/>
<file name="build/stubs/redis_cluster.php"/>
<file name="build/stubs/sftp.php"/>
<file name="build/stubs/sodium.php"/>
<file name="build/stubs/ssh2.php"/>
<file name="build/stubs/xsl.php"/>
<file name="build/stubs/ftp.php"/>

View file

@ -128,6 +128,13 @@ class DiscoveryServiceTest extends TestCase {
$this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities()));
}
public function testLocalCapabilitiesAdvertiseHttpSigByDefault(): void {
// `http-sig` is the OCM-spec flag signalling RFC 9421 support backed
// by /.well-known/jwks.json. Advertised whenever signing is not
// disabled outright.
$local = $this->discoveryService->getLocalOCMProvider();
$this->assertTrue($local->hasCapability('http-sig'));
}
public function testLocalAddedCapability(): void {
$this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class);

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\OCM\OCMJwksHandler;
use OC\OCM\OCMSignatoryManager;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Http\WellKnown\GenericResponse;
use OCP\Http\WellKnown\IRequestContext;
use OCP\Http\WellKnown\IResponse;
use OCP\Http\WellKnown\JrdResponse;
use OCP\IAppConfig;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class OCMJwksHandlerTest extends TestCase {
private IAppConfig&MockObject $appConfig;
private OCMSignatoryManager&MockObject $signatoryManager;
private LoggerInterface&MockObject $logger;
private IRequestContext&MockObject $context;
private OCMJwksHandler $handler;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->signatoryManager = $this->createMock(OCMSignatoryManager::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->context = $this->createMock(IRequestContext::class);
$this->handler = new OCMJwksHandler(
$this->appConfig,
$this->signatoryManager,
$this->logger,
);
}
public function testIgnoresUnrelatedService(): void {
$previous = new JrdResponse('foo');
$result = $this->handler->handle('webfinger', $this->context, $previous);
$this->assertSame($previous, $result);
}
public function testEmptyKeySetWhenSigningDisabled(): void {
$this->appConfig->method('getValueBool')
->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, false, true)
->willReturn(true);
$this->signatoryManager->expects($this->never())->method('getLocalJwks');
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
$this->assertSame(['keys' => []], $body);
}
public function testPublishesJwksWhenAvailable(): void {
$this->appConfig->method('getValueBool')->willReturn(false);
$jwk = [
'kty' => 'EC',
'crv' => 'P-256',
'kid' => 'https://example.org/ocm#ecdsa-p256-sha256',
'alg' => 'ES256',
'use' => 'sig',
'x' => 'AAAA',
'y' => 'BBBB',
];
$this->signatoryManager->method('getLocalJwks')->willReturn([$jwk]);
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
$this->assertSame(['keys' => [$jwk]], $body);
}
public function testPublishesAllSlotsAdvertisedDuringRotation(): void {
$this->appConfig->method('getValueBool')->willReturn(false);
$active = [
'kty' => 'EC', 'crv' => 'P-256', 'kid' => 'kid-1', 'alg' => 'ES256', 'use' => 'sig', 'x' => 'AAAA', 'y' => 'BBBB',
];
$pending = [
'kty' => 'EC', 'crv' => 'P-256', 'kid' => 'kid-2', 'alg' => 'ES256', 'use' => 'sig', 'x' => 'CCCC', 'y' => 'DDDD',
];
$this->signatoryManager->method('getLocalJwks')->willReturn([$active, $pending]);
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
$this->assertSame(['keys' => [$active, $pending]], $body);
}
public function testEmptyKeySetWhenSignatoryUnavailable(): void {
$this->appConfig->method('getValueBool')->willReturn(false);
$this->signatoryManager->method('getLocalJwks')->willReturn([]);
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
$this->assertSame(['keys' => []], $body);
}
public function testFailingJwkBuildIsLoggedAndYieldsEmptyKeySet(): void {
$this->appConfig->method('getValueBool')->willReturn(false);
$this->signatoryManager->method('getLocalJwks')
->willThrowException(new \RuntimeException('boom'));
$this->logger->expects($this->once())->method('warning');
$body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
$this->assertSame(['keys' => []], $body);
}
private function jsonBody(?IResponse $response): array {
$this->assertInstanceOf(GenericResponse::class, $response);
$http = $response->toHttpResponse();
$this->assertInstanceOf(JSONResponse::class, $http);
return $http->getData();
}
}

View file

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\OCM\OCMSignatoryManager;
use OC\Security\IdentityProof\Manager as IdentityProofManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class OCMSignatoryManagerJwksTest extends TestCase {
/** RFC 7517 §A.1 test vector for an EC P-256 public key. */
private const TEST_X = 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU';
private const TEST_Y = 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0';
private IAppConfig&MockObject $appConfig;
private ISignatureManager&MockObject $signatureManager;
private IURLGenerator&MockObject $urlGenerator;
private IdentityProofManager&MockObject $identityProofManager;
private IClientService&MockObject $clientService;
private IConfig&MockObject $config;
private LoggerInterface&MockObject $logger;
private IClient&MockObject $client;
private OCMSignatoryManager $signatoryManager;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->signatureManager = $this->createMock(ISignatureManager::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->identityProofManager = $this->createMock(IdentityProofManager::class);
$this->clientService = $this->createMock(IClientService::class);
$this->config = $this->createMock(IConfig::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->client = $this->createMock(IClient::class);
$this->clientService->method('newClient')->willReturn($this->client);
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache(''));
$this->signatoryManager = new OCMSignatoryManager(
$this->appConfig,
$this->signatureManager,
$this->urlGenerator,
$this->identityProofManager,
$this->clientService,
$this->config,
$cacheFactory,
$this->logger,
);
}
public function testGetRemoteKeyFetchesAndMatchesByKid(): void {
$kid = 'sender.example.org#key1';
$jwks = [
'keys' => [
$this->ecJwk('other'),
$this->ecJwk($kid),
],
];
$this->respondWith($jwks);
$key = $this->signatoryManager->getRemoteKey('sender.example.org', $kid);
$this->assertNotNull($key);
$this->assertSame('ES256', $key->getAlgorithm());
}
public function testGetRemoteKeyReturnsNullWhenKidMissing(): void {
$this->respondWith(['keys' => [$this->ecJwk('unrelated')]]);
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'other-kid'));
}
public function testGetRemoteKeyReturnsNullOnHttpError(): void {
$this->client->method('get')->willThrowException(new \RuntimeException('boom'));
$this->logger->expects($this->once())->method('warning');
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid'));
}
public function testGetRemoteKeyReturnsNullOnInvalidJson(): void {
$response = $this->createMock(IResponse::class);
$response->method('getBody')->willReturn('not json');
$this->client->method('get')->willReturn($response);
$this->logger->expects($this->once())->method('warning');
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid'));
}
public function testGetRemoteKeyReturnsNullWhenKeysMissing(): void {
$this->respondWith(['no-keys-here' => []]);
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid'));
}
public function testGetRemoteKeyReturnsNullOnUnparseableJwk(): void {
// JWK with kty=EC but no crv: parseKey rejects.
$this->respondWith(['keys' => [['kty' => 'EC', 'kid' => 'kid', 'x' => self::TEST_X, 'y' => self::TEST_Y]]]);
$this->logger->expects($this->once())->method('warning');
$this->assertNull($this->signatoryManager->getRemoteKey('sender.example.org', 'kid'));
}
public function testGetRemoteKeyUsesWellKnownPath(): void {
$this->client->expects($this->once())
->method('get')
->with(
$this->equalTo('https://sender.example.org/.well-known/jwks.json'),
$this->isType('array'),
)
->willReturn($this->jsonResponse(['keys' => []]));
$this->signatoryManager->getRemoteKey('sender.example.org', 'kid');
}
public function testGetRemoteKeyPassesSelfSignedFlagThrough(): void {
$this->config->method('getSystemValueBool')
->with('sharing.federation.allowSelfSignedCertificates')
->willReturn(true);
$this->client->expects($this->once())
->method('get')
->with(
$this->anything(),
$this->callback(static fn (array $opts): bool => ($opts['verify'] ?? null) === false),
)
->willReturn($this->jsonResponse(['keys' => []]));
$this->signatoryManager->getRemoteKey('sender.example.org', 'kid');
}
public function testJwksCachedAcrossCallsToTheSameOrigin(): void {
$kid = 'sender.example.org#key1';
$jwks = ['keys' => [$this->ecJwk($kid)]];
$this->client->expects($this->once())
->method('get')
->willReturn($this->jsonResponse($jwks));
$this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', $kid));
$this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', $kid));
}
public function testCacheMissOnNewKidTriggersRefetchOnce(): void {
$first = ['keys' => [$this->ecJwk('old')]];
$second = ['keys' => [$this->ecJwk('new')]];
$this->client->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
$this->jsonResponse($first),
$this->jsonResponse($second),
);
$this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', 'old'));
$this->assertNotNull($this->signatoryManager->getRemoteKey('sender.example.org', 'new'));
}
private function respondWith(array $body): void {
$this->client->method('get')->willReturn($this->jsonResponse($body));
}
private function jsonResponse(array $body): IResponse {
$response = $this->createMock(IResponse::class);
$response->method('getBody')->willReturn(json_encode($body, JSON_THROW_ON_ERROR));
return $response;
}
/** @return array<string, string> */
private function ecJwk(string $kid): array {
return [
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'use' => 'sig',
'x' => self::TEST_X,
'y' => self::TEST_Y,
];
}
}

View file

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\OCM\OCMSignatoryManager;
use OC\Security\IdentityProof\Key;
use OC\Security\IdentityProof\Manager as IdentityProofManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
/** JWKS stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */
class OCMSignatoryManagerRotationTest extends TestCase {
private IAppConfig&MockObject $appConfig;
private IdentityProofManager&MockObject $identityProofManager;
private OCMSignatoryManager $signatoryManager;
/** @var array<string, string> in-memory backing store for IAppConfig core/* */
private array $appConfigStore = [];
/** @var array<string, Key> in-memory backing store for IdentityProofManager appkeys */
private array $appKeyStore = [];
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->identityProofManager = $this->createMock(IdentityProofManager::class);
$this->wireAppConfig();
$this->wireIdentityProofManager();
$signatureManager = $this->createMock(ISignatureManager::class);
$signatureManager->method('generateKeyIdFromConfig')
->willReturnCallback(static fn (string $suffix): string => 'https://alice.example/' . ltrim($suffix, '/'));
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache(''));
$this->signatoryManager = new OCMSignatoryManager(
$this->appConfig,
$signatureManager,
$this->createMock(IURLGenerator::class),
$this->identityProofManager,
$this->stubClientService(),
$this->createMock(IConfig::class),
$cacheFactory,
$this->createMock(LoggerInterface::class),
);
}
public function testJwksBootstrapsActiveKeyOnFirstFetch(): void {
// Fresh instance: first JWKS hit must provision the active key.
$jwks = $this->signatoryManager->getLocalJwks();
$this->assertCount(1, $jwks);
$this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $jwks[0]['kid']);
// And the bootstrapped key is the active one for outbound signing.
$signatory = $this->signatoryManager->getLocalJwksSignatory();
$this->assertSame($jwks[0]['kid'], $signatory->getKeyId());
}
public function testFirstCallProvisionsActiveKey(): void {
$signatory = $this->signatoryManager->getLocalJwksSignatory();
$this->assertNotNull($signatory);
$this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $signatory->getKeyId());
$jwks = $this->signatoryManager->getLocalJwks();
$this->assertCount(1, $jwks);
$this->assertSame($signatory->getKeyId(), $jwks[0]['kid']);
$listed = $this->signatoryManager->listJwksKeys();
$this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed);
}
public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void {
$initial = $this->signatoryManager->getLocalJwksSignatory();
$staged = $this->signatoryManager->stageJwksKey();
$this->assertNotSame($initial->getKeyId(), $staged->getKeyId());
// Active signer is unchanged.
$this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
// JWKS now advertises both kids, active first then pending.
$jwks = $this->signatoryManager->getLocalJwks();
$this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid'));
}
public function testStageRefusesIfPendingAlreadyExists(): void {
$this->signatoryManager->stageJwksKey();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/pending JWKS key already exists/');
$this->signatoryManager->stageJwksKey();
}
public function testActivatePromotesPendingAndDemotesActive(): void {
$first = $this->signatoryManager->getLocalJwksSignatory();
$staged = $this->signatoryManager->stageJwksKey();
$this->signatoryManager->activateStagedJwksKey();
// New signer is the formerly-staged key.
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
// JWKS still advertises the former active key as retiring so peers
// verifying in-flight signatures with its kid don't fail.
$kids = array_column($this->signatoryManager->getLocalJwks(), 'kid');
$this->assertContains($first->getKeyId(), $kids);
$this->assertContains($staged->getKeyId(), $kids);
}
public function testActivateRefusesIfRetiringStillPopulated(): void {
$this->signatoryManager->getLocalJwksSignatory();
$this->signatoryManager->stageJwksKey();
$this->signatoryManager->activateStagedJwksKey();
// Retiring slot is now populated; staging again is allowed but
// activating must refuse until the admin explicitly retires the old
// key.
$this->signatoryManager->stageJwksKey();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/retiring JWKS key still exists/');
$this->signatoryManager->activateStagedJwksKey();
}
public function testActivateRefusesWithoutPendingKey(): void {
$this->signatoryManager->getLocalJwksSignatory();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/no pending JWKS key/');
$this->signatoryManager->activateStagedJwksKey();
}
public function testRetireRemovesRetiringKeyFromJwks(): void {
$first = $this->signatoryManager->getLocalJwksSignatory();
$staged = $this->signatoryManager->stageJwksKey();
$this->signatoryManager->activateStagedJwksKey();
$this->signatoryManager->retireJwksKey();
$kids = array_column($this->signatoryManager->getLocalJwks(), 'kid');
$this->assertSame([$staged->getKeyId()], $kids);
// listJwksKeys also drops the retired pool.
$listed = $this->signatoryManager->listJwksKeys();
$this->assertCount(1, $listed);
$this->assertSame($staged->getKeyId(), $listed[0]['kid']);
$this->assertNotContains($first->getKeyId(), array_column($listed, 'kid'));
}
public function testRetireRefusesWhenNothingToRetire(): void {
$this->signatoryManager->getLocalJwksSignatory();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessageMatches('/no retiring JWKS key/');
$this->signatoryManager->retireJwksKey();
}
public function testKidStaysStableThroughLifecycle(): void {
$first = $this->signatoryManager->getLocalJwksSignatory();
$staged = $this->signatoryManager->stageJwksKey();
// kid for the staged key must stay the same once it is activated;
// peers that cached it during the stage window must still resolve it.
$this->signatoryManager->activateStagedJwksKey();
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
$this->signatoryManager->retireJwksKey();
$this->signatoryManager->stageJwksKey();
// And every newly minted kid must differ from prior ones, no pool
// counter rewinding.
$kids = array_column($this->signatoryManager->listJwksKeys(), 'kid');
$this->assertNotContains($first->getKeyId(), $kids);
$this->assertSame($kids, array_unique($kids));
}
public function testSignerReturnsNullWhenIdentityCannotBeDerived(): void {
// Replace the signature manager with one that cannot derive an
// identity at all; provisioning the first key should fail loudly so
// the admin gets a clear message instead of a corrupt half-state.
$signatureManager = $this->createMock(ISignatureManager::class);
$signatureManager->method('generateKeyIdFromConfig')
->willThrowException(new IdentityNotFoundException('no identity'));
$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->method('linkToRouteAbsolute')
->willThrowException(new IdentityNotFoundException('no url either'));
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache(''));
$manager = new OCMSignatoryManager(
$this->appConfig,
$signatureManager,
$urlGenerator,
$this->identityProofManager,
$this->stubClientService(),
$this->createMock(IConfig::class),
$cacheFactory,
$this->createMock(LoggerInterface::class),
);
$this->expectException(\RuntimeException::class);
$manager->getLocalJwksSignatory();
}
private function wireAppConfig(): void {
$this->appConfig->method('hasKey')->willReturnCallback(
fn (string $app, string $key): bool => $app === 'core' && array_key_exists($key, $this->appConfigStore)
);
$this->appConfig->method('getValueInt')->willReturnCallback(
fn (string $app, string $key, int $default = 0): int => (int)($this->appConfigStore[$key] ?? $default)
);
$this->appConfig->method('setValueInt')->willReturnCallback(
function (string $app, string $key, int $value): bool {
$this->appConfigStore[$key] = (string)$value;
return true;
}
);
$this->appConfig->method('getValueString')->willReturnCallback(
fn (string $app, string $key, string $default = '') => $this->appConfigStore[$key] ?? $default
);
$this->appConfig->method('setValueString')->willReturnCallback(
function (string $app, string $key, string $value): bool {
$this->appConfigStore[$key] = $value;
return true;
}
);
$this->appConfig->method('getValueBool')->willReturn(false);
$this->appConfig->method('deleteKey')->willReturnCallback(
function (string $app, string $key): void {
unset($this->appConfigStore[$key]);
}
);
}
private function wireIdentityProofManager(): void {
$this->identityProofManager->method('hasAppKey')->willReturnCallback(
fn (string $app, string $name): bool => isset($this->appKeyStore[$app . '/' . $name])
);
$this->identityProofManager->method('generateEcdsaP256AppKey')->willReturnCallback(
function (string $app, string $name): Key {
$res = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
openssl_pkey_export($res, $privatePem);
$publicPem = openssl_pkey_get_details($res)['key'];
$key = new Key($publicPem, $privatePem);
$this->appKeyStore[$app . '/' . $name] = $key;
return $key;
}
);
$this->identityProofManager->method('getAppKey')->willReturnCallback(
fn (string $app, string $name): Key => $this->appKeyStore[$app . '/' . $name]
);
$this->identityProofManager->method('deleteAppKey')->willReturnCallback(
function (string $app, string $name): bool {
$existed = isset($this->appKeyStore[$app . '/' . $name]);
unset($this->appKeyStore[$app . '/' . $name]);
return $existed;
}
);
}
private function stubClientService(): IClientService&MockObject {
$service = $this->createMock(IClientService::class);
$service->method('newClient')->willReturn($this->createMock(IClient::class));
return $service;
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use Firebase\JWT\Key;
use OC\OCM\OCMSignatoryManager;
use OC\OCM\Rfc9421SignatoryManager;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Model\Signatory;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class Rfc9421SignatoryManagerTest extends TestCase {
private OCMSignatoryManager&MockObject $delegate;
private Rfc9421SignatoryManager $wrapper;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->delegate = $this->createMock(OCMSignatoryManager::class);
$this->wrapper = new Rfc9421SignatoryManager($this->delegate);
}
public function testGetOptionsForcesRfc9421Format(): void {
$this->delegate->method('getOptions')->willReturn([
'algorithm' => 'rsa-sha512',
'rfc9421.format' => false,
]);
$options = $this->wrapper->getOptions();
$this->assertTrue($options['rfc9421.format']);
$this->assertSame('rsa-sha512', $options['algorithm']);
}
public function testGetLocalSignatoryReturnsJwksKey(): void {
$signatory = $this->createMock(Signatory::class);
$this->delegate->method('getLocalJwksSignatory')->willReturn($signatory);
$this->assertSame($signatory, $this->wrapper->getLocalSignatory());
}
public function testGetLocalSignatoryThrowsWhenJwksKeyUnavailable(): void {
$this->delegate->method('getLocalJwksSignatory')->willReturn(null);
$this->expectException(IdentityNotFoundException::class);
$this->wrapper->getLocalSignatory();
}
public function testProviderIdDelegated(): void {
$this->delegate->method('getProviderId')->willReturn('ocm');
$this->assertSame('ocm', $this->wrapper->getProviderId());
}
public function testRemoteSignatoryDelegated(): void {
$signatory = $this->createMock(Signatory::class);
$this->delegate->expects($this->once())
->method('getRemoteSignatory')
->with('sender.example.org')
->willReturn($signatory);
$this->assertSame($signatory, $this->wrapper->getRemoteSignatory('sender.example.org'));
}
public function testRemoteKeyDelegated(): void {
$key = $this->createMock(Key::class);
$this->delegate->expects($this->once())
->method('getRemoteKey')
->with('sender.example.org', 'kid-1')
->willReturn($key);
$this->assertSame($key, $this->wrapper->getRemoteKey('sender.example.org', 'kid-1'));
}
}

View file

@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature\Model;
use Firebase\JWT\JWK;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OCP\IRequest;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\InvalidSignatureException;
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\Model\Signatory;
use Test\TestCase;
class Rfc9421RoundTripTest extends TestCase {
public function testEcdsaP256RoundTripVerifies(): void {
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$body = '{"hello":"world"}';
$method = 'POST';
$uri = 'https://receiver.example.org/ocm/shares';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', $method, $uri);
$out->sign();
$req = $this->mockRequestFromOutgoing($out, $method, '/ocm/shares', 'receiver.example.org');
$in = new Rfc9421IncomingSignedRequest($body, $req);
$in->setKey($jwk);
$this->assertSame($out->getSignatureBaseString(), $in->getSignatureBaseString());
$in->verify(); // throws on failure
$this->addToAssertionCount(1);
}
public function testEd25519VerifyAcceptedWhenSodiumLoaded(): void {
$this->skipUnlessSodium();
[$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManagerWithSigningAlgorithm($signatory, 'ed25519');
$body = '{"hello":"world"}';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
// Ed25519 sign() throws via Algorithm::sign; produce the signature directly.
$rawSig = sodium_crypto_sign_detached($out->getSignatureBaseString(), $signatory->getPrivateKey());
$out->setSignature(base64_encode($rawSig));
$headers = $out->getHeaders();
$paramsLine = '("@method" "@target-uri" "content-digest" "content-length" "date");created=' . time() . ';keyid="' . $signatory->getKeyId() . '"';
$headers['Signature-Input'] = 'ocm=' . $paramsLine;
$headers['Signature'] = 'ocm=:' . base64_encode($rawSig) . ':';
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$in = new Rfc9421IncomingSignedRequest($body, $req);
$in->setKey($jwk);
$in->verify();
$this->addToAssertionCount(1);
}
public function testTamperedBodyRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$body = 'original';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
$req = $this->mockRequestFromOutgoing($out, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest('tampered', $req);
}
public function testTamperedSignatureRejected(): void {
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$body = 'msg';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
$headers = $out->getHeaders();
// Replace the inner base64 of the signature with a different valid base64.
$headers['Signature'] = preg_replace('/=:[^:]+:/', '=:' . base64_encode(random_bytes(64)) . ':', (string)$headers['Signature']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$in = new Rfc9421IncomingSignedRequest($body, $req);
$in->setKey($jwk);
$this->expectException(InvalidSignatureException::class);
$in->verify();
}
public function testOutgoingUsesOcmLabel(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
$headers = $out->getHeaders();
$this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']);
$this->assertStringStartsWith('ocm=:', (string)$headers['Signature']);
}
public function testRequestWithoutOcmLabelRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Rename the OCM label to something else; verifier MUST reject.
$headers = $out->getHeaders();
$headers['Signature-Input'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature-Input']);
$headers['Signature'] = preg_replace('/^ocm=/', 'sig1=', (string)$headers['Signature']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(SignatureNotFoundException::class);
new Rfc9421IncomingSignedRequest('msg', $req);
}
public function testDuplicateOcmLabelRejected(): void {
// RFC 8941 §4.2 last-wins on duplicate dictionary keys, but OCM
// mandates that duplicate `ocm` entries cause the request to be
// rejected outright. The model layer enforces that.
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
$headers = $out->getHeaders();
$headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', ' . (string)$headers['Signature-Input'];
$headers['Signature'] = (string)$headers['Signature'] . ', ' . (string)$headers['Signature'];
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest('msg', $req);
}
public function testForeignSiblingLabelIgnored(): void {
[$signatory, $jwk] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$out = new Rfc9421OutgoingSignedRequest('msg', $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Splice in a sibling proxy_sig1 entry; the verifier must ignore it
// and still verify the ocm-labeled signature successfully.
$headers = $out->getHeaders();
$proxyParams = '("@method");created=1;keyid="proxy"';
$proxySig = base64_encode(random_bytes(64));
$headers['Signature-Input'] = (string)$headers['Signature-Input'] . ', proxy_sig1=' . $proxyParams;
$headers['Signature'] = (string)$headers['Signature'] . ', proxy_sig1=:' . $proxySig . ':';
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$in = new Rfc9421IncomingSignedRequest('msg', $req);
$in->setKey($jwk);
$in->verify();
$this->addToAssertionCount(1);
}
public function testTooOldSignatureRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$body = 'msg';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Backdate `created` in Signature-Input by 10 minutes.
$headers = $out->getHeaders();
$pastCreated = time() - 600;
$headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $pastCreated, (string)$headers['Signature-Input']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest($body, $req, ['ttl' => 300]);
}
public function testFutureCreatedRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$body = 'msg';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Push `created` 10 minutes into the future, well past the
// 60-second skew tolerance.
$headers = $out->getHeaders();
$futureCreated = time() + 600;
$headers['Signature-Input'] = preg_replace('/created=\d+/', 'created=' . $futureCreated, (string)$headers['Signature-Input']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest($body, $req);
}
public function testMissingCreatedRejected(): void {
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManager($signatory);
$body = 'msg';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
// Strip the `;created=...` parameter so the signature loses its
// freshness anchor.
$headers = $out->getHeaders();
$headers['Signature-Input'] = preg_replace('/;created=\d+/', '', (string)$headers['Signature-Input']);
$req = $this->mockRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest($body, $req);
}
public function testSignatureNotCoveringRequiredComponentsRejected(): void {
// A peer that signs only `@method` and `@target-uri`: the body and
// freshness window aren't bound. Even with a valid signature we
// must refuse it.
[$signatory] = $this->ecdsaP256Material('https://sender.example.org/ocm#ecdsa-p256-sha256');
$signatoryManager = $this->makeSignatoryManagerWithComponents(
$signatory,
['@method', '@target-uri'],
);
$body = 'msg';
$out = new Rfc9421OutgoingSignedRequest($body, $signatoryManager, 'receiver.example.org', 'POST', 'https://receiver.example.org/ocm/shares');
$out->sign();
$req = $this->mockRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org');
$this->expectException(IncomingRequestException::class);
new Rfc9421IncomingSignedRequest($body, $req);
}
private function skipUnlessSodium(): void {
if (!extension_loaded('sodium')) {
$this->markTestSkipped('ext-sodium is not loaded');
}
}
private function makeSignatoryManagerWithComponents(Signatory $signatory, array $components): ISignatoryManager {
return new class($signatory, $components) implements ISignatoryManager {
public function __construct(
private Signatory $sig,
private array $components,
) {
}
public function getProviderId(): string {
return 'test';
}
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA256,
'digestAlgorithm' => DigestAlgorithm::SHA256,
'rfc9421.coveredComponents' => $this->components,
];
}
public function getLocalSignatory(): Signatory {
return $this->sig;
}
public function getRemoteSignatory(string $remote): ?Signatory {
return null;
}
};
}
private function makeSignatoryManagerWithSigningAlgorithm(Signatory $signatory, string $signingAlgorithm): ISignatoryManager {
return new class($signatory, $signingAlgorithm) implements ISignatoryManager {
public function __construct(
private Signatory $sig,
private string $signingAlgorithm,
) {
}
public function getProviderId(): string {
return 'test';
}
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA256,
'digestAlgorithm' => DigestAlgorithm::SHA256,
'rfc9421.signingAlgorithm' => $this->signingAlgorithm,
];
}
public function getLocalSignatory(): Signatory {
return $this->sig;
}
public function getRemoteSignatory(string $remote): ?Signatory {
return null;
}
};
}
/**
* @return array{0: Signatory, 1: \Firebase\JWT\Key}
*/
private function ecdsaP256Material(string $kid): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$privatePem = '';
openssl_pkey_export($pkey, $privatePem);
$details = openssl_pkey_get_details($pkey);
$publicPem = $details['key'];
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($publicPem);
$signatory->setPrivateKey($privatePem);
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
$key = JWK::parseKey([
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'x' => self::b64url($x),
'y' => self::b64url($y),
], 'ES256');
return [$signatory, $key];
}
/**
* @return array{0: Signatory, 1: \Firebase\JWT\Key}
*/
private function ed25519Material(string $kid): array {
$keypair = sodium_crypto_sign_keypair();
$publicKey = sodium_crypto_sign_publickey($keypair);
$secretKey = sodium_crypto_sign_secretkey($keypair);
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($publicKey);
$signatory->setPrivateKey($secretKey);
$key = JWK::parseKey([
'kty' => 'OKP',
'crv' => 'Ed25519',
'kid' => $kid,
'alg' => 'EdDSA',
'x' => self::b64url($publicKey),
], 'EdDSA');
return [$signatory, $key];
}
private static function b64url(string $bin): string {
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
private function makeSignatoryManager(Signatory $signatory): ISignatoryManager {
return new class($signatory) implements ISignatoryManager {
public function __construct(
private Signatory $sig,
) {
}
public function getProviderId(): string {
return 'test';
}
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA256,
'digestAlgorithm' => DigestAlgorithm::SHA256,
];
}
public function getLocalSignatory(): Signatory {
return $this->sig;
}
public function getRemoteSignatory(string $remote): ?Signatory {
return null;
}
};
}
private function mockRequestFromOutgoing(Rfc9421OutgoingSignedRequest $out, string $method, string $path, string $host): IRequest {
return $this->mockRequest($out->getHeaders(), $method, $path, $host);
}
private function mockRequest(array $headers, string $method, string $path, string $host): IRequest {
$lowered = [];
foreach ($headers as $name => $value) {
$lowered[strtolower($name)] = (string)$value;
}
$mock = $this->createMock(IRequest::class);
$mock->method('getHeader')->willReturnCallback(static fn (string $h) => $lowered[strtolower($h)] ?? '');
$mock->method('getMethod')->willReturn($method);
$mock->method('getRequestUri')->willReturn($path);
$mock->method('getServerProtocol')->willReturn('https');
$mock->method('getServerHost')->willReturn($host);
return $mock;
}
}

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature\Rfc9421;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
use OC\Security\Signature\Rfc9421\Algorithm;
use OCP\Security\Signature\Exceptions\SignatureException;
use Test\TestCase;
class AlgorithmTest extends TestCase {
public function testNormalizeNativeIsPassThrough(): void {
$this->assertSame('ed25519', Algorithm::normalize('ed25519'));
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256'));
$this->assertSame('rsa-v1_5-sha384', Algorithm::normalize('rsa-v1_5-sha384'));
$this->assertSame('rsa-v1_5-sha512', Algorithm::normalize('rsa-v1_5-sha512'));
$this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256'));
$this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ecdsa-p384-sha384'));
}
public function testNormalizeJoseAliases(): void {
$this->assertSame('ed25519', Algorithm::normalize('EdDSA'));
$this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ES256'));
$this->assertSame('ecdsa-p384-sha384', Algorithm::normalize('ES384'));
$this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('RS256'));
}
public function testNormalizeRejectsUnknown(): void {
$this->expectException(SignatureException::class);
Algorithm::normalize('totally-not-real');
}
public function testNormalizeRejectsRsaPss(): void {
$this->expectException(SignatureException::class);
Algorithm::normalize('rsa-pss-sha512');
}
public function testNormalizeRejectsJosePsAlias(): void {
$this->expectException(SignatureException::class);
Algorithm::normalize('PS512');
}
public function testDeriveJoseAlgFromJwk(): void {
$this->assertSame('EdDSA', Algorithm::deriveJoseAlgFromJwk(['kty' => 'OKP', 'crv' => 'Ed25519']));
$this->assertSame('ES256', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-256']));
$this->assertSame('ES384', Algorithm::deriveJoseAlgFromJwk(['kty' => 'EC', 'crv' => 'P-384']));
// RSA: hash function isn't determined by key shape.
$this->assertNull(Algorithm::deriveJoseAlgFromJwk(['kty' => 'RSA']));
$this->assertNull(Algorithm::deriveJoseAlgFromJwk([]));
}
public function testEd25519SigningIsRejected(): void {
$this->expectException(SignatureException::class);
$this->expectExceptionMessageMatches('/Ed25519 signing is not supported/');
Algorithm::sign('payload', str_repeat("\x00", 64), 'ed25519');
}
public function testEd25519VerifyRoundTripWithSodium(): void {
$this->skipUnlessSodium();
[$secret, $key] = $this->ed25519KeyPair();
$base = 'arbitrary signature base';
$sig = sodium_crypto_sign_detached($base, $secret);
$this->assertSame(64, strlen($sig));
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
// JOSE alias accepted.
$this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA'));
// alg-omitted path resolves through Key alg.
$this->assertTrue(Algorithm::verify($base, $sig, $key, null));
// tamper detection
$this->assertFalse(Algorithm::verify($base . 'x', $sig, $key, 'ed25519'));
}
public function testRsaPkcs1RoundTrip(): void {
[$priv, $key] = $this->rsaKeyPair();
$sig = Algorithm::sign('payload', $priv, 'rsa-v1_5-sha256');
$this->assertSame(256, strlen($sig));
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'rsa-v1_5-sha256'));
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'RS256'));
}
public function testEcdsaP256RoundTrip(): void {
[$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256');
$sig = Algorithm::sign('payload', $priv, 'ecdsa-p256-sha256');
$this->assertSame(64, strlen($sig));
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p256-sha256'));
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ES256'));
}
public function testEcdsaP384RoundTrip(): void {
[$priv, $key] = $this->ecKeyPair('secp384r1', 'P-384', 'ES384');
$sig = Algorithm::sign('payload', $priv, 'ecdsa-p384-sha384');
$this->assertSame(96, strlen($sig));
$this->assertTrue(Algorithm::verify('payload', $sig, $key, 'ecdsa-p384-sha384'));
}
public function testKeyTypeMismatchFailsClosed(): void {
[, $rsaKey] = $this->rsaKeyPair();
$this->expectException(SignatureException::class);
Algorithm::verify('payload', random_bytes(64), $rsaKey, 'ed25519');
}
public function testAlgHintConflictsWithJwkAlgRejected(): void {
$this->skipUnlessSodium();
// Ed25519 JWK, request claims ES256: RFC 9421 §3.2 step 6 disagreement.
[, $key] = $this->ed25519KeyPair();
$this->expectException(SignatureException::class);
Algorithm::verify('payload', random_bytes(64), $key, 'ES256');
}
public function testParseKeyRejectsContradictoryAlg(): void {
$this->markTestSkipped(
'firebase/php-jwt JWK::parseKey does not validate kty/crv/alg coherence; '
. 'the alg mismatch is caught at verify() time instead — see testVerifyEd25519KeyAgainstES256Alg.'
);
}
public function testEcdsaRawToDerProducesValidSignature(): void {
[$priv, $key] = $this->ecKeyPair('prime256v1', 'P-256', 'ES256');
$rawSig = Algorithm::sign('msg', $priv, 'ecdsa-p256-sha256');
$der = Algorithm::ecdsaRawToDer($rawSig, 32);
$this->assertNotNull($der);
$this->assertTrue(Algorithm::verify('msg', $rawSig, $key, 'ecdsa-p256-sha256'));
}
public function testEcdsaRawToDerWrongLength(): void {
$this->assertNull(Algorithm::ecdsaRawToDer('short', 32));
}
private function skipUnlessSodium(): void {
if (!extension_loaded('sodium')) {
$this->markTestSkipped('ext-sodium is not loaded');
}
}
/**
* @return array{0: string, 1: Key}
*/
private function ed25519KeyPair(): array {
$keypair = sodium_crypto_sign_keypair();
$publicKey = sodium_crypto_sign_publickey($keypair);
$secretKey = sodium_crypto_sign_secretkey($keypair);
$key = JWK::parseKey([
'kty' => 'OKP',
'crv' => 'Ed25519',
'kid' => 'k',
'alg' => 'EdDSA',
'x' => self::b64url($publicKey),
], 'EdDSA');
return [$secretKey, $key];
}
/**
* @return array{0: string, 1: Key}
*/
private function rsaKeyPair(): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
$priv = '';
openssl_pkey_export($pkey, $priv);
$details = openssl_pkey_get_details($pkey);
$key = JWK::parseKey([
'kty' => 'RSA',
'kid' => 'k',
'alg' => 'RS256',
'n' => self::b64url($details['rsa']['n']),
'e' => self::b64url($details['rsa']['e']),
], 'RS256');
return [$priv, $key];
}
/**
* @return array{0: string, 1: Key}
*/
private function ecKeyPair(string $opensslCurve, string $jwkCurve, string $joseAlg): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => $opensslCurve]);
$priv = '';
openssl_pkey_export($pkey, $priv);
$details = openssl_pkey_get_details($pkey);
$key = JWK::parseKey([
'kty' => 'EC',
'crv' => $jwkCurve,
'kid' => 'k',
'alg' => $joseAlg,
'x' => self::b64url($details['ec']['x']),
'y' => self::b64url($details['ec']['y']),
], $joseAlg);
return [$priv, $key];
}
private static function b64url(string $bin): string {
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature\Rfc9421;
use OC\Security\Signature\Rfc9421\ContentDigest;
use Test\TestCase;
class ContentDigestTest extends TestCase {
public function testComputeRoundTrip(): void {
$body = '{"hello":"world"}';
$header = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
$this->assertStringStartsWith('sha-256=:', $header);
$this->assertStringEndsWith(':', $header);
$this->assertTrue(ContentDigest::verify($header, $body));
}
public function testDifferentBodyFails(): void {
$header = ContentDigest::compute('hello', ContentDigest::ALGO_SHA256);
$this->assertFalse(ContentDigest::verify($header, 'goodbye'));
}
public function testSha512(): void {
$header = ContentDigest::compute('payload', ContentDigest::ALGO_SHA512);
$this->assertStringStartsWith('sha-512=:', $header);
$this->assertTrue(ContentDigest::verify($header, 'payload'));
}
public function testParseMultipleAlgorithmsAcceptsAnyMatch(): void {
$body = 'data';
$sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
$sha512 = ContentDigest::compute($body, ContentDigest::ALGO_SHA512);
$header = $sha256 . ', ' . $sha512;
$this->assertTrue(ContentDigest::verify($header, $body));
}
public function testFailsIfAnyRecognisedAlgorithmMismatches(): void {
// All recognised digests must agree. A correct sha-256 alongside a
// wrong sha-512 is treated as an attack on the weaker algorithm,
// not as a successful match on the stronger one.
$body = 'data';
$sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
$wrongSha512 = 'sha-512=:' . base64_encode(hash('sha512', 'tampered', true)) . ':';
$this->assertFalse(ContentDigest::verify($sha256 . ', ' . $wrongSha512, $body));
// And the inverse ordering.
$this->assertFalse(ContentDigest::verify($wrongSha512 . ', ' . $sha256, $body));
}
public function testUnknownAlgorithmIsIgnored(): void {
$body = 'data';
$sha256 = ContentDigest::compute($body, ContentDigest::ALGO_SHA256);
$header = 'md5=:abcd:, ' . $sha256;
$this->assertTrue(ContentDigest::verify($header, $body));
}
public function testEmptyHeaderFails(): void {
$this->assertFalse(ContentDigest::verify('', 'body'));
}
public function testGarbageHeaderFails(): void {
$this->assertFalse(ContentDigest::verify('not a digest', 'body'));
}
public function testParseExtractsRawBytes(): void {
$header = ContentDigest::compute('abc', ContentDigest::ALGO_SHA256);
$parsed = ContentDigest::parse($header);
$this->assertArrayHasKey('sha-256', $parsed);
$this->assertSame(hash('sha256', 'abc', true), $parsed['sha-256']);
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature\Rfc9421;
use OC\Security\Signature\Rfc9421\SignatureBase;
use OCP\Security\Signature\Exceptions\SignatureException;
use Test\TestCase;
class SignatureBaseTest extends TestCase {
public function testBuildBasicComponents(): void {
$base = SignatureBase::build(
method: 'POST',
uri: 'https://example.org/foo?bar=baz',
headers: [
'content-digest' => 'sha-256=:abcd:',
'date' => 'Mon, 04 May 2026 12:00:00 GMT',
],
components: ['@method', '@target-uri', 'content-digest', 'date'],
signatureParamsLine: '("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"',
);
$expected = '"@method": POST' . "\n"
. '"@target-uri": https://example.org/foo?bar=baz' . "\n"
. '"content-digest": sha-256=:abcd:' . "\n"
. '"date": Mon, 04 May 2026 12:00:00 GMT' . "\n"
. '"@signature-params": ("@method" "@target-uri" "content-digest" "date");created=1;keyid="k"';
$this->assertSame($expected, $base);
}
public function testAuthorityStripsDefaultPort(): void {
$base = SignatureBase::build('GET', 'https://EXAMPLE.org:443/x', [], ['@authority'], '()');
$this->assertStringContainsString('"@authority": example.org' . "\n", $base);
}
public function testAuthorityKeepsCustomPort(): void {
$base = SignatureBase::build('GET', 'https://example.org:8443/x', [], ['@authority'], '()');
$this->assertStringContainsString('"@authority": example.org:8443' . "\n", $base);
}
public function testQueryComponent(): void {
$base = SignatureBase::build('GET', 'https://example.org/x?a=1', [], ['@query'], '()');
$this->assertStringContainsString('"@query": ?a=1' . "\n", $base);
}
public function testMissingFieldThrows(): void {
$this->expectException(SignatureException::class);
SignatureBase::build('GET', 'https://example.org/', [], ['x-missing'], '()');
}
public function testFieldValueIsTrimmed(): void {
$base = SignatureBase::build(
'GET',
'https://example.org/',
['date' => ' Mon, 04 May 2026 12:00:00 GMT '],
['date'],
'()'
);
$this->assertStringContainsString('"date": Mon, 04 May 2026 12:00:00 GMT' . "\n", $base);
}
public function testSerializeSignatureParams(): void {
$line = SignatureBase::serializeSignatureParams(
['@method', '@target-uri'],
['created' => 100, 'keyid' => 'kid', 'expires' => 200],
);
$this->assertSame('("@method" "@target-uri");created=100;keyid="kid";expires=200', $line);
}
public function testSerializeBareItemEscapesQuotes(): void {
$this->assertSame('"\\"hi\\""', SignatureBase::serializeBareItem('"hi"'));
$this->assertSame('"\\\\"', SignatureBase::serializeBareItem('\\'));
}
public function testSerializeBareItemBoolean(): void {
$this->assertSame('?1', SignatureBase::serializeBareItem(true));
$this->assertSame('?0', SignatureBase::serializeBareItem(false));
}
}

View file

@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Security\Signature;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
use OC\Security\Signature\Db\SignatoryMapper;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OC\Security\Signature\SignatureManager;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\Model\Signatory;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class SignatureManagerDispatchTest extends TestCase {
private IRequest&MockObject $request;
private SignatoryMapper&MockObject $mapper;
private IAppConfig&MockObject $appConfig;
private LoggerInterface&MockObject $logger;
private SignatureManager $signatureManager;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->mapper = $this->createMock(SignatoryMapper::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->signatureManager = new SignatureManager(
$this->request,
$this->mapper,
$this->appConfig,
$this->logger,
);
}
public function testOutgoingDispatchesToCavageByDefault(): void {
$signatoryManager = $this->rsaSignatoryManager();
$signed = $this->signatureManager->getOutgoingSignedRequest(
$signatoryManager,
'{}',
'POST',
'https://receiver.example.org/ocm/shares',
);
$this->assertNotInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed);
}
public function testOutgoingDispatchesToRfc9421WhenOptionSet(): void {
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
$signed = $this->signatureManager->getOutgoingSignedRequest(
$signatoryManager,
'{}',
'POST',
'https://receiver.example.org/ocm/shares',
);
$this->assertInstanceOf(Rfc9421OutgoingSignedRequest::class, $signed);
$headers = $signed->getHeaders();
$this->assertArrayHasKey('Signature-Input', $headers);
$this->assertStringStartsWith('ocm=(', (string)$headers['Signature-Input']);
}
public function testInboundDispatchesToRfc9421WhenSignatureInputPresent(): void {
[$signatoryManager, $jwk] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
// Build a real signed request and replay its headers as the inbound
// request to exercise the full inbound path including verification.
$body = '{"hello":"world"}';
$out = new Rfc9421OutgoingSignedRequest(
$body,
$signatoryManager,
'receiver.example.org',
'POST',
'https://receiver.example.org/ocm/shares',
);
$out->sign();
$headers = $out->getHeaders();
$this->primeRequest($headers, 'POST', '/ocm/shares', 'receiver.example.org');
$resolver = $this->makeKeyResolver($signatoryManager, $jwk, 'https://sender.example.org/ocm#ecdsa-p256-sha256');
$signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body);
$this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed);
}
public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void {
[$signatoryManager,] = $this->ecdsaP256SignatoryManager(rfc9421Format: true);
$body = '{"hello":"world"}';
$out = new Rfc9421OutgoingSignedRequest(
$body,
$signatoryManager,
'receiver.example.org',
'POST',
'https://receiver.example.org/ocm/shares',
);
$out->sign();
$this->primeRequest($out->getHeaders(), 'POST', '/ocm/shares', 'receiver.example.org');
// $signatoryManager does NOT implement IJwkResolvingSignatoryManager.
$this->expectException(IncomingRequestException::class);
$this->signatureManager->getIncomingSignedRequest($signatoryManager, $body);
}
private function rsaSignatoryManager(): ISignatoryManager {
$key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
$priv = '';
openssl_pkey_export($key, $priv);
$pub = openssl_pkey_get_details($key)['key'];
$signatory = new Signatory(true);
$signatory->setKeyId('https://sender.example.org/ocm#signature');
$signatory->setPublicKey($pub);
$signatory->setPrivateKey($priv);
return new class($signatory) implements ISignatoryManager {
public function __construct(
private Signatory $signatory,
) {
}
public function getProviderId(): string {
return 'test';
}
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA256,
'digestAlgorithm' => DigestAlgorithm::SHA256,
];
}
public function getLocalSignatory(): Signatory {
return $this->signatory;
}
public function getRemoteSignatory(string $remote): ?Signatory {
return null;
}
};
}
/**
* @return array{ISignatoryManager, Key} [manager, parsed verification key]
*/
private function ecdsaP256SignatoryManager(bool $rfc9421Format): array {
$pkey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
$privatePem = '';
openssl_pkey_export($pkey, $privatePem);
$details = openssl_pkey_get_details($pkey);
$publicPem = $details['key'];
$kid = 'https://sender.example.org/ocm#ecdsa-p256-sha256';
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($publicPem);
$signatory->setPrivateKey($privatePem);
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
$key = JWK::parseKey([
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'x' => rtrim(strtr(base64_encode($x), '+/', '-_'), '='),
'y' => rtrim(strtr(base64_encode($y), '+/', '-_'), '='),
], 'ES256');
$manager = new class($signatory, $rfc9421Format) implements ISignatoryManager {
public function __construct(
private Signatory $signatory,
private bool $rfc9421,
) {
}
public function getProviderId(): string {
return 'test';
}
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA256,
'digestAlgorithm' => DigestAlgorithm::SHA256,
'rfc9421.format' => $this->rfc9421,
];
}
public function getLocalSignatory(): Signatory {
return $this->signatory;
}
public function getRemoteSignatory(string $remote): ?Signatory {
return null;
}
};
return [$manager, $key];
}
private function makeKeyResolver(ISignatoryManager $delegate, Key $key, string $kid): IJwkResolvingSignatoryManager {
return new class($delegate, $key, $kid) implements IJwkResolvingSignatoryManager {
public function __construct(
private ISignatoryManager $delegate,
private Key $key,
private string $kid,
) {
}
public function getProviderId(): string {
return $this->delegate->getProviderId();
}
public function getOptions(): array {
return $this->delegate->getOptions();
}
public function getLocalSignatory(): Signatory {
return $this->delegate->getLocalSignatory();
}
public function getRemoteSignatory(string $remote): ?Signatory {
return $this->delegate->getRemoteSignatory($remote);
}
public function getRemoteKey(string $origin, string $keyId): ?Key {
return $keyId === $this->kid ? $this->key : null;
}
};
}
private function primeRequest(array $headers, string $method, string $path, string $host): void {
$lowered = [];
foreach ($headers as $name => $value) {
$lowered[strtolower($name)] = (string)$value;
}
$this->request->method('getHeader')
->willReturnCallback(static fn (string $name) => $lowered[strtolower($name)] ?? '');
$this->request->method('getMethod')->willReturn($method);
$this->request->method('getRequestUri')->willReturn($path);
$this->request->method('getServerProtocol')->willReturn('https');
$this->request->method('getServerHost')->willReturn($host);
}
}