From 3b5107bc96a7b47270ebbaaff4cb2b17251ff321 Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Tue, 5 May 2026 16:29:45 +0200 Subject: [PATCH] feat(http-sig): OCM Ed25519 keys, JWKS endpoint, http-sig capability OCM dual-stack integration of RFC 9421 alongside the existing cavage publicKey path: - OCMSignatoryManager: Ed25519 active/pending/retiring slot rotation backed by numbered pool appkeys, getRemoteKey for inbound JWK lookup with per-origin cache + cache-miss refetch, and getLocalEd25519Jwks for the JWKS endpoint. - Rfc9421SignatoryManager: per-call wrapper that swaps in the Ed25519 signatory and toggles `rfc9421.format`. - OCMJwksHandler: serves /.well-known/jwks.json (RFC 7517) when signing is enabled. - OCMDiscoveryService: advertises `http-sig` in capabilities when signing is enabled, and picks the signature scheme on outbound based on the remote's advertised capabilities. - Application.php: register the JWKS well-known handler. Signed-off-by: Micke Nordin --- core/AppInfo/Application.php | 2 + lib/private/OCM/OCMDiscoveryService.php | 42 +- lib/private/OCM/OCMJwksHandler.php | 49 +++ lib/private/OCM/OCMSignatoryManager.php | 414 +++++++++++++++++- lib/private/OCM/Rfc9421SignatoryManager.php | 56 +++ tests/lib/OCM/DiscoveryServiceTest.php | 7 + tests/lib/OCM/OCMJwksHandlerTest.php | 117 +++++ tests/lib/OCM/OCMSignatoryManagerJwksTest.php | 177 ++++++++ .../OCM/OCMSignatoryManagerRotationTest.php | 273 ++++++++++++ tests/lib/OCM/Rfc9421SignatoryManagerTest.php | 78 ++++ 10 files changed, 1185 insertions(+), 30 deletions(-) create mode 100644 lib/private/OCM/OCMJwksHandler.php create mode 100644 lib/private/OCM/Rfc9421SignatoryManager.php create mode 100644 tests/lib/OCM/OCMJwksHandlerTest.php create mode 100644 tests/lib/OCM/OCMSignatoryManagerJwksTest.php create mode 100644 tests/lib/OCM/OCMSignatoryManagerRotationTest.php create mode 100644 tests/lib/OCM/Rfc9421SignatoryManagerTest.php diff --git a/core/AppInfo/Application.php b/core/AppInfo/Application.php index 15cf42c4a55..cd655ac386f 100644 --- a/core/AppInfo/Application.php +++ b/core/AppInfo/Application.php @@ -23,6 +23,7 @@ use OC\Core\Listener\BeforeTemplateRenderedListener; use OC\Core\Listener\PasswordUpdatedListener; use OC\Core\Notification\CoreNotifier; use OC\OCM\OCMDiscoveryHandler; +use OC\OCM\OCMJwksHandler; use OC\TagManager; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -88,6 +89,7 @@ class Application extends App implements IBootstrap { $context->registerConfigLexicon(ConfigLexicon::class); $context->registerWellKnownHandler(OCMDiscoveryHandler::class); + $context->registerWellKnownHandler(OCMJwksHandler::class); $context->registerCapability(Capabilities::class); } diff --git a/lib/private/OCM/OCMDiscoveryService.php b/lib/private/OCM/OCMDiscoveryService.php index 9459e9a03f0..77b7d63ec0d 100644 --- a/lib/private/OCM/OCMDiscoveryService.php +++ b/lib/private/OCM/OCMDiscoveryService.php @@ -199,10 +199,15 @@ final class OCMDiscoveryService implements IOCMDiscoveryService { return $provider; } + $signingEnabled = !$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true); + $provider->setEnabled(true); $provider->setApiVersion(self::API_VERSION); $provider->setEndPoint(substr($url, 0, $pos)); $provider->setCapabilities(['invite-accepted', 'notifications', 'shares']); + if ($signingEnabled) { + $provider->setCapabilities(['http-sig']); + } // The inviteAcceptDialog is available from the contacts app, if this config value is set $inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG); @@ -217,9 +222,8 @@ final class OCMDiscoveryService implements IOCMDiscoveryService { $provider->addResourceType($resource); if ($fullDetails) { - // Adding a public key to the ocm discovery try { - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + if ($signingEnabled) { /** * @experimental 31.0.0 * @psalm-suppress UndefinedInterfaceMethod @@ -342,10 +346,11 @@ final class OCMDiscoveryService implements IOCMDiscoveryService { } /** - * add entries to the payload to auth the whole request + * Sign the outgoing payload using the scheme the remote advertises + * (RFC 9421 if `http-sig`, else cavage if a `publicKey` is present). + * APPCONFIG_SIGN_ENFORCED / APPCONFIG_SIGN_DISABLED still apply. * * @throws OCMProviderException - * @return array */ private function prepareOcmPayload(string $uri, string $method, array $options, string $payload, bool $signed): array { $payload = array_merge($this->generateRequestOptions($options), ['body' => $payload]); @@ -353,20 +358,31 @@ final class OCMDiscoveryService implements IOCMDiscoveryService { return $payload; } - if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true) - && $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) { + $origin = $this->signatureManager->extractIdentityFromUri($uri); + $ocmProvider = $this->discover($origin); + + $useRfc9421 = $ocmProvider->hasCapability('http-sig'); + $hasPublicKey = $this->signatoryManager->getRemoteSignatory($origin) !== null; + + if (!$useRfc9421 && !$hasPublicKey + && $this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { throw new OCMProviderException('remote endpoint does not support signed request'); } - if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { - $signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload( - $this->signatoryManager, - $payload, - $method, $uri - ); + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + return $payload; } - return $signedPayload ?? $payload; + $signatoryManager = $useRfc9421 + ? new Rfc9421SignatoryManager($this->signatoryManager) + : $this->signatoryManager; + + return $this->signatureManager->signOutgoingRequestIClientPayload( + $signatoryManager, + $payload, + $method, + $uri, + ); } private function generateRequestOptions(array $options): array { diff --git a/lib/private/OCM/OCMJwksHandler.php b/lib/private/OCM/OCMJwksHandler.php new file mode 100644 index 00000000000..281c3eaab2d --- /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 b239a4d1bce..d60dc845e4a 100644 --- a/lib/private/OCM/OCMSignatoryManager.php +++ b/lib/private/OCM/OCMSignatoryManager.php @@ -9,21 +9,31 @@ declare(strict_types=1); namespace OC\OCM; +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use JsonException; use OC\Security\IdentityProof\Manager; +use OC\Security\Signature\Rfc9421\Algorithm; +use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager; +use OCP\Http\Client\IClientService; use OCP\IAppConfig; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; use OCP\IURLGenerator; use OCP\OCM\Exceptions\OCMProviderException; use OCP\Security\Signature\Enum\DigestAlgorithm; use OCP\Security\Signature\Enum\SignatoryType; use OCP\Security\Signature\Enum\SignatureAlgorithm; use OCP\Security\Signature\Exceptions\IdentityNotFoundException; -use OCP\Security\Signature\ISignatoryManager; use OCP\Security\Signature\ISignatureManager; use OCP\Security\Signature\Model\Signatory; use OCP\Server; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; +use Throwable; /** * @inheritDoc @@ -33,19 +43,41 @@ use Psr\Log\LoggerInterface; * * @since 31.0.0 */ -class OCMSignatoryManager implements ISignatoryManager { +class OCMSignatoryManager implements IJwkResolvingSignatoryManager { public const PROVIDER_ID = 'ocm'; public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external'; public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled'; public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced'; + private const APPKEY_CAVAGE = 'ocm_external'; + private const KEYID_FRAGMENT_CAVAGE = 'signature'; + private const KEYID_FRAGMENT_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 @@ class OCMSignatoryManager implements ISignatoryManager { * TODO: manage multiple identity (external, internal, ...) to allow a limitation * based on the requested interface (ie. only accept shares from globalscale) */ - if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) { - $identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true); - $keyId = 'https://' . $identity . '/ocm#signature'; - } else { - $keyId = $this->generateKeyId(); - } + $keyId = $this->buildLocalKeyId(self::KEYID_FRAGMENT_CAVAGE); - if (!$this->identityProofManager->hasAppKey('core', 'ocm_external')) { - $this->identityProofManager->generateAppKey('core', 'ocm_external', [ + if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_CAVAGE)) { + $this->identityProofManager->generateAppKey('core', self::APPKEY_CAVAGE, [ 'algorithm' => 'rsa', 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); } - $keyPair = $this->identityProofManager->getAppKey('core', 'ocm_external'); + $keyPair = $this->identityProofManager->getAppKey('core', self::APPKEY_CAVAGE); $signatory = new Signatory(true); $signatory->setKeyId($keyId); @@ -115,28 +142,263 @@ class OCMSignatoryManager implements ISignatoryManager { } + /** Active 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); + } + /** - * - tries to generate a keyId using global configuration (from signature manager) if available - * - generate a keyId using the current route to ocm shares + * JWKs for the active/pending/retiring slots, in advertise order. The + * active slot is provisioned if missing so first-hit returns a key. * + * @return list> + */ + 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(); + } + + /** + * 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 @@ class OCMSignatoryManager implements ISignatoryManager { return null; } } + + /** + * Resolve a peer's JWK by kid. Cached per-origin for {@see JWKS_CACHE_TTL}s + * with a single refetch on cache-hit-but-kid-missing so rotations propagate. + */ + #[\Override] + public function getRemoteKey(string $origin, string $keyId): ?Key { + $keys = $this->readCachedJwks($origin); + $fromCache = $keys !== null; + if (!$fromCache) { + $keys = $this->fetchJwks($origin); + if ($keys !== null) { + $this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL); + } + } + + $key = $this->findKid($keys, $keyId); + if ($key !== null) { + return $key; + } + // Only refetch when the miss came from cache; fresh is authoritative. + if (!$fromCache) { + return null; + } + + $keys = $this->fetchJwks($origin); + if ($keys === null) { + return null; + } + $this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL); + return $this->findKid($keys, $keyId); + } + + /** @return list>|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 00000000000..f0756d9ca6e --- /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/tests/lib/OCM/DiscoveryServiceTest.php b/tests/lib/OCM/DiscoveryServiceTest.php index 1cf026a64bc..58a22a07bd1 100644 --- a/tests/lib/OCM/DiscoveryServiceTest.php +++ b/tests/lib/OCM/DiscoveryServiceTest.php @@ -128,6 +128,13 @@ class DiscoveryServiceTest extends TestCase { $this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities())); } + public function testLocalCapabilitiesAdvertiseHttpSigByDefault(): void { + // `http-sig` is the OCM-spec flag signalling RFC 9421 support backed + // by /.well-known/jwks.json. Advertised whenever signing is not + // disabled outright. + $local = $this->discoveryService->getLocalOCMProvider(); + $this->assertTrue($local->hasCapability('http-sig')); + } public function testLocalAddedCapability(): void { $this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class); diff --git a/tests/lib/OCM/OCMJwksHandlerTest.php b/tests/lib/OCM/OCMJwksHandlerTest.php new file mode 100644 index 00000000000..7040b19f675 --- /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 00000000000..7fcc0818e31 --- /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 00000000000..9b52d88c61f --- /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 00000000000..f186986cf81 --- /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')); + } +}