Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions lib/private/OCM/Model/OCMProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class OCMProvider implements IOCMProvider {
private array $capabilities = [];
private string $endPoint = '';
private string $tokenEndPoint = '';
private string $jwksUri = '';
/** @var IOCMResource[] */
private array $resourceTypes = [];
private ?Signatory $signatory = null;
Expand Down Expand Up @@ -144,6 +145,26 @@ public function getTokenEndPoint(): string {
return '';
}

/**
* @param string $jwksUri
*
* @return $this
*/
#[\Override]
public function setJwksUri(string $jwksUri): static {
$this->jwksUri = $jwksUri;

return $this;
}

/**
* @return string
*/
#[\Override]
public function getJwksUri(): string {
return $this->jwksUri;
}

/**
* @return string
*/
Expand Down Expand Up @@ -311,6 +332,9 @@ public function import(array $data): static {
if (isset($data['tokenEndPoint'])) {
$this->setTokenEndPoint($data['tokenEndPoint']);
}
if (is_string($data['jwksUri'] ?? null)) {
$this->setJwksUri($data['jwksUri']);
}

if (!$this->looksValid()) {
throw new OCMProviderException('remote provider does not look valid');
Expand Down Expand Up @@ -357,6 +381,10 @@ public function jsonSerialize(): array {
if ($inviteAcceptDialog !== '') {
$response['inviteAcceptDialog'] = $inviteAcceptDialog;
}
$jwksUri = $this->getJwksUri();
if ($jwksUri !== '') {
$response['jwksUri'] = $jwksUri;
}
return $response;
}
}
11 changes: 9 additions & 2 deletions lib/private/OCM/OCMDiscoveryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,14 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider {
$provider->setCapabilities(['notifications', 'shares', 'exchange-token']);
$provider->setTokenEndPoint($tokenUrl);
if ($signingEnabled) {
$provider->setCapabilities(['http-sig']);
try {
// advertising `http-sig` requires publishing the location of
// the local JWK Set in `jwksUri` (and it must be https)
$provider->setJwksUri($this->signatoryManager->getLocalJwksUri());
$provider->setCapabilities(['http-sig']);
} catch (IdentityNotFoundException $e) {
$this->logger->warning('cannot build local jwksUri, http-sig capability not advertised', ['exception' => $e]);
}
}

$resource = $provider->createNewResourceType();
Expand Down Expand Up @@ -277,7 +284,7 @@ public function getIncomingSignedRequest(): ?IIncomingSignedRequest {
/**
* @inheritDoc
*
* @since 34.0.0
* @since 35.0.0
*/
#[\Override]
public function confirmRequestOrigin(?string $signedOrigin, string $ocmAddress): void {
Expand Down
6 changes: 5 additions & 1 deletion lib/private/OCM/OCMJwksHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
use Psr\Log\LoggerInterface;
use Throwable;

/** Serves `/.well-known/jwks.json` (RFC 7517) with the OCM signing keys. */
/**
* Serves the local JWK Set (RFC 7517) with the OCM signing keys at
* `/.well-known/jwks.json`, the URL advertised to peers through the
* `jwksUri` field of the OCM discovery response.
*/
class OCMJwksHandler implements IHandler {
public function __construct(
private readonly IAppConfig $appConfig,
Expand Down
94 changes: 85 additions & 9 deletions lib/private/OCM/OCMSignatoryManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\Enum\DigestAlgorithm;
use OCP\Security\Signature\Enum\SignatoryType;
use OCP\Security\Signature\Enum\SignatureAlgorithm;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\SignatureException;
use OCP\Security\Signature\ISignatureManager;
use OCP\Security\Signature\Model\Signatory;
use OCP\Server;
Expand Down Expand Up @@ -373,31 +375,54 @@ private function signatoryFromPool(int $poolId): ?Signatory {
return $signatory;
}

/**
* Absolute https URL of the local JWK Set document, advertised as
* `jwksUri` in the OCM discovery response. The spec mandates https and
* requires the field whenever the `http-sig` capability is exposed.
*
* @throws IdentityNotFoundException
*/
public function getLocalJwksUri(): string {
return $this->buildLocalUrl('/.well-known/jwks.json');
}

/**
* @param string $fragment URL fragment (e.g. 'signature' for cavage, 'ecdsa-p256-sha256' for the JWKS-published key)
* @return string
* @throws IdentityNotFoundException
*/
private function buildLocalKeyId(string $fragment): string {
return $this->buildLocalUrl('/ocm#' . $fragment);
}

/**
* Prefix $path with 'https://' and the signing identity of this instance,
* including a possible subfolder.
*
* @param string $path absolute path, starting with a slash
* @return string
* @throws IdentityNotFoundException
*/
private function buildLocalUrl(string $path): string {
if ($this->appConfig->hasKey('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, true)) {
$identity = $this->appConfig->getValueString('core', self::APPCONFIG_SIGN_IDENTITY_EXTERNAL, lazy: true);
return 'https://' . $identity . '/ocm#' . $fragment;
return 'https://' . $identity . $path;
}

try {
return $this->signatureManager->generateKeyIdFromConfig('/ocm#' . $fragment);
return $this->signatureManager->generateKeyIdFromConfig($path);
} catch (IdentityNotFoundException) {
}

$url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare');
$identity = $this->signatureManager->extractIdentityFromUri($url);

// catching possible subfolder to create a keyId like 'https://hostname/subfolder/ocm#<fragment>'
$path = parse_url($url, PHP_URL_PATH);
$pos = strpos($path, '/ocm/shares');
$sub = ($pos) ? substr($path, 0, $pos) : '';
// catching possible subfolder to create a URL like 'https://hostname/subfolder/ocm#<fragment>'
$routePath = parse_url($url, PHP_URL_PATH);
$pos = strpos($routePath, '/ocm/shares');
$sub = ($pos) ? substr($routePath, 0, $pos) : '';

return 'https://' . $identity . $sub . '/ocm#' . $fragment;
return 'https://' . $identity . $sub . $path;
}

/**
Expand Down Expand Up @@ -476,10 +501,16 @@ private function readCachedJwks(string $origin): ?array {
}

/**
* Fetch the peer's JWK Set from the URL advertised in the `jwksUri`
* field of its discovery response.
*
* @return list<array<string, mixed>>|null
*/
private function fetchJwks(string $origin): ?array {
$url = 'https://' . $origin . '/.well-known/jwks.json';
$url = $this->resolveJwksUri($origin);
if ($url === null) {
return null;
}
$options = [
'timeout' => 10,
'connect_timeout' => 10,
Expand Down Expand Up @@ -508,6 +539,34 @@ private function fetchJwks(string $origin): ?array {
return array_values(array_filter($decoded['keys'], 'is_array'));
}

/**
* Location of the peer's JWK Set, read from the `jwksUri` field of its
* discovery response. A peer that advertises `http-sig` without a https
* `jwksUri` is non-conformant: no keys can be obtained from it, so its
* signed requests will fail verification.
*/
private function resolveJwksUri(string $origin): ?string {
try {
$provider = Server::get(IOCMDiscoveryService::class)->discover($origin);
} catch (NotFoundExceptionInterface|ContainerExceptionInterface|OCMProviderException $e) {
$this->logger->warning('cannot discover remote OCM provider for JWKS', ['exception' => $e, 'origin' => $origin]);
return null;
}

$jwksUri = $provider->getJwksUri();
if ($jwksUri === '') {
if ($provider->hasCapability('http-sig')) {
$this->logger->warning('remote advertises http-sig but no jwksUri; non-conformant peer', ['origin' => $origin]);
}
return null;
}
if (!str_starts_with($jwksUri, 'https://')) {
$this->logger->warning('remote jwksUri does not use https, ignoring', ['origin' => $origin, 'jwksUri' => $jwksUri]);
return null;
}
return $jwksUri;
}

/**
* @param list<array<string, mixed>>|null $keys
*/
Expand All @@ -519,8 +578,25 @@ private function findKid(?array $keys, string $keyId): ?Key {
if (($entry['kid'] ?? null) !== $keyId) {
continue;
}
// every published JWK must carry an `alg` parameter naming an
// acceptable asymmetric signature algorithm; keys without one
// are rejected as non-conformant
$alg = $entry['alg'] ?? null;
if (!is_string($alg) || $alg === '') {
$this->logger->warning('remote JWK carries no alg parameter', ['kid' => $keyId]);
return null;
}
try {
return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry));
$native = Algorithm::normalize($alg);
$derived = Algorithm::deriveJoseAlgFromJwk($entry);
if ($derived !== null && Algorithm::normalize($derived) !== $native) {
$this->logger->warning('remote JWK alg does not match its key type', ['kid' => $keyId, 'alg' => $alg]);
return null;
}
return JWK::parseKey($entry);
} catch (SignatureException $e) {
$this->logger->warning('remote JWK alg is not acceptable', ['exception' => $e, 'kid' => $keyId, 'alg' => $alg]);
return null;
} catch (Throwable $e) {
$this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]);
return null;
Expand Down
Loading
Loading