mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
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:
parent
3a99cf9a67
commit
3b5107bc96
10 changed files with 1185 additions and 30 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
49
lib/private/OCM/OCMJwksHandler.php
Normal file
49
lib/private/OCM/OCMJwksHandler.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\OCM;
|
||||
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Http\WellKnown\GenericResponse;
|
||||
use OCP\Http\WellKnown\IHandler;
|
||||
use OCP\Http\WellKnown\IRequestContext;
|
||||
use OCP\Http\WellKnown\IResponse;
|
||||
use OCP\IAppConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/** Serves `/.well-known/jwks.json` (RFC 7517) 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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
lib/private/OCM/Rfc9421SignatoryManager.php
Normal file
56
lib/private/OCM/Rfc9421SignatoryManager.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OC\OCM;
|
||||
|
||||
use Firebase\JWT\Key;
|
||||
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
|
||||
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
|
||||
use OCP\Security\Signature\Model\Signatory;
|
||||
|
||||
/**
|
||||
* Per-call wrapper around {@see OCMSignatoryManager} that swaps in the
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
117
tests/lib/OCM/OCMJwksHandlerTest.php
Normal file
117
tests/lib/OCM/OCMJwksHandlerTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
177
tests/lib/OCM/OCMSignatoryManagerJwksTest.php
Normal file
177
tests/lib/OCM/OCMSignatoryManagerJwksTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
273
tests/lib/OCM/OCMSignatoryManagerRotationTest.php
Normal file
273
tests/lib/OCM/OCMSignatoryManagerRotationTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
tests/lib/OCM/Rfc9421SignatoryManagerTest.php
Normal file
78
tests/lib/OCM/Rfc9421SignatoryManagerTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace Test\OCM;
|
||||
|
||||
use Firebase\JWT\Key;
|
||||
use OC\OCM\OCMSignatoryManager;
|
||||
use OC\OCM\Rfc9421SignatoryManager;
|
||||
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
|
||||
use OCP\Security\Signature\Model\Signatory;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Test\TestCase;
|
||||
|
||||
class Rfc9421SignatoryManagerTest extends TestCase {
|
||||
private OCMSignatoryManager&MockObject $delegate;
|
||||
private Rfc9421SignatoryManager $wrapper;
|
||||
|
||||
#[\Override]
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->delegate = $this->createMock(OCMSignatoryManager::class);
|
||||
$this->wrapper = new Rfc9421SignatoryManager($this->delegate);
|
||||
}
|
||||
|
||||
public function testGetOptionsForcesRfc9421Format(): void {
|
||||
$this->delegate->method('getOptions')->willReturn([
|
||||
'algorithm' => 'rsa-sha512',
|
||||
'rfc9421.format' => false,
|
||||
]);
|
||||
|
||||
$options = $this->wrapper->getOptions();
|
||||
$this->assertTrue($options['rfc9421.format']);
|
||||
$this->assertSame('rsa-sha512', $options['algorithm']);
|
||||
}
|
||||
|
||||
public function 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'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue