Skip to content

Commit 79b7365

Browse files
authored
Merge pull request #62543 from nextcloud/kano-update-httpsig
http-sig: Update http message signatures for OCM after spec review
2 parents 32eaeaa + 3dcf085 commit 79b7365

30 files changed

Lines changed: 767 additions & 298 deletions

apps/cloud_federation_api/appinfo/routes.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
*/
99
return [
1010
'routes' => [
11+
[
12+
'name' => 'Token#jwks',
13+
'url' => '/api/v1/jwks',
14+
'verb' => 'GET',
15+
],
1116
[
1217
'name' => 'RequestHandler#addShare',
1318
'url' => '/shares',

apps/cloud_federation_api/lib/Controller/OCMRequestController.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,17 @@ public function manageOCMRequests(string $ocmPath): Response {
5656
throw new OCMArgumentException('path is not UTF-8');
5757
}
5858

59+
$ocmAddress = null;
60+
$params = $this->request->getParams();
61+
foreach (['owner', 'sender', 'sharedBy'] as $field) {
62+
if (is_string($params[$field] ?? null) && $params[$field] !== '') {
63+
$ocmAddress = $params[$field];
64+
break;
65+
}
66+
}
67+
5968
try {
60-
// if request is signed and well signed, no exceptions are thrown
61-
// if request is not signed and host is known for not supporting signed request, no exceptions are thrown
62-
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
69+
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest($ocmAddress);
6370
} catch (IncomingRequestException $e) {
6471
$this->logger->warning('incoming ocm request exception', ['exception' => $e]);
6572
$response = new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);

apps/cloud_federation_api/lib/Controller/RequestHandlerController.php

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
110110
try {
111111
// if request is signed and well signed, no exceptions are thrown
112112
// if request is not signed and host is known for not supporting signed request, no exception are thrown
113-
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
113+
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest($owner);
114114
$this->confirmSignedOrigin($signedRequest, 'owner', $owner);
115115
} catch (IncomingRequestException $e) {
116116
$this->logger->warning('incoming request exception', ['exception' => $e]);
@@ -307,10 +307,11 @@ public function receiveNotification($notificationType, $resourceType, $providerI
307307

308308
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
309309
try {
310-
// if request is signed and well signed, no exception are thrown
311-
// if request is not signed and host is known for not supporting signed request, no exception are thrown
312-
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
313-
$this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
310+
$identity = $this->resolveNotificationIdentity($resourceType, $notification);
311+
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest($identity !== '' ? $identity : null);
312+
if ($identity !== '') {
313+
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
314+
}
314315
} catch (IncomingRequestException $e) {
315316
$this->logger->warning('incoming request exception', ['exception' => $e]);
316317
return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
@@ -450,22 +451,16 @@ private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, str
450451
}
451452

452453
/**
453-
* confirm identity of the remote instance on notification, based on the share token.
454-
*
455-
* If request is not signed, we still verify that the hostname from the extracted value does,
456-
* actually, not support signed request
454+
* Resolve the sender identity from a notification's sharedSecret.
455+
* Returns '' when the provider does not implement signed federation.
457456
*
458-
* @param IIncomingSignedRequest|null $signedRequest
459457
* @param string $resourceType
458+
* @param array<string, mixed> $notification
460459
*
461460
* @throws IncomingRequestException
462461
* @throws BadRequestException
463462
*/
464-
private function confirmNotificationIdentity(
465-
?IIncomingSignedRequest $signedRequest,
466-
string $resourceType,
467-
array $notification,
468-
): void {
463+
private function resolveNotificationIdentity(string $resourceType, array $notification): string {
469464
$sharedSecret = $notification['sharedSecret'] ?? '';
470465
if ($sharedSecret === '') {
471466
throw new BadRequestException(['sharedSecret']);
@@ -481,14 +476,12 @@ private function confirmNotificationIdentity(
481476
$mapping = Server::get(OcmTokenMapMapper::class)->getByAccessTokenId($accessTokenDb->getId());
482477
$identity = $provider->getFederationIdFromSharedSecret($mapping->getRefreshToken(), $notification);
483478
}
484-
} else {
485-
$this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
486-
return;
479+
return $identity;
487480
}
481+
$this->logger->debug('cloud federation provider {provider} does not implement ISignedCloudFederationProvider', ['provider' => $provider::class]);
488482
} catch (\Exception $e) {
489483
throw new IncomingRequestException($e->getMessage(), previous: $e);
490484
}
491-
492-
$this->ocmDiscoveryService->confirmRequestOrigin($signedRequest?->getOrigin(), $identity);
485+
return '';
493486
}
494487
}

apps/cloud_federation_api/lib/Controller/TokenController.php

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,24 @@
1818
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1919
use OCP\AppFramework\Http\Attribute\PublicPage;
2020
use OCP\AppFramework\Http\DataResponse;
21+
use OCP\AppFramework\Http\JSONResponse;
2122
use OCP\AppFramework\Utility\ITimeFactory;
2223
use OCP\Authentication\Exceptions\ExpiredTokenException;
2324
use OCP\Authentication\Exceptions\InvalidTokenException;
2425
use OCP\Authentication\Token\IToken;
26+
use OCP\Federation\ICloudIdManager;
2527
use OCP\IAppConfig;
2628
use OCP\IRequest;
2729
use OCP\Security\ISecureRandom;
30+
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
2831
use OCP\Security\Signature\Exceptions\IncomingRequestException;
2932
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
3033
use OCP\Security\Signature\Exceptions\SignatureException;
3134
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
3235
use OCP\Security\Signature\IIncomingSignedRequest;
3336
use OCP\Security\Signature\ISignatureManager;
3437
use OCP\Security\Signature\Model\Signatory;
38+
use OCP\Share\Exceptions\ShareNotFound;
3539
use OCP\Share\IManager as IShareManager;
3640
use Psr\Log\LoggerInterface;
3741

@@ -51,19 +55,45 @@ public function __construct(
5155
private readonly IAppConfig $appConfig,
5256
private readonly OcmTokenMapMapper $ocmTokenMapMapper,
5357
private readonly IShareManager $shareManager,
58+
private readonly ICloudIdManager $cloudIdManager,
5459
) {
5560
parent::__construct('cloud_federation_api', $request);
5661
}
5762

63+
/**
64+
* Resolve the signer origin from the refresh token's share, or null.
65+
*
66+
* @param string $code refresh token
67+
* @return string|null signer origin, or null if it cannot be determined
68+
*/
69+
private function resolveOriginFromRefreshToken(string $code): ?string {
70+
if ($code === '') {
71+
return null;
72+
}
73+
try {
74+
$share = $this->shareManager->getShareByToken($code);
75+
$sharedWith = $share->getSharedWith();
76+
if ($sharedWith === null || $sharedWith === '') {
77+
return null;
78+
}
79+
$remote = $this->cloudIdManager->resolveCloudId($sharedWith)->getRemote();
80+
return $this->signatureManager->extractIdentityFromUri($remote);
81+
} catch (ShareNotFound|IdentityNotFoundException|\InvalidArgumentException) {
82+
return null;
83+
}
84+
}
85+
5886
/**
5987
* Verify the signature of incoming request if available
6088
*
89+
* @param string|null $origin sender origin, or null if unknown
90+
*
6191
* @return IIncomingSignedRequest|null null if remote does not support signed requests
6292
* @throws IncomingRequestException if signature is required but invalid
6393
*/
64-
private function verifySignedRequest(): ?IIncomingSignedRequest {
94+
private function verifySignedRequest(?string $origin): ?IIncomingSignedRequest {
6595
try {
66-
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
96+
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager, null, $origin);
6797
$this->logger->debug('Token request signature verified', [
6898
'origin' => $signedRequest->getOrigin()
6999
]);
@@ -109,6 +139,25 @@ private function resolveJwtSigningKey(string $privateKeyPem): array {
109139
throw new \RuntimeException('Unsupported signatory key type for JWT access token');
110140
}
111141

142+
/**
143+
* Serve the local JWK Set
144+
*
145+
* @return JSONResponse<Http::STATUS_OK, array{keys: list<array<string, string>>}, array{}>
146+
*
147+
* 200: JWK Set returned
148+
*/
149+
#[PublicPage]
150+
#[NoCSRFRequired]
151+
public function jwks(): JSONResponse {
152+
$keys = [];
153+
try {
154+
$keys = $this->signatoryManager->getLocalJwks();
155+
} catch (\Throwable $e) {
156+
$this->logger->warning('failed to build local JWKs', ['exception' => $e]);
157+
}
158+
return new JSONResponse(['keys' => $keys]);
159+
}
160+
112161
/**
113162
* Exchange a refresh token for a short-lived access token
114163
*
@@ -126,7 +175,7 @@ private function resolveJwtSigningKey(string $privateKeyPem): array {
126175
#[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')]
127176
public function accessToken(string $grant_type = '', string $code = ''): DataResponse {
128177
try {
129-
$signedRequest = $this->verifySignedRequest();
178+
$signedRequest = $this->verifySignedRequest($this->resolveOriginFromRefreshToken($code));
130179
} catch (IncomingRequestException $e) {
131180
$this->logger->warning('Token request signature verification failed', [
132181
'exception' => $e

apps/cloud_federation_api/openapi.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,13 @@
323323
}
324324
},
325325
"tags": [
326-
{
327-
"name": "request_handler",
328-
"description": "Open-Cloud-Mesh-API"
329-
},
330326
{
331327
"name": "token",
332328
"description": "Controller for the /token endpoint Exchanges long-lived refresh tokens for short-lived access tokens"
329+
},
330+
{
331+
"name": "request_handler",
332+
"description": "Open-Cloud-Mesh-API"
333333
}
334334
]
335335
}

apps/cloud_federation_api/tests/Controller/TokenControllerTest.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
use OCP\Authentication\Exceptions\ExpiredTokenException;
2222
use OCP\Authentication\Exceptions\InvalidTokenException;
2323
use OCP\Authentication\Token\IToken;
24+
use OCP\Federation\ICloudId;
25+
use OCP\Federation\ICloudIdManager;
2426
use OCP\IAppConfig;
2527
use OCP\IRequest;
2628
use OCP\Security\ISecureRandom;
@@ -47,6 +49,7 @@ class TokenControllerTest extends TestCase {
4749
private IAppConfig&MockObject $appConfig;
4850
private OcmTokenMapMapper&MockObject $ocmTokenMapMapper;
4951
private IShareManager&MockObject $shareManager;
52+
private ICloudIdManager&MockObject $cloudIdManager;
5053

5154
private TokenController $controller;
5255

@@ -63,10 +66,13 @@ protected function setUp(): void {
6366
$this->timeFactory = $this->createMock(ITimeFactory::class);
6467
$this->logger = $this->createMock(LoggerInterface::class);
6568
$this->signatureManager = $this->createMock(ISignatureManager::class);
69+
$this->signatureManager->method('extractIdentityFromUri')
70+
->willReturnCallback(static fn (string $uri): string => (string)parse_url($uri, PHP_URL_HOST));
6671
$this->signatoryManager = $this->createMock(OCMSignatoryManager::class);
6772
$this->appConfig = $this->createMock(IAppConfig::class);
6873
$this->ocmTokenMapMapper = $this->createMock(OcmTokenMapMapper::class);
6974
$this->shareManager = $this->createMock(IShareManager::class);
75+
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
7076

7177
$this->controller = new TokenController(
7278
$this->request,
@@ -79,6 +85,7 @@ protected function setUp(): void {
7985
$this->appConfig,
8086
$this->ocmTokenMapMapper,
8187
$this->shareManager,
88+
$this->cloudIdManager,
8289
);
8390
}
8491

@@ -129,6 +136,11 @@ private function configureHappyPath(
129136
$this->shareManager->method('getShareByToken')
130137
->with($refreshToken)
131138
->willReturn($share);
139+
$cloudId = $this->createMock(ICloudId::class);
140+
$cloudId->method('getRemote')->willReturn('https://remote.example.com');
141+
$this->cloudIdManager->method('resolveCloudId')
142+
->with($sharedWith)
143+
->willReturn($cloudId);
132144

133145
$signatory = new Signatory();
134146
$signatory->setKeyId('https://local.example.com/index.php/ocm#signature');
@@ -149,10 +161,10 @@ public function testAccessTokenSuccess(): void {
149161
$signedRequest = $this->createMock(IIncomingSignedRequest::class);
150162
$signedRequest->method('getOrigin')->willReturn('remote.example.com');
151163
$this->signatureManager->method('getIncomingSignedRequest')
152-
->with($this->signatoryManager)
164+
->with($this->signatoryManager, null, 'remote.example.com')
153165
->willReturn($signedRequest);
154166

155-
$this->configureHappyPath('valid-refresh-token', 123, 'testuser', 'owner', 'sharee@remote.example.com', 'fixedjtivalue00');
167+
$this->configureHappyPath('valid-refresh-token', 123, 'testuser', 'owner', 'sharee@department@remote.example.com', 'fixedjtivalue00');
156168

157169
$this->ocmTokenMapMapper->expects($this->once())
158170
->method('insert')
@@ -177,7 +189,7 @@ public function testAccessTokenSuccess(): void {
177189
$decoded = JWT::decode($data['access_token'], new Key($this->publicKeyPem, 'RS256'));
178190
$this->assertSame('https://local.example.com', $decoded->iss);
179191
$this->assertSame('owner', $decoded->sub);
180-
$this->assertSame('sharee@remote.example.com', $decoded->aud);
192+
$this->assertSame('sharee@department@remote.example.com', $decoded->aud);
181193
$this->assertSame('789', $decoded->client_id);
182194
$this->assertSame('fixedjtivalue00', $decoded->jti);
183195
$this->assertSame(1000000, $decoded->iat);

core/AppInfo/Application.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
use OC\DirectEditing\Listeners\UserDeletedTokenCleanupListener as UserDeletedDirectEditingTokenCleanupListener;
4242
use OC\DirectEditing\Listeners\UserDisabledTokenCleanupListener as UserDisabledDirectEditingTokenCleanupListener;
4343
use OC\OCM\OCMDiscoveryHandler;
44-
use OC\OCM\OCMJwksHandler;
4544
use OC\TagManager;
4645
use OCP\AppFramework\App;
4746
use OCP\AppFramework\Bootstrap\IBootContext;
@@ -113,7 +112,6 @@ public function register(IRegistrationContext $context): void {
113112
$context->registerConfigLexicon(ConfigLexicon::class);
114113

115114
$context->registerWellKnownHandler(OCMDiscoveryHandler::class);
116-
$context->registerWellKnownHandler(OCMJwksHandler::class);
117115
$context->registerCapability(Capabilities::class);
118116

119117
$context->registerEventListener(RestrictInteractionEvent::class, RestrictInteractionListener::class);

lib/composer/composer/autoload_classmap.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2061,7 +2061,6 @@
20612061
'OC\\OCM\\Model\\OCMResource' => $baseDir . '/lib/private/OCM/Model/OCMResource.php',
20622062
'OC\\OCM\\OCMDiscoveryHandler' => $baseDir . '/lib/private/OCM/OCMDiscoveryHandler.php',
20632063
'OC\\OCM\\OCMDiscoveryService' => $baseDir . '/lib/private/OCM/OCMDiscoveryService.php',
2064-
'OC\\OCM\\OCMJwksHandler' => $baseDir . '/lib/private/OCM/OCMJwksHandler.php',
20652064
'OC\\OCM\\OCMSignatoryManager' => $baseDir . '/lib/private/OCM/OCMSignatoryManager.php',
20662065
'OC\\OCM\\Rfc9421SignatoryManager' => $baseDir . '/lib/private/OCM/Rfc9421SignatoryManager.php',
20672066
'OC\\OCS\\ApiHelper' => $baseDir . '/lib/private/OCS/ApiHelper.php',

lib/composer/composer/autoload_static.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2102,7 +2102,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
21022102
'OC\\OCM\\Model\\OCMResource' => __DIR__ . '/../../..' . '/lib/private/OCM/Model/OCMResource.php',
21032103
'OC\\OCM\\OCMDiscoveryHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryHandler.php',
21042104
'OC\\OCM\\OCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMDiscoveryService.php',
2105-
'OC\\OCM\\OCMJwksHandler' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMJwksHandler.php',
21062105
'OC\\OCM\\OCMSignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/OCMSignatoryManager.php',
21072106
'OC\\OCM\\Rfc9421SignatoryManager' => __DIR__ . '/../../..' . '/lib/private/OCM/Rfc9421SignatoryManager.php',
21082107
'OC\\OCS\\ApiHelper' => __DIR__ . '/../../..' . '/lib/private/OCS/ApiHelper.php',

lib/private/AppFramework/Http/Attributes/FederationRateLimit.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ public function shouldApply(IRequest $request): bool {
4141
}
4242

4343
try {
44-
$signedRequest = $this->discoveryService->getIncomingSignedRequest();
44+
$owner = $request->getParam('owner');
45+
$signedRequest = $this->discoveryService->getIncomingSignedRequest(is_string($owner) ? $owner : null);
4546
if (!$signedRequest) {
4647
return true;
4748
}
48-
$signedRequest->verify();
4949
return !$this->trustedServers->isTrustedServer($signedRequest->getOrigin());
5050
} catch (\Exception) {
51-
// no or invalid signature
51+
// no or invalid signature, or unresolvable origin
5252
return true;
5353
}
5454
}

0 commit comments

Comments
 (0)