diff --git a/3rdparty b/3rdparty
index 5d09a7f56e2d0..e73f85f09109f 160000
--- a/3rdparty
+++ b/3rdparty
@@ -1 +1 @@
-Subproject commit 5d09a7f56e2d01b5f4083e65db77c4f7aa775252
+Subproject commit e73f85f09109f77c033b643c68f5acbed6ce1244
diff --git a/apps/settings/lib/SetupChecks/PhpModules.php b/apps/settings/lib/SetupChecks/PhpModules.php
index 64fa6e15c0a85..d4c3d2c5c2a59 100644
--- a/apps/settings/lib/SetupChecks/PhpModules.php
+++ b/apps/settings/lib/SetupChecks/PhpModules.php
@@ -24,6 +24,7 @@ class PhpModules implements ISetupCheck {
'openssl',
'posix',
'session',
+ 'sodium',
'xml',
'xmlreader',
'xmlwriter',
@@ -35,7 +36,6 @@ class PhpModules implements ISetupCheck {
'exif',
'gmp',
'intl',
- 'sodium',
'sysvsem',
];
@@ -58,7 +58,6 @@ public function getCategory(): string {
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'),
'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 => '',
diff --git a/build/stubs/openssl.php b/build/stubs/openssl.php
new file mode 100644
index 0000000000000..5bf410a677445
--- /dev/null
+++ b/build/stubs/openssl.php
@@ -0,0 +1,12 @@
+registerConfigLexicon(ConfigLexicon::class);
$context->registerWellKnownHandler(OCMDiscoveryHandler::class);
+ $context->registerWellKnownHandler(OCMJwksHandler::class);
$context->registerCapability(Capabilities::class);
}
diff --git a/core/Command/OCM/ActivateKey.php b/core/Command/OCM/ActivateKey.php
new file mode 100644
index 0000000000000..090538e00248d
--- /dev/null
+++ b/core/Command/OCM/ActivateKey.php
@@ -0,0 +1,42 @@
+setName('ocm:keys:activate')
+ ->setDescription('promote the staged Ed25519 key to active; the previous active key moves to retiring');
+ }
+
+ #[\Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ try {
+ $this->signatoryManager->activateStagedEd25519Key();
+ } catch (\RuntimeException $e) {
+ $output->writeln('' . $e->getMessage() . '');
+ return 1;
+ }
+ $output->writeln('Staged key promoted to active.');
+ $output->writeln('Run occ ocm:keys:retire once any in-flight signatures using the previous key have been verified.');
+ return 0;
+ }
+}
diff --git a/core/Command/OCM/ListKeys.php b/core/Command/OCM/ListKeys.php
new file mode 100644
index 0000000000000..f73a476311134
--- /dev/null
+++ b/core/Command/OCM/ListKeys.php
@@ -0,0 +1,54 @@
+setName('ocm:keys:list')
+ ->setDescription('list Ed25519 keys used by OCM RFC 9421 HTTP Message Signatures');
+ parent::configure();
+ }
+
+ #[\Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $keys = $this->signatoryManager->listEd25519Keys();
+ $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 0;
+ }
+
+ if ($keys === []) {
+ $output->writeln('No Ed25519 keys yet; one will be generated on first OCM request.');
+ return 0;
+ }
+
+ $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 0;
+ }
+}
diff --git a/core/Command/OCM/RetireKey.php b/core/Command/OCM/RetireKey.php
new file mode 100644
index 0000000000000..58db976077c5f
--- /dev/null
+++ b/core/Command/OCM/RetireKey.php
@@ -0,0 +1,41 @@
+setName('ocm:keys:retire')
+ ->setDescription('delete the retiring Ed25519 key; signatures that referenced its kid can no longer be verified');
+ }
+
+ #[\Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ try {
+ $this->signatoryManager->retireEd25519Key();
+ } catch (\RuntimeException $e) {
+ $output->writeln('' . $e->getMessage() . '');
+ return 1;
+ }
+ $output->writeln('Retiring key deleted.');
+ return 0;
+ }
+}
diff --git a/core/Command/OCM/StageKey.php b/core/Command/OCM/StageKey.php
new file mode 100644
index 0000000000000..75437f460bfc2
--- /dev/null
+++ b/core/Command/OCM/StageKey.php
@@ -0,0 +1,42 @@
+setName('ocm:keys:stage')
+ ->setDescription('generate a new Ed25519 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->stageEd25519Key();
+ } catch (\RuntimeException $e) {
+ $output->writeln('' . $e->getMessage() . '');
+ return 1;
+ }
+ $output->writeln('Staged new Ed25519 key: ' . $signatory->getKeyId() . '');
+ $output->writeln('Wait for federated peers to refresh their JWKS cache before activating.');
+ return 0;
+ }
+}
diff --git a/core/register_command.php b/core/register_command.php
index d28c1633c62bb..856894b5c4c77 100644
--- a/core/register_command.php
+++ b/core/register_command.php
@@ -74,6 +74,10 @@
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 @@
$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));
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 28373a5af8597..7ca7f6837ce4c 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -1402,6 +1402,10 @@
'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',
@@ -1947,7 +1951,9 @@
'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',
@@ -2151,7 +2157,13 @@
'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',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index e28dc90763681..f50fce4223260 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -1443,6 +1443,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',
@@ -1988,7 +1992,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',
@@ -2192,7 +2198,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',
diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php
index 9459e9a03f043..77b7d63ec0d53 100644
--- a/lib/private/OCM/OCMDiscoveryService.php
+++ b/lib/private/OCM/OCMDiscoveryService.php
@@ -199,10 +199,15 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider {
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 +222,8 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider {
$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
@@ -342,10 +346,11 @@ public function requestRemoteOcmEndpoint(
}
/**
- * 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 +358,31 @@ private function prepareOcmPayload(string $uri, string $method, array $options,
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 {
diff --git a/lib/private/OCM/OCMJwksHandler.php b/lib/private/OCM/OCMJwksHandler.php
new file mode 100644
index 0000000000000..281c3eaab2d88
--- /dev/null
+++ b/lib/private/OCM/OCMJwksHandler.php
@@ -0,0 +1,49 @@
+appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
+ try {
+ foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) {
+ $keys[] = $jwk;
+ }
+ } catch (Throwable $e) {
+ $this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]);
+ }
+ }
+
+ return new GenericResponse(new JSONResponse(['keys' => $keys]));
+ }
+}
diff --git a/lib/private/OCM/OCMSignatoryManager.php b/lib/private/OCM/OCMSignatoryManager.php
index b239a4d1bceca..d60dc845e4ab7 100644
--- a/lib/private/OCM/OCMSignatoryManager.php
+++ b/lib/private/OCM/OCMSignatoryManager.php
@@ -9,21 +9,31 @@
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 @@
*
* @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_ED25519 = 'ed25519';
+ /** Ed25519 keypairs live in numbered pool appkeys; slots point to them by id. */
+ private const APPKEY_ED25519_POOL_PREFIX = 'ocm_ed25519_pool_';
+ private const APPCONFIG_ED25519_POOL_COUNTER = 'ocm_ed25519_pool_counter';
+ private const APPCONFIG_ED25519_POOL_KID_PREFIX = 'ocm_ed25519_pool_kid_';
+ /** Stable kid identity portion, reused across rotations so kids stay on one hostname. */
+ private const APPCONFIG_ED25519_KID_BASE = 'ocm_ed25519_kid_base';
+ public const SLOT_ACTIVE = 'active';
+ public const SLOT_PENDING = 'pending';
+ public const SLOT_RETIRING = 'retiring';
+ /** All slots in advertise order. */
+ public const ED25519_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 @@ public function getLocalSignatory(): Signatory {
* 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 @@ public function getLocalSignatory(): Signatory {
}
+ /** Active Ed25519 signing key, lazily provisioned. */
+ public function getLocalEd25519Signatory(): ?Signatory {
+ $poolId = $this->getSlotPool(self::SLOT_ACTIVE);
+ if ($poolId === null) {
+ $poolId = $this->generatePool($this->nextEd25519PoolKid());
+ $this->setSlotPool(self::SLOT_ACTIVE, $poolId);
+ }
+ return $this->signatoryFromPool($poolId);
+ }
+
+ /**
+ * 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>
+ */
+ public function getLocalEd25519Jwks(): array {
+ if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
+ $this->getLocalEd25519Signatory();
+ }
+
+ $jwks = [];
+ foreach (self::ED25519_SLOTS as $slot) {
+ $poolId = $this->getSlotPool($slot);
+ if ($poolId === null) {
+ continue;
+ }
+ $signatory = $this->signatoryFromPool($poolId);
+ if ($signatory !== null) {
+ $jwks[] = self::buildEd25519JwkArray($signatory->getPublicKey(), $signatory->getKeyId());
+ }
+ }
+ return $jwks;
+ }
+
+ /**
+ * Generate a pending Ed25519 keypair (advertised in JWKS, not yet used
+ * for outbound signing).
+ *
+ * @throws \RuntimeException if pending is already populated
+ */
+ public function stageEd25519Key(): Signatory {
+ if ($this->getSlotPool(self::SLOT_PENDING) !== null) {
+ throw new \RuntimeException('a pending Ed25519 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->getLocalEd25519Signatory();
+ }
+ $poolId = $this->generatePool($this->nextEd25519PoolKid());
+ $this->setSlotPool(self::SLOT_PENDING, $poolId);
+ $signatory = $this->signatoryFromPool($poolId);
+ if ($signatory === null) {
+ throw new \RuntimeException('failed to materialise newly staged Ed25519 key');
+ }
+ return $signatory;
+ }
+
+ /**
+ * pending -> active, previous active -> retiring. The retiring slot
+ * stays in JWKS until {@see retireEd25519Key} is run.
+ *
+ * @throws \RuntimeException if no pending key is staged, or retiring is occupied
+ */
+ public function activateStagedEd25519Key(): void {
+ $pending = $this->getSlotPool(self::SLOT_PENDING);
+ if ($pending === null) {
+ throw new \RuntimeException('no pending Ed25519 key to activate; run `ocm:keys:stage` first');
+ }
+ if ($this->getSlotPool(self::SLOT_RETIRING) !== null) {
+ throw new \RuntimeException('a retiring Ed25519 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 retireEd25519Key(): void {
+ $poolId = $this->getSlotPool(self::SLOT_RETIRING);
+ if ($poolId === null) {
+ throw new \RuntimeException('no retiring Ed25519 key to remove');
+ }
+ $this->identityProofManager->deleteAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId);
+ $this->appConfig->deleteKey('core', self::APPCONFIG_ED25519_POOL_KID_PREFIX . $poolId);
+ $this->clearSlot(self::SLOT_RETIRING);
+ }
+
+ /**
+ * Diagnostics snapshot. `slot` is null for orphaned pools.
+ *
+ * @return list
+ */
+ public function listEd25519Keys(): array {
+ $bySlot = [];
+ foreach (self::ED25519_SLOTS as $slot) {
+ $id = $this->getSlotPool($slot);
+ if ($id !== null) {
+ $bySlot[$id] = $slot;
+ }
+ }
+
+ $max = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0);
+ $entries = [];
+ for ($id = 1; $id <= $max; $id++) {
+ if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $id)) {
+ continue;
+ }
+ $entries[] = [
+ 'poolId' => $id,
+ 'kid' => $this->canonicalKid(
+ $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_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_ED25519_POOL_COUNTER, 0) + 1;
+ $this->appConfig->setValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, $poolId);
+
+ $this->identityProofManager->generateEd25519AppKey('core', self::APPKEY_ED25519_POOL_PREFIX . $poolId);
+ $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_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();
+ }
+
/**
- * - tries to generate a keyId using global configuration (from signature manager) if available
- * - generate a keyId using the current route to ocm shares
+ * 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 nextEd25519PoolKid(): string {
+ $base = $this->resolveEd25519KidBase();
+ $next = $this->appConfig->getValueInt('core', self::APPCONFIG_ED25519_POOL_COUNTER, 0) + 1;
+ return $base . '-' . $next;
+ }
+
+ /**
+ * Stable identity portion (before the `-N` suffix). Resolution order:
+ * stored APPCONFIG_ED25519_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 resolveEd25519KidBase(): string {
+ $base = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_KID_BASE, '');
+ if ($base !== '') {
+ return $base;
+ }
+
+ $activePool = $this->getSlotPool(self::SLOT_ACTIVE);
+ if ($activePool !== null) {
+ $kid = $this->canonicalKid(
+ $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_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_ED25519));
+ } catch (IdentityNotFoundException $e) {
+ throw new \RuntimeException('cannot derive instance identity for Ed25519 kid', 0, $e);
+ }
+ }
+
+ $this->appConfig->setValueString('core', self::APPCONFIG_ED25519_KID_BASE, $base);
+ return $base;
+ }
+
+ private function getSlotPool(string $slot): ?int {
+ $key = 'ocm_ed25519_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_ed25519_slot_' . $slot, $poolId);
+ }
+
+ private function clearSlot(string $slot): void {
+ $this->appConfig->deleteKey('core', 'ocm_ed25519_slot_' . $slot);
+ }
+
+ /** Returns null if the underlying appkey was manually deleted. */
+ private function signatoryFromPool(int $poolId): ?Signatory {
+ $appKey = self::APPKEY_ED25519_POOL_PREFIX . $poolId;
+ if (!$this->identityProofManager->hasAppKey('core', $appKey)) {
+ return null;
+ }
+ $kid = $this->appConfig->getValueString('core', self::APPCONFIG_ED25519_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', 'ed25519')
* @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#'
$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,122 @@ public function getRemoteSignatory(string $remote): ?Signatory {
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>|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> $decoded */
+ return array_values(array_filter($decoded, 'is_array'));
+ }
+
+ /**
+ * @return list>|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>|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;
+ }
+
+ /**
+ * @return array
+ */
+ private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array {
+ return [
+ 'kty' => 'OKP',
+ 'crv' => 'Ed25519',
+ 'kid' => $kid,
+ 'alg' => 'EdDSA',
+ 'use' => 'sig',
+ 'x' => JWT::urlsafeB64Encode($rawPublicKey),
+ ];
+ }
}
diff --git a/lib/private/OCM/Rfc9421SignatoryManager.php b/lib/private/OCM/Rfc9421SignatoryManager.php
new file mode 100644
index 0000000000000..f0756d9ca6eff
--- /dev/null
+++ b/lib/private/OCM/Rfc9421SignatoryManager.php
@@ -0,0 +1,56 @@
+delegate->getProviderId();
+ }
+
+ #[\Override]
+ public function getOptions(): array {
+ return array_merge($this->delegate->getOptions(), ['rfc9421.format' => true]);
+ }
+
+ #[\Override]
+ public function getLocalSignatory(): Signatory {
+ $signatory = $this->delegate->getLocalEd25519Signatory();
+ if ($signatory === null) {
+ throw new IdentityNotFoundException('no Ed25519 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);
+ }
+}
diff --git a/lib/private/Security/IdentityProof/Manager.php b/lib/private/Security/IdentityProof/Manager.php
index ef0faeb6ad632..d6ebe3813b21e 100644
--- a/lib/private/Security/IdentityProof/Manager.php
+++ b/lib/private/Security/IdentityProof/Manager.php
@@ -178,6 +178,30 @@ public function generateAppKey(string $app, string $name, array $options = []):
return $this->generateKey($this->generateAppKeyId($app, $name), $options);
}
+ /**
+ * Generate an Ed25519 keypair via libsodium. Returns raw 32-byte public
+ * + 64-byte secret (sodium seed||publickey), no PEM. Overwrites if
+ * already present.
+ */
+ public function generateEd25519AppKey(string $app, string $name): Key {
+ $keyPair = sodium_crypto_sign_keypair();
+ $publicKey = sodium_crypto_sign_publickey($keyPair);
+ $privateKey = sodium_crypto_sign_secretkey($keyPair);
+
+ $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));
diff --git a/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php
new file mode 100644
index 0000000000000..3af3f7c0f8d7a
--- /dev/null
+++ b/lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php
@@ -0,0 +1,465 @@
+ */
+ private array $components;
+ /** @var array */
+ 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 `#`; 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
+ */
+ public function getSignatureParams(): array {
+ return $this->signatureParams;
+ }
+
+ /**
+ * @return list
+ */
+ 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 $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
+ */
+ 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();
+ }
+ $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, params: array}>
+ * @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 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 $parameters
+ * @return array
+ */
+ 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;
+ }
+}
diff --git a/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php
new file mode 100644
index 0000000000000..3a44776ef4ad1
--- /dev/null
+++ b/lib/private/Security/Signature/Model/Rfc9421OutgoingSignedRequest.php
@@ -0,0 +1,210 @@
+ $headerList */
+ private array $headerList = [];
+ private SignatureAlgorithm $algorithm;
+ private string $signingAlgorithm;
+ /** @var array */
+ 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'] ?? 'ed25519');
+ $contentDigestAlgorithm = (string)($options['rfc9421.contentDigestAlgorithm'] ?? ContentDigest::ALGO_SHA256);
+ /** @var list $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. `ed25519`). 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
+ */
+ 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,
+ ]
+ );
+ }
+}
diff --git a/lib/private/Security/Signature/Rfc9421/Algorithm.php b/lib/private/Security/Signature/Rfc9421/Algorithm.php
new file mode 100644
index 0000000000000..155aead60135f
--- /dev/null
+++ b/lib/private/Security/Signature/Rfc9421/Algorithm.php
@@ -0,0 +1,221 @@
+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 (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 $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) {
+ 'ed25519' => 'EdDSA',
+ '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;
+ }
+}
diff --git a/lib/private/Security/Signature/Rfc9421/ContentDigest.php b/lib/private/Security/Signature/Rfc9421/ContentDigest.php
new file mode 100644
index 0000000000000..7df49f624d22f
--- /dev/null
+++ b/lib/private/Security/Signature/Rfc9421/ContentDigest.php
@@ -0,0 +1,72 @@
+ $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 [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),
+ };
+ }
+}
diff --git a/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php
new file mode 100644
index 0000000000000..5747ccb43d8d2
--- /dev/null
+++ b/lib/private/Security/Signature/Rfc9421/IJwkResolvingSignatoryManager.php
@@ -0,0 +1,29 @@
+ $headers headers keyed by lowercase name
+ * @param list $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 $components
+ * @param array $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)) ?? '';
+ }
+}
diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php
index 11aff48438dbf..f094ac5148a37 100644
--- a/lib/private/Security/Signature/SignatureManager.php
+++ b/lib/private/Security/Signature/SignatureManager.php
@@ -11,6 +11,9 @@
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 @@ public function getIncomingSignedRequest(
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 @@ public function getIncomingSignedRequest(
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 @@ public function getOutgoingSignedRequest(
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();
diff --git a/psalm.xml b/psalm.xml
index f43487afeb31b..23d063915e05c 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -116,9 +116,11 @@
+
+
diff --git a/tests/lib/OCM/DiscoveryServiceTest.php b/tests/lib/OCM/DiscoveryServiceTest.php
index 1cf026a64bc0b..58a22a07bd166 100644
--- a/tests/lib/OCM/DiscoveryServiceTest.php
+++ b/tests/lib/OCM/DiscoveryServiceTest.php
@@ -128,6 +128,13 @@ public function testLocalBaseCapability(): void {
$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);
diff --git a/tests/lib/OCM/OCMJwksHandlerTest.php b/tests/lib/OCM/OCMJwksHandlerTest.php
new file mode 100644
index 0000000000000..7040b19f67537
--- /dev/null
+++ b/tests/lib/OCM/OCMJwksHandlerTest.php
@@ -0,0 +1,117 @@
+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('getLocalEd25519Jwks');
+
+ $body = $this->jsonBody($this->handler->handle('jwks.json', $this->context, null));
+ $this->assertSame(['keys' => []], $body);
+ }
+
+ public function testPublishesEd25519JwksWhenAvailable(): void {
+ $this->appConfig->method('getValueBool')->willReturn(false);
+ $jwk = [
+ 'kty' => 'OKP',
+ 'crv' => 'Ed25519',
+ 'kid' => 'https://example.org/ocm#ed25519',
+ 'alg' => 'EdDSA',
+ 'use' => 'sig',
+ 'x' => 'AAAA',
+ ];
+ $this->signatoryManager->method('getLocalEd25519Jwks')->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' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-1', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'AAAA',
+ ];
+ $pending = [
+ 'kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'kid-2', 'alg' => 'EdDSA', 'use' => 'sig', 'x' => 'BBBB',
+ ];
+ $this->signatoryManager->method('getLocalEd25519Jwks')->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('getLocalEd25519Jwks')->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('getLocalEd25519Jwks')
+ ->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();
+ }
+}
diff --git a/tests/lib/OCM/OCMSignatoryManagerJwksTest.php b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php
new file mode 100644
index 0000000000000..7fcc0818e31fc
--- /dev/null
+++ b/tests/lib/OCM/OCMSignatoryManagerJwksTest.php
@@ -0,0 +1,177 @@
+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' => [
+ ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'other', 'x' => 'AAAA'],
+ ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'BBBB'],
+ ],
+ ];
+ $this->respondWith($jwks);
+
+ $key = $this->signatoryManager->getRemoteKey('sender.example.org', $kid);
+ $this->assertNotNull($key);
+ $this->assertSame('EdDSA', $key->getAlgorithm());
+ // Key stores OKP material as plain base64 of the raw bytes.
+ $this->assertSame('BBBB', $key->getKeyMaterial());
+ }
+
+ public function testGetRemoteKeyReturnsNullWhenKidMissing(): void {
+ $this->respondWith(['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'unrelated', 'x' => 'AAAA']]]);
+ $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=OKP but no crv: parseKey rejects.
+ $this->respondWith(['keys' => [['kty' => 'OKP', 'kid' => 'kid', 'x' => 'AAAA']]]);
+ $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' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => $kid, 'x' => 'AAAA']]];
+ $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' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'old', 'x' => 'AAAA']]];
+ $second = ['keys' => [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'new', 'x' => 'BBBB']]];
+ $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;
+ }
+}
diff --git a/tests/lib/OCM/OCMSignatoryManagerRotationTest.php b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php
new file mode 100644
index 0000000000000..9b52d88c61f7b
--- /dev/null
+++ b/tests/lib/OCM/OCMSignatoryManagerRotationTest.php
@@ -0,0 +1,273 @@
+ in-memory backing store for IAppConfig core/* */
+ private array $appConfigStore = [];
+ /** @var array 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->getLocalEd25519Jwks();
+ $this->assertCount(1, $jwks);
+ $this->assertSame('https://alice.example/ocm#ed25519-1', $jwks[0]['kid']);
+
+ // And the bootstrapped key is the active one for outbound signing.
+ $signatory = $this->signatoryManager->getLocalEd25519Signatory();
+ $this->assertSame($jwks[0]['kid'], $signatory->getKeyId());
+ }
+
+ public function testFirstCallProvisionsActiveKey(): void {
+ $signatory = $this->signatoryManager->getLocalEd25519Signatory();
+ $this->assertNotNull($signatory);
+ $this->assertSame('https://alice.example/ocm#ed25519-1', $signatory->getKeyId());
+
+ $jwks = $this->signatoryManager->getLocalEd25519Jwks();
+ $this->assertCount(1, $jwks);
+ $this->assertSame($signatory->getKeyId(), $jwks[0]['kid']);
+
+ $listed = $this->signatoryManager->listEd25519Keys();
+ $this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed);
+ }
+
+ public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void {
+ $initial = $this->signatoryManager->getLocalEd25519Signatory();
+ $staged = $this->signatoryManager->stageEd25519Key();
+ $this->assertNotSame($initial->getKeyId(), $staged->getKeyId());
+
+ // Active signer is unchanged.
+ $this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId());
+
+ // JWKS now advertises both kids, active first then pending.
+ $jwks = $this->signatoryManager->getLocalEd25519Jwks();
+ $this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid'));
+ }
+
+ public function testStageRefusesIfPendingAlreadyExists(): void {
+ $this->signatoryManager->stageEd25519Key();
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/pending Ed25519 key already exists/');
+ $this->signatoryManager->stageEd25519Key();
+ }
+
+ public function testActivatePromotesPendingAndDemotesActive(): void {
+ $first = $this->signatoryManager->getLocalEd25519Signatory();
+ $staged = $this->signatoryManager->stageEd25519Key();
+ $this->signatoryManager->activateStagedEd25519Key();
+
+ // New signer is the formerly-staged key.
+ $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->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->getLocalEd25519Jwks(), 'kid');
+ $this->assertContains($first->getKeyId(), $kids);
+ $this->assertContains($staged->getKeyId(), $kids);
+ }
+
+ public function testActivateRefusesIfRetiringStillPopulated(): void {
+ $this->signatoryManager->getLocalEd25519Signatory();
+ $this->signatoryManager->stageEd25519Key();
+ $this->signatoryManager->activateStagedEd25519Key();
+ // Retiring slot is now populated; staging again is allowed but
+ // activating must refuse until the admin explicitly retires the old
+ // key.
+ $this->signatoryManager->stageEd25519Key();
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/retiring Ed25519 key still exists/');
+ $this->signatoryManager->activateStagedEd25519Key();
+ }
+
+ public function testActivateRefusesWithoutPendingKey(): void {
+ $this->signatoryManager->getLocalEd25519Signatory();
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/no pending Ed25519 key/');
+ $this->signatoryManager->activateStagedEd25519Key();
+ }
+
+ public function testRetireRemovesRetiringKeyFromJwks(): void {
+ $first = $this->signatoryManager->getLocalEd25519Signatory();
+ $staged = $this->signatoryManager->stageEd25519Key();
+ $this->signatoryManager->activateStagedEd25519Key();
+ $this->signatoryManager->retireEd25519Key();
+
+ $kids = array_column($this->signatoryManager->getLocalEd25519Jwks(), 'kid');
+ $this->assertSame([$staged->getKeyId()], $kids);
+ // listEd25519Keys also drops the retired pool.
+ $listed = $this->signatoryManager->listEd25519Keys();
+ $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->getLocalEd25519Signatory();
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/no retiring Ed25519 key/');
+ $this->signatoryManager->retireEd25519Key();
+ }
+
+ public function testKidStaysStableThroughLifecycle(): void {
+ $first = $this->signatoryManager->getLocalEd25519Signatory();
+ $staged = $this->signatoryManager->stageEd25519Key();
+ // 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->activateStagedEd25519Key();
+ $this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalEd25519Signatory()->getKeyId());
+
+ $this->signatoryManager->retireEd25519Key();
+ $this->signatoryManager->stageEd25519Key();
+ // And every newly minted kid must differ from prior ones, no pool
+ // counter rewinding.
+ $kids = array_column($this->signatoryManager->listEd25519Keys(), '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->getLocalEd25519Signatory();
+ }
+
+ 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('generateEd25519AppKey')->willReturnCallback(
+ function (string $app, string $name): Key {
+ $keyPair = sodium_crypto_sign_keypair();
+ $key = new Key(sodium_crypto_sign_publickey($keyPair), sodium_crypto_sign_secretkey($keyPair));
+ $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;
+ }
+}
diff --git a/tests/lib/OCM/Rfc9421SignatoryManagerTest.php b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php
new file mode 100644
index 0000000000000..f186986cf81a2
--- /dev/null
+++ b/tests/lib/OCM/Rfc9421SignatoryManagerTest.php
@@ -0,0 +1,78 @@
+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 testGetLocalSignatoryReturnsEd25519Key(): void {
+ $signatory = $this->createMock(Signatory::class);
+ $this->delegate->method('getLocalEd25519Signatory')->willReturn($signatory);
+
+ $this->assertSame($signatory, $this->wrapper->getLocalSignatory());
+ }
+
+ public function testGetLocalSignatoryThrowsWhenEd25519Unavailable(): void {
+ $this->delegate->method('getLocalEd25519Signatory')->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'));
+ }
+}
diff --git a/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php
new file mode 100644
index 0000000000000..5f4285f14ccde
--- /dev/null
+++ b/tests/lib/Security/Signature/Model/Rfc9421RoundTripTest.php
@@ -0,0 +1,316 @@
+ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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 testTamperedBodyRejected(): void {
+ [$signatory, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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, $jwk] = $this->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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->ed25519Material('https://sender.example.org/ocm#ed25519');
+ $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 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 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' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='),
+ ], 'EdDSA');
+ return [$signatory, $key];
+ }
+
+ 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;
+ }
+}
diff --git a/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
new file mode 100644
index 0000000000000..ba117ca99baf4
--- /dev/null
+++ b/tests/lib/Security/Signature/Rfc9421/AlgorithmTest.php
@@ -0,0 +1,197 @@
+assertSame('ed25519', Algorithm::normalize('ed25519'));
+ $this->assertSame('rsa-v1_5-sha256', Algorithm::normalize('rsa-v1_5-sha256'));
+ $this->assertSame('ecdsa-p256-sha256', Algorithm::normalize('ecdsa-p256-sha256'));
+ }
+
+ 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 testEd25519RoundTrip(): void {
+ [$priv, $key] = $this->ed25519KeyPair();
+ $base = 'arbitrary signature base';
+ $sig = Algorithm::sign($base, $priv, 'ed25519');
+ $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 {
+ // 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 {
+ // kty=OKP/crv=Ed25519 with alg=ES256 is contradictory; firebase's
+ // parseKey rejects it before we ever build a Key.
+ $keypair = sodium_crypto_sign_keypair();
+ $this->expectException(\Throwable::class);
+ JWK::parseKey([
+ 'kty' => 'OKP',
+ 'crv' => 'Ed25519',
+ 'kid' => 'k',
+ 'alg' => 'ES256',
+ 'x' => self::b64url(sodium_crypto_sign_publickey($keypair)),
+ ], null);
+ }
+
+ public function testAlgHintAgreesViaJoseAlias(): void {
+ [$priv, $key] = $this->ed25519KeyPair();
+ $base = 'agreement check';
+ $sig = Algorithm::sign($base, $priv, 'ed25519');
+ $this->assertTrue(Algorithm::verify($base, $sig, $key, 'ed25519'));
+ $this->assertTrue(Algorithm::verify($base, $sig, $key, 'EdDSA'));
+ }
+
+ 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));
+ }
+
+ /**
+ * @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), '+/', '-_'), '=');
+ }
+}
diff --git a/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php
new file mode 100644
index 0000000000000..4198acec5342b
--- /dev/null
+++ b/tests/lib/Security/Signature/Rfc9421/ContentDigestTest.php
@@ -0,0 +1,76 @@
+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']);
+ }
+}
diff --git a/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php
new file mode 100644
index 0000000000000..d5aed5e9ab679
--- /dev/null
+++ b/tests/lib/Security/Signature/Rfc9421/SignatureBaseTest.php
@@ -0,0 +1,85 @@
+ '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));
+ }
+}
diff --git a/tests/lib/Security/Signature/SignatureManagerDispatchTest.php b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php
new file mode 100644
index 0000000000000..ae5945fd9b5a9
--- /dev/null
+++ b/tests/lib/Security/Signature/SignatureManagerDispatchTest.php
@@ -0,0 +1,262 @@
+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 {
+ // Cavage signs with an RSA PEM, so we need a real RSA keypair here;
+ // the Ed25519 helper would produce libsodium bytes that openssl_sign
+ // can't consume.
+ $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->ed25519SignatoryManager(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, $secret] = $this->ed25519SignatoryManager(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#ed25519');
+
+ $signed = $this->signatureManager->getIncomingSignedRequest($resolver, $body);
+ $this->assertInstanceOf(Rfc9421IncomingSignedRequest::class, $signed);
+ }
+
+ public function testInboundRejectsRfc9421WhenSignatoryManagerCannotResolve(): void {
+ [$signatoryManager,] = $this->ed25519SignatoryManager(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, string} [manager, parsed verification key, raw secret key]
+ */
+ private function ed25519SignatoryManager(bool $rfc9421Format): array {
+ $keypair = sodium_crypto_sign_keypair();
+ $publicKey = sodium_crypto_sign_publickey($keypair);
+ $secretKey = sodium_crypto_sign_secretkey($keypair);
+ $kid = 'https://sender.example.org/ocm#ed25519';
+
+ $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' => rtrim(strtr(base64_encode($publicKey), '+/', '-_'), '='),
+ ], 'EdDSA');
+
+ $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, $secretKey];
+ }
+
+ 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);
+ }
+}