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 <kano@sunet.se>
This commit is contained in:
Micke Nordin 2026-05-05 16:29:45 +02:00 committed by Micke Nordin
parent 3a99cf9a67
commit 3b5107bc96
10 changed files with 1185 additions and 30 deletions

View file

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

View file

@ -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 {

View file

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

View file

@ -9,21 +9,31 @@ declare(strict_types=1);
namespace OC\OCM;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use JsonException;
use OC\Security\IdentityProof\Manager;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatoryType;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\ISignatureManager;
use OCP\Security\Signature\Model\Signatory;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* @inheritDoc
@ -33,19 +43,41 @@ use Psr\Log\LoggerInterface;
*
* @since 31.0.0
*/
class OCMSignatoryManager implements ISignatoryManager {
class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
public const PROVIDER_ID = 'ocm';
public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external';
public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled';
public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced';
private const APPKEY_CAVAGE = 'ocm_external';
private const KEYID_FRAGMENT_CAVAGE = 'signature';
private const KEYID_FRAGMENT_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<array<string, string>>
*/
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<array{poolId: int, kid: string, slot: ?string}>
*/
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#<fragment>'
$path = parse_url($url, PHP_URL_PATH);
$pos = strpos($path, '/ocm/shares');
$sub = ($pos) ? substr($path, 0, $pos) : '';
return 'https://' . $identity . $sub . '/ocm#signature';
return 'https://' . $identity . $sub . '/ocm#' . $fragment;
}
/**
@ -163,4 +425,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<array<string, mixed>>|null null on cold/corrupt cache */
private function readCachedJwks(string $origin): ?array {
$cached = $this->jwksCache->get($origin);
if (!is_string($cached)) {
return null;
}
try {
$decoded = json_decode($cached, true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (!is_array($decoded)) {
return null;
}
/** @var list<array<string, mixed>> $decoded */
return array_values(array_filter($decoded, 'is_array'));
}
/**
* @return list<array<string, mixed>>|null
*/
private function fetchJwks(string $origin): ?array {
$url = 'https://' . $origin . '/.well-known/jwks.json';
$options = [
'timeout' => 10,
'connect_timeout' => 10,
];
if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) {
$options['verify'] = false;
}
try {
$response = $this->clientService->newClient()->get($url, $options);
} catch (Throwable $e) {
$this->logger->warning('failed to fetch remote JWKS', ['exception' => $e, 'url' => $url]);
return null;
}
try {
$decoded = json_decode((string)$response->getBody(), true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->logger->warning('remote JWKS is not valid JSON', ['exception' => $e, 'url' => $url]);
return null;
}
if (!is_array($decoded) || !is_array($decoded['keys'] ?? null)) {
return null;
}
return array_values(array_filter($decoded['keys'], 'is_array'));
}
/**
* @param list<array<string, mixed>>|null $keys
*/
private function findKid(?array $keys, string $keyId): ?Key {
if ($keys === null) {
return null;
}
foreach ($keys as $entry) {
if (($entry['kid'] ?? null) !== $keyId) {
continue;
}
try {
return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry));
} catch (Throwable $e) {
$this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]);
return null;
}
}
return null;
}
/**
* @return array<string, string>
*/
private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array {
return [
'kty' => 'OKP',
'crv' => 'Ed25519',
'kid' => $kid,
'alg' => 'EdDSA',
'use' => 'sig',
'x' => JWT::urlsafeB64Encode($rawPublicKey),
];
}
}

View file

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

View file

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

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\OCM\OCMJwksHandler;
use OC\OCM\OCMSignatoryManager;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Http\WellKnown\GenericResponse;
use OCP\Http\WellKnown\IRequestContext;
use OCP\Http\WellKnown\IResponse;
use OCP\Http\WellKnown\JrdResponse;
use OCP\IAppConfig;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class OCMJwksHandlerTest extends TestCase {
private IAppConfig&MockObject $appConfig;
private OCMSignatoryManager&MockObject $signatoryManager;
private LoggerInterface&MockObject $logger;
private IRequestContext&MockObject $context;
private OCMJwksHandler $handler;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->signatoryManager = $this->createMock(OCMSignatoryManager::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->context = $this->createMock(IRequestContext::class);
$this->handler = new OCMJwksHandler(
$this->appConfig,
$this->signatoryManager,
$this->logger,
);
}
public function testIgnoresUnrelatedService(): void {
$previous = new JrdResponse('foo');
$result = $this->handler->handle('webfinger', $this->context, $previous);
$this->assertSame($previous, $result);
}
public function testEmptyKeySetWhenSigningDisabled(): void {
$this->appConfig->method('getValueBool')
->with('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, false, true)
->willReturn(true);
$this->signatoryManager->expects($this->never())->method('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();
}
}

View file

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\OCM\OCMSignatoryManager;
use OC\Security\IdentityProof\Manager as IdentityProofManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class OCMSignatoryManagerJwksTest extends TestCase {
private IAppConfig&MockObject $appConfig;
private ISignatureManager&MockObject $signatureManager;
private IURLGenerator&MockObject $urlGenerator;
private IdentityProofManager&MockObject $identityProofManager;
private IClientService&MockObject $clientService;
private IConfig&MockObject $config;
private LoggerInterface&MockObject $logger;
private IClient&MockObject $client;
private OCMSignatoryManager $signatoryManager;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->signatureManager = $this->createMock(ISignatureManager::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->identityProofManager = $this->createMock(IdentityProofManager::class);
$this->clientService = $this->createMock(IClientService::class);
$this->config = $this->createMock(IConfig::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->client = $this->createMock(IClient::class);
$this->clientService->method('newClient')->willReturn($this->client);
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache(''));
$this->signatoryManager = new OCMSignatoryManager(
$this->appConfig,
$this->signatureManager,
$this->urlGenerator,
$this->identityProofManager,
$this->clientService,
$this->config,
$cacheFactory,
$this->logger,
);
}
public function testGetRemoteKeyFetchesAndMatchesByKid(): void {
$kid = 'sender.example.org#key1';
$jwks = [
'keys' => [
['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;
}
}

View file

@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\OCM\OCMSignatoryManager;
use OC\Security\IdentityProof\Key;
use OC\Security\IdentityProof\Manager as IdentityProofManager;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
/** Ed25519 stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */
class OCMSignatoryManagerRotationTest extends TestCase {
private IAppConfig&MockObject $appConfig;
private IdentityProofManager&MockObject $identityProofManager;
private OCMSignatoryManager $signatoryManager;
/** @var array<string, string> in-memory backing store for IAppConfig core/* */
private array $appConfigStore = [];
/** @var array<string, Key> in-memory backing store for IdentityProofManager appkeys */
private array $appKeyStore = [];
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->identityProofManager = $this->createMock(IdentityProofManager::class);
$this->wireAppConfig();
$this->wireIdentityProofManager();
$signatureManager = $this->createMock(ISignatureManager::class);
$signatureManager->method('generateKeyIdFromConfig')
->willReturnCallback(static fn (string $suffix): string => 'https://alice.example/' . ltrim($suffix, '/'));
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')->willReturn(new \OC\Memcache\ArrayCache(''));
$this->signatoryManager = new OCMSignatoryManager(
$this->appConfig,
$signatureManager,
$this->createMock(IURLGenerator::class),
$this->identityProofManager,
$this->stubClientService(),
$this->createMock(IConfig::class),
$cacheFactory,
$this->createMock(LoggerInterface::class),
);
}
public function testJwksBootstrapsActiveKeyOnFirstFetch(): void {
// Fresh instance: first JWKS hit must provision the active key.
$jwks = $this->signatoryManager->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;
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use Firebase\JWT\Key;
use OC\OCM\OCMSignatoryManager;
use OC\OCM\Rfc9421SignatoryManager;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Model\Signatory;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class Rfc9421SignatoryManagerTest extends TestCase {
private OCMSignatoryManager&MockObject $delegate;
private Rfc9421SignatoryManager $wrapper;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->delegate = $this->createMock(OCMSignatoryManager::class);
$this->wrapper = new Rfc9421SignatoryManager($this->delegate);
}
public function testGetOptionsForcesRfc9421Format(): void {
$this->delegate->method('getOptions')->willReturn([
'algorithm' => 'rsa-sha512',
'rfc9421.format' => false,
]);
$options = $this->wrapper->getOptions();
$this->assertTrue($options['rfc9421.format']);
$this->assertSame('rsa-sha512', $options['algorithm']);
}
public function 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'));
}
}