2026-05-05 10:29:45 -04:00
|
|
|
<?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;
|
|
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
/** JWKS stage / activate / retire lifecycle, with stateful IAppConfig + IdentityProofManager fakes. */
|
2026-05-05 10:29:45 -04:00
|
|
|
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.
|
2026-05-11 10:13:13 -04:00
|
|
|
$jwks = $this->signatoryManager->getLocalJwks();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertCount(1, $jwks);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $jwks[0]['kid']);
|
2026-05-05 10:29:45 -04:00
|
|
|
|
|
|
|
|
// And the bootstrapped key is the active one for outbound signing.
|
2026-05-11 10:13:13 -04:00
|
|
|
$signatory = $this->signatoryManager->getLocalJwksSignatory();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertSame($jwks[0]['kid'], $signatory->getKeyId());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testFirstCallProvisionsActiveKey(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$signatory = $this->signatoryManager->getLocalJwksSignatory();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertNotNull($signatory);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->assertSame('https://alice.example/ocm#ecdsa-p256-sha256-1', $signatory->getKeyId());
|
2026-05-05 10:29:45 -04:00
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
$jwks = $this->signatoryManager->getLocalJwks();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertCount(1, $jwks);
|
|
|
|
|
$this->assertSame($signatory->getKeyId(), $jwks[0]['kid']);
|
|
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
$listed = $this->signatoryManager->listJwksKeys();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertSame([['poolId' => 1, 'kid' => $signatory->getKeyId(), 'slot' => 'active']], $listed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testStageDoesNotChangeActiveSignerButPublishesNewJwk(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$initial = $this->signatoryManager->getLocalJwksSignatory();
|
|
|
|
|
$staged = $this->signatoryManager->stageJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertNotSame($initial->getKeyId(), $staged->getKeyId());
|
|
|
|
|
|
|
|
|
|
// Active signer is unchanged.
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->assertSame($initial->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
|
2026-05-05 10:29:45 -04:00
|
|
|
|
|
|
|
|
// JWKS now advertises both kids, active first then pending.
|
2026-05-11 10:13:13 -04:00
|
|
|
$jwks = $this->signatoryManager->getLocalJwks();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertSame([$initial->getKeyId(), $staged->getKeyId()], array_column($jwks, 'kid'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testStageRefusesIfPendingAlreadyExists(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->stageJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->expectException(\RuntimeException::class);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->expectExceptionMessageMatches('/pending JWKS key already exists/');
|
|
|
|
|
$this->signatoryManager->stageJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testActivatePromotesPendingAndDemotesActive(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$first = $this->signatoryManager->getLocalJwksSignatory();
|
|
|
|
|
$staged = $this->signatoryManager->stageJwksKey();
|
|
|
|
|
$this->signatoryManager->activateStagedJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
|
|
|
|
|
// New signer is the formerly-staged key.
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
|
2026-05-05 10:29:45 -04:00
|
|
|
|
|
|
|
|
// JWKS still advertises the former active key as retiring so peers
|
|
|
|
|
// verifying in-flight signatures with its kid don't fail.
|
2026-05-11 10:13:13 -04:00
|
|
|
$kids = array_column($this->signatoryManager->getLocalJwks(), 'kid');
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertContains($first->getKeyId(), $kids);
|
|
|
|
|
$this->assertContains($staged->getKeyId(), $kids);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testActivateRefusesIfRetiringStillPopulated(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->getLocalJwksSignatory();
|
|
|
|
|
$this->signatoryManager->stageJwksKey();
|
|
|
|
|
$this->signatoryManager->activateStagedJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
// Retiring slot is now populated; staging again is allowed but
|
|
|
|
|
// activating must refuse until the admin explicitly retires the old
|
|
|
|
|
// key.
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->stageJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->expectException(\RuntimeException::class);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->expectExceptionMessageMatches('/retiring JWKS key still exists/');
|
|
|
|
|
$this->signatoryManager->activateStagedJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testActivateRefusesWithoutPendingKey(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->getLocalJwksSignatory();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->expectException(\RuntimeException::class);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->expectExceptionMessageMatches('/no pending JWKS key/');
|
|
|
|
|
$this->signatoryManager->activateStagedJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testRetireRemovesRetiringKeyFromJwks(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$first = $this->signatoryManager->getLocalJwksSignatory();
|
|
|
|
|
$staged = $this->signatoryManager->stageJwksKey();
|
|
|
|
|
$this->signatoryManager->activateStagedJwksKey();
|
|
|
|
|
$this->signatoryManager->retireJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
$kids = array_column($this->signatoryManager->getLocalJwks(), 'kid');
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertSame([$staged->getKeyId()], $kids);
|
2026-05-11 10:13:13 -04:00
|
|
|
// listJwksKeys also drops the retired pool.
|
|
|
|
|
$listed = $this->signatoryManager->listJwksKeys();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->assertCount(1, $listed);
|
|
|
|
|
$this->assertSame($staged->getKeyId(), $listed[0]['kid']);
|
|
|
|
|
$this->assertNotContains($first->getKeyId(), array_column($listed, 'kid'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testRetireRefusesWhenNothingToRetire(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->getLocalJwksSignatory();
|
2026-05-05 10:29:45 -04:00
|
|
|
$this->expectException(\RuntimeException::class);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->expectExceptionMessageMatches('/no retiring JWKS key/');
|
|
|
|
|
$this->signatoryManager->retireJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testKidStaysStableThroughLifecycle(): void {
|
2026-05-11 10:13:13 -04:00
|
|
|
$first = $this->signatoryManager->getLocalJwksSignatory();
|
|
|
|
|
$staged = $this->signatoryManager->stageJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
// 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.
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->activateStagedJwksKey();
|
|
|
|
|
$this->assertSame($staged->getKeyId(), $this->signatoryManager->getLocalJwksSignatory()->getKeyId());
|
2026-05-05 10:29:45 -04:00
|
|
|
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->signatoryManager->retireJwksKey();
|
|
|
|
|
$this->signatoryManager->stageJwksKey();
|
2026-05-05 10:29:45 -04:00
|
|
|
// And every newly minted kid must differ from prior ones, no pool
|
|
|
|
|
// counter rewinding.
|
2026-05-11 10:13:13 -04:00
|
|
|
$kids = array_column($this->signatoryManager->listJwksKeys(), 'kid');
|
2026-05-05 10:29:45 -04:00
|
|
|
$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);
|
2026-05-11 10:13:13 -04:00
|
|
|
$manager->getLocalJwksSignatory();
|
2026-05-05 10:29:45 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
);
|
2026-05-11 10:13:13 -04:00
|
|
|
$this->identityProofManager->method('generateEcdsaP256AppKey')->willReturnCallback(
|
2026-05-05 10:29:45 -04:00
|
|
|
function (string $app, string $name): Key {
|
2026-05-11 10:13:13 -04:00
|
|
|
$res = openssl_pkey_new([
|
|
|
|
|
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
|
|
|
|
'curve_name' => 'prime256v1',
|
|
|
|
|
]);
|
|
|
|
|
openssl_pkey_export($res, $privatePem);
|
|
|
|
|
$publicPem = openssl_pkey_get_details($res)['key'];
|
|
|
|
|
$key = new Key($publicPem, $privatePem);
|
2026-05-05 10:29:45 -04:00
|
|
|
$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;
|
|
|
|
|
}
|
|
|
|
|
}
|