Skip to content

Commit 5ffde03

Browse files
authored
Merge pull request #60136 from nextcloud/kano-dual-stack-rfc-9421-http-sig
feat(http-sig): Dual stack http-sig
2 parents 496662e + 0dbb611 commit 5ffde03

40 files changed

Lines changed: 3795 additions & 111 deletions

apps/cloud_federation_api/lib/Controller/RequestHandlerController.php

Lines changed: 9 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
1313
use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent;
1414
use OCA\CloudFederationAPI\ResponseDefinitions;
15-
use OCA\FederatedFileSharing\AddressHandler;
1615
use OCP\AppFramework\Controller;
1716
use OCP\AppFramework\Db\DoesNotExistException;
1817
use OCP\AppFramework\Http;
@@ -38,11 +37,8 @@
3837
use OCP\IURLGenerator;
3938
use OCP\IUserManager;
4039
use OCP\OCM\IOCMDiscoveryService;
41-
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
4240
use OCP\Security\Signature\Exceptions\IncomingRequestException;
43-
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
4441
use OCP\Security\Signature\IIncomingSignedRequest;
45-
use OCP\Security\Signature\ISignatureManager;
4642
use OCP\Share\Exceptions\ShareNotFound;
4743
use OCP\Util;
4844
use Psr\Log\LoggerInterface;
@@ -69,12 +65,10 @@ public function __construct(
6965
private Config $config,
7066
private IEventDispatcher $dispatcher,
7167
private FederatedInviteMapper $federatedInviteMapper,
72-
private readonly AddressHandler $addressHandler,
7368
private readonly IAppConfig $appConfig,
7469
private ICloudFederationFactory $factory,
7570
private ICloudIdManager $cloudIdManager,
7671
private readonly IOCMDiscoveryService $ocmDiscoveryService,
77-
private readonly ISignatureManager $signatureManager,
7872
private ITimeFactory $timeFactory,
7973
) {
8074
parent::__construct($appName, $request);
@@ -440,28 +434,22 @@ private function mapUid($uid) {
440434
* If request is not signed, we still verify that the hostname from the extracted value does,
441435
* actually, not support signed request
442436
*
437+
* Delegates to {@see IOCMDiscoveryService::confirmRequestOrigin()}.
438+
*
443439
* @param IIncomingSignedRequest|null $signedRequest
444440
* @param string $key entry from data available in data
445441
* @param string $value value itself used in case request is not signed
446442
*
447443
* @throws IncomingRequestException
448444
*/
449445
private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
450-
if ($signedRequest === null) {
451-
$instance = $this->getHostFromFederationId($value);
452-
try {
453-
$this->signatureManager->getSignatory($instance);
454-
throw new IncomingRequestException('instance is supposed to sign its request');
455-
} catch (SignatoryNotFoundException) {
456-
return;
457-
}
458-
}
459-
460-
$body = json_decode($signedRequest->getBody(), true) ?? [];
461-
$entry = trim($body[$key] ?? '', '@');
462-
if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) {
463-
throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']');
446+
if ($signedRequest !== null) {
447+
$body = json_decode($signedRequest->getBody(), true) ?? [];
448+
$entry = trim(($body[$key] ?? ''), '@');
449+
} else {
450+
$entry = trim($value, '@');
464451
}
452+
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $entry);
465453
}
466454

467455
/**
@@ -498,48 +486,6 @@ private function confirmNotificationIdentity(
498486
throw new IncomingRequestException($e->getMessage(), previous: $e);
499487
}
500488

501-
$this->confirmNotificationEntry($signedRequest, $identity);
502-
}
503-
504-
505-
/**
506-
* @param IIncomingSignedRequest|null $signedRequest
507-
* @param string $entry
508-
*
509-
* @return void
510-
* @throws IncomingRequestException
511-
*/
512-
private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void {
513-
$instance = $this->getHostFromFederationId($entry);
514-
if ($signedRequest === null) {
515-
try {
516-
$this->signatureManager->getSignatory($instance);
517-
throw new IncomingRequestException('instance is supposed to sign its request');
518-
} catch (SignatoryNotFoundException) {
519-
return;
520-
}
521-
} elseif ($instance !== $signedRequest->getOrigin()) {
522-
throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin());
523-
}
524-
}
525-
526-
/**
527-
* @param string $entry
528-
* @return string
529-
* @throws IncomingRequestException
530-
*/
531-
private function getHostFromFederationId(string $entry): string {
532-
if (!str_contains($entry, '@')) {
533-
throw new IncomingRequestException('entry ' . $entry . ' does not contain @');
534-
}
535-
$rightPart = substr($entry, strrpos($entry, '@') + 1);
536-
537-
// in case the full scheme is sent; getting rid of it
538-
$rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart);
539-
try {
540-
return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart);
541-
} catch (IdentityNotFoundException) {
542-
throw new IncomingRequestException('invalid host within federation id: ' . $entry);
543-
}
489+
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
544490
}
545491
}

apps/cloud_federation_api/tests/RequestHandlerControllerTest.php

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
use OCA\CloudFederationAPI\Controller\RequestHandlerController;
1414
use OCA\CloudFederationAPI\Db\FederatedInvite;
1515
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
16-
use OCA\FederatedFileSharing\AddressHandler;
1716
use OCP\AppFramework\Http;
1817
use OCP\AppFramework\Http\JSONResponse;
1918
use OCP\AppFramework\Utility\ITimeFactory;
@@ -28,7 +27,6 @@
2827
use OCP\IUser;
2928
use OCP\IUserManager;
3029
use OCP\OCM\IOCMDiscoveryService;
31-
use OCP\Security\Signature\ISignatureManager;
3230
use PHPUnit\Framework\MockObject\MockObject;
3331
use Psr\Log\LoggerInterface;
3432
use Test\TestCase;
@@ -43,13 +41,11 @@ class RequestHandlerControllerTest extends TestCase {
4341
private Config&MockObject $config;
4442
private IEventDispatcher&MockObject $eventDispatcher;
4543
private FederatedInviteMapper&MockObject $federatedInviteMapper;
46-
private AddressHandler&MockObject $addressHandler;
4744
private IAppConfig&MockObject $appConfig;
4845

4946
private ICloudFederationFactory&MockObject $cloudFederationFactory;
5047
private ICloudIdManager&MockObject $cloudIdManager;
5148
private IOCMDiscoveryService&MockObject $discoveryService;
52-
private ISignatureManager&MockObject $signatureManager;
5349
private ITimeFactory&MockObject $timeFactory;
5450

5551
private RequestHandlerController $requestHandlerController;
@@ -66,12 +62,10 @@ protected function setUp(): void {
6662
$this->config = $this->createMock(Config::class);
6763
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
6864
$this->federatedInviteMapper = $this->createMock(FederatedInviteMapper::class);
69-
$this->addressHandler = $this->createMock(AddressHandler::class);
7065
$this->appConfig = $this->createMock(IAppConfig::class);
7166
$this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class);
7267
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
7368
$this->discoveryService = $this->createMock(IOCMDiscoveryService::class);
74-
$this->signatureManager = $this->createMock(ISignatureManager::class);
7569
$this->timeFactory = $this->createMock(ITimeFactory::class);
7670

7771
$this->requestHandlerController = new RequestHandlerController(
@@ -85,12 +79,10 @@ protected function setUp(): void {
8579
$this->config,
8680
$this->eventDispatcher,
8781
$this->federatedInviteMapper,
88-
$this->addressHandler,
8982
$this->appConfig,
9083
$this->cloudFederationFactory,
9184
$this->cloudIdManager,
9285
$this->discoveryService,
93-
$this->signatureManager,
9486
$this->timeFactory,
9587
);
9688
}

apps/settings/lib/SetupChecks/PhpModules.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public function getCategory(): string {
5858
protected function getRecommendedModuleDescription(string $module): string {
5959
return match($module) {
6060
'intl' => $this->l10n->t('increases language translation performance and fixes sorting of non-ASCII characters'),
61-
'sodium' => $this->l10n->t('for Argon2 for password hashing'),
61+
'sodium' => $this->l10n->t('for Argon2 for password hashing and Ed25519 signature verification for RFC 9421 http message signatures'),
6262
'gmp' => $this->l10n->t('required for SFTP storage and recommended for WebAuthn performance'),
6363
'exif' => $this->l10n->t('for picture rotation in server and metadata extraction in the Photos app'),
6464
default => '',

build/stubs/openssl.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
// ext-openssl padding mode constants for psalm. PSS omitted: PHP 8.5+ only.
9+
const OPENSSL_PKCS1_PADDING = 1;
10+
const OPENSSL_SSLV23_PADDING = 2;
11+
const OPENSSL_NO_PADDING = 3;
12+
const OPENSSL_PKCS1_OAEP_PADDING = 4;

build/stubs/sodium.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
// ext-sodium Ed25519 size constants for psalm.
9+
const SODIUM_CRYPTO_SIGN_BYTES = 64;
10+
const SODIUM_CRYPTO_SIGN_SEEDBYTES = 32;
11+
const SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES = 32;
12+
const SODIUM_CRYPTO_SIGN_SECRETKEYBYTES = 64;
13+
const SODIUM_CRYPTO_SIGN_KEYPAIRBYTES = 96;

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"ext-zip": "*",
4848
"ext-zlib": "*"
4949
},
50+
"suggest": {
51+
"ext-sodium": "Argon2 password hashing and Ed25519 signature verification for RFC 9421 HTTP message signatures."
52+
},
5053
"require-dev": {
5154
"bamarni/composer-bin-plugin": "^1.4"
5255
},

core/AppInfo/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use OC\Core\Listener\PasswordUpdatedListener;
2424
use OC\Core\Notification\CoreNotifier;
2525
use OC\OCM\OCMDiscoveryHandler;
26+
use OC\OCM\OCMJwksHandler;
2627
use OC\TagManager;
2728
use OCP\AppFramework\App;
2829
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -88,6 +89,7 @@ public function register(IRegistrationContext $context): void {
8889
$context->registerConfigLexicon(ConfigLexicon::class);
8990

9091
$context->registerWellKnownHandler(OCMDiscoveryHandler::class);
92+
$context->registerWellKnownHandler(OCMJwksHandler::class);
9193
$context->registerCapability(Capabilities::class);
9294
}
9395

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 JWKS 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->activateStagedJwksKey();
34+
} catch (\RuntimeException $e) {
35+
$output->writeln('<error>' . $e->getMessage() . '</error>');
36+
return self::FAILURE;
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 self::SUCCESS;
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 JWKS-published signing keys');
29+
parent::configure();
30+
}
31+
32+
#[\Override]
33+
protected function execute(InputInterface $input, OutputInterface $output): int {
34+
$keys = $this->signatoryManager->listJwksKeys();
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 self::SUCCESS;
39+
}
40+
41+
if ($keys === []) {
42+
$output->writeln('<comment>No JWKS keys yet; one will be generated on first OCM request.</comment>');
43+
return self::SUCCESS;
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 self::SUCCESS;
53+
}
54+
}

0 commit comments

Comments
 (0)