Skip to content

Commit 16d70fc

Browse files
committed
feat(http-sig): Add occ commands to rotate keys
occ com:keys:list occ com:keys:stage occ com:keys:activate occ com:keys:retire We cache keys for an hour, so at least that time should expire between stage and retire. Signed-off-by: Micke Nordin <kano@sunet.se>
1 parent 8bee990 commit 16d70fc

14 files changed

Lines changed: 1062 additions & 48 deletions

core/Command/OCM/ActivateKey.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OC\Core\Command\OCM;
10+
11+
use OC\Core\Command\Base;
12+
use OC\OCM\OCMSignatoryManager;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
class ActivateKey extends Base {
17+
public function __construct(
18+
private readonly OCMSignatoryManager $signatoryManager,
19+
) {
20+
parent::__construct();
21+
}
22+
23+
#[\Override]
24+
protected function configure(): void {
25+
$this
26+
->setName('ocm:keys:activate')
27+
->setDescription('promote the staged Ed25519 key to active; the previous active key moves to retiring');
28+
}
29+
30+
#[\Override]
31+
protected function execute(InputInterface $input, OutputInterface $output): int {
32+
try {
33+
$this->signatoryManager->activateStagedEd25519Key();
34+
} catch (\RuntimeException $e) {
35+
$output->writeln('<error>' . $e->getMessage() . '</error>');
36+
return 1;
37+
}
38+
$output->writeln('<info>Staged key promoted to active.</info>');
39+
$output->writeln('Run <info>occ ocm:keys:retire</info> once any in-flight signatures using the previous key have been verified.');
40+
return 0;
41+
}
42+
}

core/Command/OCM/ListKeys.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OC\Core\Command\OCM;
10+
11+
use OC\Core\Command\Base;
12+
use OC\OCM\OCMSignatoryManager;
13+
use Symfony\Component\Console\Helper\Table;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
class ListKeys extends Base {
18+
public function __construct(
19+
private readonly OCMSignatoryManager $signatoryManager,
20+
) {
21+
parent::__construct();
22+
}
23+
24+
#[\Override]
25+
protected function configure(): void {
26+
$this
27+
->setName('ocm:keys:list')
28+
->setDescription('list Ed25519 keys used by OCM RFC 9421 HTTP Message Signatures');
29+
parent::configure();
30+
}
31+
32+
#[\Override]
33+
protected function execute(InputInterface $input, OutputInterface $output): int {
34+
$keys = $this->signatoryManager->listEd25519Keys();
35+
$format = $input->getOption('output');
36+
if ($format === self::OUTPUT_FORMAT_JSON || $format === self::OUTPUT_FORMAT_JSON_PRETTY) {
37+
$output->writeln(json_encode($keys, $format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0));
38+
return 0;
39+
}
40+
41+
if ($keys === []) {
42+
$output->writeln('<comment>No Ed25519 keys yet — one will be generated on first OCM request.</comment>');
43+
return 0;
44+
}
45+
46+
$table = new Table($output);
47+
$table->setHeaders(['Pool', 'Slot', 'Key ID']);
48+
foreach ($keys as $key) {
49+
$table->addRow([$key['poolId'], $key['slot'] ?? '-', $key['kid']]);
50+
}
51+
$table->render();
52+
return 0;
53+
}
54+
}

core/Command/OCM/RetireKey.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OC\Core\Command\OCM;
10+
11+
use OC\Core\Command\Base;
12+
use OC\OCM\OCMSignatoryManager;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
class RetireKey extends Base {
17+
public function __construct(
18+
private readonly OCMSignatoryManager $signatoryManager,
19+
) {
20+
parent::__construct();
21+
}
22+
23+
#[\Override]
24+
protected function configure(): void {
25+
$this
26+
->setName('ocm:keys:retire')
27+
->setDescription('delete the retiring Ed25519 key; signatures that referenced its kid can no longer be verified');
28+
}
29+
30+
#[\Override]
31+
protected function execute(InputInterface $input, OutputInterface $output): int {
32+
try {
33+
$this->signatoryManager->retireEd25519Key();
34+
} catch (\RuntimeException $e) {
35+
$output->writeln('<error>' . $e->getMessage() . '</error>');
36+
return 1;
37+
}
38+
$output->writeln('<info>Retiring key deleted.</info>');
39+
return 0;
40+
}
41+
}

core/Command/OCM/StageKey.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OC\Core\Command\OCM;
10+
11+
use OC\Core\Command\Base;
12+
use OC\OCM\OCMSignatoryManager;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
class StageKey extends Base {
17+
public function __construct(
18+
private readonly OCMSignatoryManager $signatoryManager,
19+
) {
20+
parent::__construct();
21+
}
22+
23+
#[\Override]
24+
protected function configure(): void {
25+
$this
26+
->setName('ocm:keys:stage')
27+
->setDescription('generate a new Ed25519 key and advertise it via JWKS without using it for signing yet');
28+
}
29+
30+
#[\Override]
31+
protected function execute(InputInterface $input, OutputInterface $output): int {
32+
try {
33+
$signatory = $this->signatoryManager->stageEd25519Key();
34+
} catch (\RuntimeException $e) {
35+
$output->writeln('<error>' . $e->getMessage() . '</error>');
36+
return 1;
37+
}
38+
$output->writeln('Staged new Ed25519 key: <info>' . $signatory->getKeyId() . '</info>');
39+
$output->writeln('Wait for federated peers to refresh their JWKS cache before activating.');
40+
return 0;
41+
}
42+
}

core/register_command.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@
7474
use OC\Core\Command\Memcache\DistributedGet;
7575
use OC\Core\Command\Memcache\DistributedSet;
7676
use OC\Core\Command\Memcache\RedisCommand;
77+
use OC\Core\Command\OCM\ActivateKey as OCMActivateKey;
78+
use OC\Core\Command\OCM\ListKeys as OCMListKeys;
79+
use OC\Core\Command\OCM\RetireKey as OCMRetireKey;
80+
use OC\Core\Command\OCM\StageKey as OCMStageKey;
7781
use OC\Core\Command\Preview\Generate;
7882
use OC\Core\Command\Preview\ResetRenderedTexts;
7983
use OC\Core\Command\Router\ListRoutes;
@@ -251,6 +255,11 @@
251255
$application->add(Server::get(SnowflakeDecodeId::class));
252256
$application->add(Server::get(Get::class));
253257

258+
$application->add(Server::get(OCMListKeys::class));
259+
$application->add(Server::get(OCMStageKey::class));
260+
$application->add(Server::get(OCMActivateKey::class));
261+
$application->add(Server::get(OCMRetireKey::class));
262+
254263
$application->add(Server::get(GetCommand::class));
255264
$application->add(Server::get(EnabledCommand::class));
256265
$application->add(Server::get(Command\TaskProcessing\ListCommand::class));

lib/private/OCM/OCMJwksHandler.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,11 @@ public function handle(string $service, IRequestContext $context, ?IResponse $pr
4242
$keys = [];
4343
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
4444
try {
45-
$jwk = $this->signatoryManager->getLocalEd25519Jwk();
46-
if ($jwk !== null) {
45+
foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) {
4746
$keys[] = $jwk->toArray();
4847
}
4948
} catch (Throwable $e) {
50-
$this->logger->warning('failed to build local Ed25519 JWK', ['exception' => $e]);
49+
$this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]);
5150
}
5251
}
5352

0 commit comments

Comments
 (0)