mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
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
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:
commit
5ffde0370b
40 changed files with 3795 additions and 111 deletions
2
3rdparty
2
3rdparty
|
|
@ -1 +1 @@
|
|||
Subproject commit 16dd9453d0d94a90f886b55ca26ddd190f2cd5a0
|
||||
Subproject commit f7176e8becad6b9ef000422fc6039025b58dd2c9
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
12
build/stubs/openssl.php
Normal 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
13
build/stubs/sodium.php
Normal 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;
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
42
core/Command/OCM/ActivateKey.php
Normal file
42
core/Command/OCM/ActivateKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
core/Command/OCM/ListKeys.php
Normal file
54
core/Command/OCM/ListKeys.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
core/Command/OCM/RetireKey.php
Normal file
41
core/Command/OCM/RetireKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
core/Command/OCM/StageKey.php
Normal file
42
core/Command/OCM/StageKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
49
lib/private/OCM/OCMJwksHandler.php
Normal file
49
lib/private/OCM/OCMJwksHandler.php
Normal 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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
lib/private/OCM/Rfc9421SignatoryManager.php
Normal file
56
lib/private/OCM/Rfc9421SignatoryManager.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
223
lib/private/Security/Signature/Rfc9421/Algorithm.php
Normal file
223
lib/private/Security/Signature/Rfc9421/Algorithm.php
Normal 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;
|
||||
}
|
||||
}
|
||||
72
lib/private/Security/Signature/Rfc9421/ContentDigest.php
Normal file
72
lib/private/Security/Signature/Rfc9421/ContentDigest.php
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
124
lib/private/Security/Signature/Rfc9421/SignatureBase.php
Normal file
124
lib/private/Security/Signature/Rfc9421/SignatureBase.php
Normal 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)) ?? '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
118
tests/lib/OCM/OCMJwksHandlerTest.php
Normal file
118
tests/lib/OCM/OCMJwksHandlerTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
192
tests/lib/OCM/OCMSignatoryManagerJwksTest.php
Normal file
192
tests/lib/OCM/OCMSignatoryManagerJwksTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
278
tests/lib/OCM/OCMSignatoryManagerRotationTest.php
Normal file
278
tests/lib/OCM/OCMSignatoryManagerRotationTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
tests/lib/OCM/Rfc9421SignatoryManagerTest.php
Normal file
78
tests/lib/OCM/Rfc9421SignatoryManagerTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
409
tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php
Normal file
409
tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
199
tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
Normal file
199
tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
Normal 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
76
tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php
Normal file
76
tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
85
tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php
Normal file
85
tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
264
tests/lib/Security/Signature/SignatureManagerDispatchTest.php
Normal file
264
tests/lib/Security/Signature/SignatureManagerDispatchTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue