Skip to content

Commit 088dcbb

Browse files
committed
feat(http-sig): Wire up the dual stack behaviour
We select which signature flavor to use based on if the remote server has the `http-sig` capability. The new bahaviour is backwards compatible, still using draft-cavage flavor signatures if the remote server has the `publicKey` in the discovery, but does not advertise the correct capability. Signed-off-by: Micke Nordin <kano@sunet.se>
1 parent 6a8bce5 commit 088dcbb

14 files changed

Lines changed: 477 additions & 35 deletions

core/AppInfo/Application.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use OC\Core\Listener\BeforeTemplateRenderedListener;
2323
use OC\Core\Listener\PasswordUpdatedListener;
2424
use OC\Core\Notification\CoreNotifier;
25+
use OC\OCM\Listener\HttpSigCapabilityListener;
2526
use OC\OCM\OCMDiscoveryHandler;
2627
use OC\OCM\OCMJwksHandler;
2728
use OC\TagManager;
@@ -33,6 +34,7 @@
3334
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
3435
use OCP\DB\Events\AddMissingIndicesEvent;
3536
use OCP\DB\Events\AddMissingPrimaryKeyEvent;
37+
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
3638
use OCP\User\Events\BeforeUserDeletedEvent;
3739
use OCP\User\Events\PasswordUpdatedEvent;
3840
use OCP\User\Events\UserDeletedEvent;
@@ -81,6 +83,7 @@ public function register(IRegistrationContext $context): void {
8183
$context->registerEventListener(UserDeletedEvent::class, UserDeletedFilesCleanupListener::class);
8284
$context->registerEventListener(UserDeletedEvent::class, UserDeletedWebAuthnCleanupListener::class);
8385
$context->registerEventListener(PasswordUpdatedEvent::class, PasswordUpdatedListener::class);
86+
$context->registerEventListener(LocalOCMDiscoveryEvent::class, HttpSigCapabilityListener::class);
8487

8588
// Tags
8689
$context->registerEventListener(UserDeletedEvent::class, TagManager::class);

lib/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,12 +1943,14 @@
19431943
'OC\\Notification\\Action' => $baseDir . '/lib/private/Notification/Action.php',
19441944
'OC\\Notification\\Manager' => $baseDir . '/lib/private/Notification/Manager.php',
19451945
'OC\\Notification\\Notification' => $baseDir . '/lib/private/Notification/Notification.php',
1946+
'OC\\OCM\\Listener\\HttpSigCapabilityListener' => $baseDir . '/lib/private/OCM/Listener/HttpSigCapabilityListener.php',
19461947
'OC\\OCM\\Model\\OCMProvider' => $baseDir . '/lib/private/OCM/Model/OCMProvider.php',
19471948
'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php',
19481949
'OC\\OCM\\OCMDiscoveryHandler' => $baseDir . '/lib/private/OCM/OCMDiscoveryHandler.php',
19491950
'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php',
19501951
'OC\\OCM\\OCMJwksHandler' => $baseDir . '/lib/private/OCM/OCMJwksHandler.php',
19511952
'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php',
1953+
'OC\\OCM\\Rfc9421SignatoryManager' => $baseDir . '/lib/private/OCM/Rfc9421SignatoryManager.php',
19521954
'OC\\OCS\\ApiHelper' => $baseDir . '/lib/private/OCS/ApiHelper.php',
19531955
'OC\\OCS\\CoreCapabilities' => $baseDir . '/lib/private/OCS/CoreCapabilities.php',
19541956
'OC\\OCS\\DiscoveryService' => $baseDir . '/lib/private/OCS/DiscoveryService.php',

lib/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1984,12 +1984,14 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
19841984
'OC\\Notification\\Action' => __DIR__ . '/../../..' . '/lib/private/Notification/Action.php',
19851985
'OC\\Notification\\Manager' => __DIR__ . '/../../..' . '/lib/private/Notification/Manager.php',
19861986
'OC\\Notification\\Notification' => __DIR__ . '/../../..' . '/lib/private/Notification/Notification.php',
1987+
'OC\\OCM\\Listener\\HttpSigCapabilityListener' => __DIR__ . '/../../..' . '/lib/private/OCM/Listener/HttpSigCapabilityListener.php',
19871988
'OC\\OCM\\Model\\OCMProvider' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMProvider.php',
19881989
'OC\\OCM\\Model\\OCMResource' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMResource.php',
19891990
'OC\\OCM\\OCMDiscoveryHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryHandler.php',
19901991
'OC\\OCM\\OCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryService.php',
19911992
'OC\\OCM\\OCMJwksHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMJwksHandler.php',
19921993
'OC\\OCM\\OCMSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMSignatoryManager.php',
1994+
'OC\\OCM\\Rfc9421SignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/Rfc9421SignatoryManager.php',
19931995
'OC\\OCS\\ApiHelper' => __DIR__ . '/../../..' . '/lib/private/OCS/ApiHelper.php',
19941996
'OC\\OCS\\CoreCapabilities' => __DIR__ . '/../../..' . '/lib/private/OCS/CoreCapabilities.php',
19951997
'OC\\OCS\\DiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCS/DiscoveryService.php',
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
10+
namespace OC\OCM\Listener;
11+
12+
use OC\OCM\OCMSignatoryManager;
13+
use OCP\EventDispatcher\Event;
14+
use OCP\EventDispatcher\IEventListener;
15+
use OCP\IAppConfig;
16+
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
17+
18+
/**
19+
* Adds the `http-sig` capability to the local OCM discovery document so that
20+
* remote senders know they may use RFC 9421 HTTP Message Signatures with the
21+
* keys published at `/.well-known/jwks.json`. The capability is suppressed
22+
* when signing has been disabled outright via
23+
* {@see OCMSignatoryManager::APPCONFIG_SIGN_DISABLED}.
24+
*
25+
* @template-implements IEventListener<LocalOCMDiscoveryEvent>
26+
*/
27+
class HttpSigCapabilityListener implements IEventListener {
28+
public function __construct(
29+
private readonly IAppConfig $appConfig,
30+
) {
31+
}
32+
33+
#[\Override]
34+
public function handle(Event $event): void {
35+
if (!($event instanceof LocalOCMDiscoveryEvent)) {
36+
return;
37+
}
38+
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
39+
return;
40+
}
41+
$event->addCapability('http-sig');
42+
}
43+
}

lib/private/OCM/OCMDiscoveryService.php

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,12 @@ public function requestRemoteOcmEndpoint(
344344
/**
345345
* add entries to the payload to auth the whole request
346346
*
347+
* Picks the signature scheme from the remote's advertised OCM
348+
* capabilities; see the OCM specification for the selection rules. The
349+
* existing strict/permissive policy (`APPCONFIG_SIGN_ENFORCED` /
350+
* `APPCONFIG_SIGN_DISABLED`) is preserved as the fallback when the remote
351+
* advertises neither `http-sig` nor a `publicKey`.
352+
*
347353
* @throws OCMProviderException
348354
* @return array
349355
*/
@@ -353,20 +359,31 @@ private function prepareOcmPayload(string $uri, string $method, array $options,
353359
return $payload;
354360
}
355361

356-
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)
357-
&& $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) {
362+
$origin = $this->signatureManager->extractIdentityFromUri($uri);
363+
$ocmProvider = $this->discover($origin);
364+
365+
$useRfc9421 = $ocmProvider->hasCapability('http-sig');
366+
$hasPublicKey = $this->signatoryManager->getRemoteSignatory($origin) !== null;
367+
368+
if (!$useRfc9421 && !$hasPublicKey
369+
&& $this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
358370
throw new OCMProviderException('remote endpoint does not support signed request');
359371
}
360372

361-
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
362-
$signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload(
363-
$this->signatoryManager,
364-
$payload,
365-
$method, $uri
366-
);
373+
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
374+
return $payload;
367375
}
368376

369-
return $signedPayload ?? $payload;
377+
$signatoryManager = $useRfc9421
378+
? new Rfc9421SignatoryManager($this->signatoryManager)
379+
: $this->signatoryManager;
380+
381+
return $this->signatureManager->signOutgoingRequestIClientPayload(
382+
$signatoryManager,
383+
$payload,
384+
$method,
385+
$uri,
386+
);
370387
}
371388

372389
private function generateRequestOptions(array $options): array {

lib/private/OCM/OCMSignatoryManager.php

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,26 @@
99

1010
namespace OC\OCM;
1111

12+
use JsonException;
1213
use OC\Security\IdentityProof\Manager;
1314
use OC\Security\Jwks\Jwk;
15+
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
16+
use OCP\Http\Client\IClientService;
1417
use OCP\IAppConfig;
18+
use OCP\IConfig;
1519
use OCP\IURLGenerator;
1620
use OCP\OCM\Exceptions\OCMProviderException;
1721
use OCP\Security\Signature\Enum\DigestAlgorithm;
1822
use OCP\Security\Signature\Enum\SignatoryType;
1923
use OCP\Security\Signature\Enum\SignatureAlgorithm;
2024
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
21-
use OCP\Security\Signature\ISignatoryManager;
2225
use OCP\Security\Signature\ISignatureManager;
2326
use OCP\Security\Signature\Model\Signatory;
2427
use OCP\Server;
2528
use Psr\Container\ContainerExceptionInterface;
2629
use Psr\Container\NotFoundExceptionInterface;
2730
use Psr\Log\LoggerInterface;
31+
use Throwable;
2832

2933
/**
3034
* @inheritDoc
@@ -34,7 +38,7 @@
3438
*
3539
* @since 31.0.0
3640
*/
37-
class OCMSignatoryManager implements ISignatoryManager {
41+
class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
3842
public const PROVIDER_ID = 'ocm';
3943
public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external';
4044
public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled';
@@ -49,6 +53,8 @@ public function __construct(
4953
private readonly ISignatureManager $signatureManager,
5054
private readonly IURLGenerator $urlGenerator,
5155
private readonly Manager $identityProofManager,
56+
private readonly IClientService $clientService,
57+
private readonly IConfig $config,
5258
private readonly LoggerInterface $logger,
5359
) {
5460
}
@@ -206,4 +212,49 @@ public function getRemoteSignatory(string $remote): ?Signatory {
206212
return null;
207213
}
208214
}
215+
216+
/**
217+
* Fetch the remote's `/.well-known/jwks.json` (per the OCM specification)
218+
* and return the JWK whose `kid` matches $keyId. Returns null when the
219+
* fetch fails or no key with that kid is published.
220+
*/
221+
#[\Override]
222+
public function getRemoteJwk(string $origin, string $keyId): ?Jwk {
223+
$url = 'https://' . $origin . '/.well-known/jwks.json';
224+
$options = [
225+
'timeout' => 10,
226+
'connect_timeout' => 10,
227+
];
228+
if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) {
229+
$options['verify'] = false;
230+
}
231+
232+
try {
233+
$response = $this->clientService->newClient()->get($url, $options);
234+
} catch (Throwable $e) {
235+
$this->logger->warning('failed to fetch remote JWKS', ['exception' => $e, 'url' => $url]);
236+
return null;
237+
}
238+
239+
try {
240+
$decoded = json_decode((string)$response->getBody(), true, 8, JSON_THROW_ON_ERROR);
241+
} catch (JsonException $e) {
242+
$this->logger->warning('remote JWKS is not valid JSON', ['exception' => $e, 'url' => $url]);
243+
return null;
244+
}
245+
246+
if (!is_array($decoded) || !is_array($decoded['keys'] ?? null)) {
247+
return null;
248+
}
249+
250+
foreach ($decoded['keys'] as $entry) {
251+
if (!is_array($entry)) {
252+
continue;
253+
}
254+
if (($entry['kid'] ?? null) === $keyId) {
255+
return Jwk::fromArray($entry);
256+
}
257+
}
258+
return null;
259+
}
209260
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
10+
namespace OC\OCM;
11+
12+
use OC\Security\Jwks\Jwk;
13+
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
14+
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
15+
use OCP\Security\Signature\Model\Signatory;
16+
17+
/**
18+
* Per-call wrapper around {@see OCMSignatoryManager} that swaps in the
19+
* Ed25519 signatory and turns on the `rfc9421.format` option. Constructed
20+
* by {@see OCMDiscoveryService::prepareOcmPayload} when the remote has
21+
* advertised the OCM `http-sig` capability.
22+
*
23+
* Wrapping rather than mutating OCMSignatoryManager keeps the underlying
24+
* service stateless — important because it lives in the DI container and
25+
* may be reused across requests.
26+
*/
27+
final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager {
28+
public function __construct(
29+
private readonly OCMSignatoryManager $delegate,
30+
) {
31+
}
32+
33+
#[\Override]
34+
public function getProviderId(): string {
35+
return $this->delegate->getProviderId();
36+
}
37+
38+
#[\Override]
39+
public function getOptions(): array {
40+
return array_merge($this->delegate->getOptions(), ['rfc9421.format' => true]);
41+
}
42+
43+
#[\Override]
44+
public function getLocalSignatory(): Signatory {
45+
$signatory = $this->delegate->getLocalEd25519Signatory();
46+
if ($signatory === null) {
47+
throw new IdentityNotFoundException('no Ed25519 signatory available');
48+
}
49+
return $signatory;
50+
}
51+
52+
#[\Override]
53+
public function getRemoteSignatory(string $remote): ?Signatory {
54+
return $this->delegate->getRemoteSignatory($remote);
55+
}
56+
57+
#[\Override]
58+
public function getRemoteJwk(string $origin, string $keyId): ?Jwk {
59+
return $this->delegate->getRemoteJwk($origin, $keyId);
60+
}
61+
}

lib/private/Security/Signature/Rfc9421/Algorithm.php

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,13 @@ public static function sign(string $signatureBase, string $privateKey, string $a
6161
return sodium_crypto_sign_detached($signatureBase, $privateKey);
6262
}
6363

64-
[$opensslAlgo, $padding, $encoding] = self::opensslParametersForAlgorithm($normalized);
64+
[$opensslAlgo, $encoding] = self::opensslParametersForAlgorithm($normalized);
6565

66-
// Padding is only valid for RSA keys; passing it for ECDSA triggers a
67-
// PHP warning and rejection.
68-
if ($padding === null) {
69-
$ok = openssl_sign($signatureBase, $signature, $privateKey, $opensslAlgo);
70-
} else {
71-
/** @psalm-suppress TooManyArguments - the 5-arg form is supported on PHP 8 */
72-
$ok = openssl_sign($signatureBase, $signature, $privateKey, $opensslAlgo, $padding);
73-
}
66+
// We do not pass an explicit padding mode: openssl_sign's 5th argument
67+
// only became available in PHP 8.5, and the algorithms we still
68+
// support (RSA-PKCS1-v1_5, ECDSA) all use the function's default
69+
// padding behaviour (PKCS1 v1.5 for RSA, ignored for ECDSA).
70+
$ok = openssl_sign($signatureBase, $signature, $privateKey, $opensslAlgo);
7471
if (!$ok) {
7572
throw new SignatureException('openssl_sign failed for ' . $normalized);
7673
}
@@ -111,7 +108,7 @@ public static function verify(string $signatureBase, string $signature, Jwk $jwk
111108
return sodium_crypto_sign_verify_detached($signature, $signatureBase, $rawPublicKey);
112109
}
113110

114-
[$opensslAlgo, $padding, $encoding] = self::opensslParametersForAlgorithm($resolved);
111+
[$opensslAlgo, $encoding] = self::opensslParametersForAlgorithm($resolved);
115112

116113
if ($encoding === 'ecdsa') {
117114
$signature = self::ecdsaRawToDer($signature, self::ecdsaCoordinateSize($resolved));
@@ -125,11 +122,10 @@ public static function verify(string $signatureBase, string $signature, Jwk $jwk
125122
throw new SignatureException('cannot derive public key from JWK');
126123
}
127124

128-
if ($padding === null) {
129-
return openssl_verify($signatureBase, $signature, $publicKey, $opensslAlgo) === 1;
130-
}
131-
/** @psalm-suppress TooManyArguments - the 5-arg form is supported on PHP 8 */
132-
return openssl_verify($signatureBase, $signature, $publicKey, $opensslAlgo, $padding) === 1;
125+
// See comment in sign(): padding is the openssl_verify default for
126+
// the algorithms we still support, and the 5-arg form requires
127+
// PHP 8.5.
128+
return openssl_verify($signatureBase, $signature, $publicKey, $opensslAlgo) === 1;
133129
}
134130

135131
/**
@@ -176,18 +172,18 @@ public static function normalize(string $algorithm): string {
176172
}
177173

178174
/**
179-
* @return array{0: int, 1: int|null, 2: string} [openssl algo, padding (null = omit for non-RSA), wire encoding]
175+
* @return array{0: int, 1: string} [openssl digest, wire encoding]
180176
*/
181177
private static function opensslParametersForAlgorithm(string $native): array {
182178
// Ed25519 is handled by libsodium upstream of this method and never
183179
// reaches it; only RSA-PKCS1-v1_5 and ECDSA go through OpenSSL.
184180
// RSA-PSS is not supported (see class docblock).
185181
return match ($native) {
186-
'rsa-v1_5-sha256' => [OPENSSL_ALGO_SHA256, OPENSSL_PKCS1_PADDING, 'raw'],
187-
'rsa-v1_5-sha384' => [OPENSSL_ALGO_SHA384, OPENSSL_PKCS1_PADDING, 'raw'],
188-
'rsa-v1_5-sha512' => [OPENSSL_ALGO_SHA512, OPENSSL_PKCS1_PADDING, 'raw'],
189-
'ecdsa-p256-sha256' => [OPENSSL_ALGO_SHA256, null, 'ecdsa'],
190-
'ecdsa-p384-sha384' => [OPENSSL_ALGO_SHA384, null, 'ecdsa'],
182+
'rsa-v1_5-sha256' => [OPENSSL_ALGO_SHA256, 'raw'],
183+
'rsa-v1_5-sha384' => [OPENSSL_ALGO_SHA384, 'raw'],
184+
'rsa-v1_5-sha512' => [OPENSSL_ALGO_SHA512, 'raw'],
185+
'ecdsa-p256-sha256' => [OPENSSL_ALGO_SHA256, 'ecdsa'],
186+
'ecdsa-p384-sha384' => [OPENSSL_ALGO_SHA384, 'ecdsa'],
191187
default => throw new SignatureException('unsupported signature algorithm: ' . $native),
192188
};
193189
}

tests/lib/OCM/DiscoveryServiceTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ public function testLocalBaseCapability(): void {
128128
$this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities()));
129129
}
130130

131+
public function testLocalCapabilitiesAdvertiseHttpSigByDefault(): void {
132+
// `http-sig` is the OCM-spec flag signalling RFC 9421 support backed
133+
// by /.well-known/jwks.json. Advertised whenever signing is not
134+
// disabled outright.
135+
$local = $this->discoveryService->getLocalOCMProvider();
136+
$this->assertTrue($local->hasCapability('http-sig'));
137+
}
131138

132139
public function testLocalAddedCapability(): void {
133140
$this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class);

0 commit comments

Comments
 (0)