You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
nextcloud-server/lib/private/OCM/OCMSignatoryManager.php

557 lines
18 KiB

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\OCM;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use JsonException;
use OC\Security\IdentityProof\Manager;
use OC\Security\Signature\Rfc9421\Algorithm;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\OCM\Exceptions\OCMProviderException;
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\ISignatureManager;
use OCP\Security\Signature\Model\Signatory;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* @inheritDoc
*
* returns local signatory using IKeyPairManager
* extract optional signatory (keyId+public key) from ocm discovery service on remote instance
*
* @since 31.0.0
*/
class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
public const PROVIDER_ID = 'ocm';
public const APPCONFIG_SIGN_IDENTITY_EXTERNAL = 'ocm_signed_request_identity_external';
public const APPCONFIG_SIGN_DISABLED = 'ocm_signed_request_disabled';
public const APPCONFIG_SIGN_ENFORCED = 'ocm_signed_request_enforced';
private const APPKEY_CAVAGE = 'ocm_external';
private const KEYID_FRAGMENT_CAVAGE = 'signature';
private const KEYID_FRAGMENT_JWKS = 'ecdsa-p256-sha256';
/** JWKS-published keypairs live in numbered pool appkeys; slots point to them by id. */
private const APPKEY_JWKS_POOL_PREFIX = 'ocm_jwks_pool_';
private const APPCONFIG_JWKS_POOL_COUNTER = 'ocm_jwks_pool_counter';
private const APPCONFIG_JWKS_POOL_KID_PREFIX = 'ocm_jwks_pool_kid_';
/** Stable kid identity portion, reused across rotations so kids stay on one hostname. */
private const APPCONFIG_JWKS_KID_BASE = 'ocm_jwks_kid_base';
public const SLOT_ACTIVE = 'active';
public const SLOT_PENDING = 'pending';
public const SLOT_RETIRING = 'retiring';
/** All slots in advertise order. */
public const JWKS_SLOTS = [self::SLOT_ACTIVE, self::SLOT_PENDING, self::SLOT_RETIRING];
/** Remote JWKS cache TTL (seconds). */
private const JWKS_CACHE_TTL = 3600;
private readonly ICache $jwksCache;
public function __construct(
private readonly IAppConfig $appConfig,
private readonly ISignatureManager $signatureManager,
private readonly IURLGenerator $urlGenerator,
private readonly Manager $identityProofManager,
private readonly IClientService $clientService,
private readonly IConfig $config,
ICacheFactory $cacheFactory,
private readonly LoggerInterface $logger,
) {
$this->jwksCache = $cacheFactory->createDistributed('ocm-jwks');
}
/**
* @inheritDoc
*
* @return string
* @since 31.0.0
*/
#[\Override]
public function getProviderId(): string {
return self::PROVIDER_ID;
}
/**
* @inheritDoc
*
* @return array
* @since 31.0.0
*/
#[\Override]
public function getOptions(): array {
return [
'algorithm' => SignatureAlgorithm::RSA_SHA512,
'digestAlgorithm' => DigestAlgorithm::SHA512,
'extraSignatureHeaders' => [],
'ttl' => 300,
'dateHeader' => 'D, d M Y H:i:s T',
'ttlSignatory' => 86400 * 3,
'bodyMaxSize' => 50000,
];
}
/**
* @inheritDoc
*
* @return Signatory
* @throws IdentityNotFoundException
* @since 31.0.0
*/
#[\Override]
public function getLocalSignatory(): Signatory {
/**
* TODO: manage multiple identity (external, internal, ...) to allow a limitation
* based on the requested interface (ie. only accept shares from globalscale)
*/
$keyId = $this->buildLocalKeyId(self::KEYID_FRAGMENT_CAVAGE);
if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_CAVAGE)) {
$this->identityProofManager->generateAppKey('core', self::APPKEY_CAVAGE, [
'algorithm' => 'rsa',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
}
$keyPair = $this->identityProofManager->getAppKey('core', self::APPKEY_CAVAGE);
$signatory = new Signatory(true);
$signatory->setKeyId($keyId);
$signatory->setPublicKey($keyPair->getPublic());
$signatory->setPrivateKey($keyPair->getPrivate());
return $signatory;
}
/** Active JWKS-published signing key (ECDSA P-256), lazily provisioned. */
public function getLocalJwksSignatory(): ?Signatory {
$poolId = $this->getSlotPool(self::SLOT_ACTIVE);
if ($poolId === null) {
$poolId = $this->generatePool($this->nextPoolKid());
$this->setSlotPool(self::SLOT_ACTIVE, $poolId);
}
return $this->signatoryFromPool($poolId);
}
/**
* JWKs for the active/pending/retiring slots, in advertise order. The
* active slot is provisioned if missing so first-hit returns a key.
*
* @return list<array<string, string>>
*/
public function getLocalJwks(): array {
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
$this->getLocalJwksSignatory();
}
$jwks = [];
foreach (self::JWKS_SLOTS as $slot) {
$poolId = $this->getSlotPool($slot);
if ($poolId === null) {
continue;
}
$signatory = $this->signatoryFromPool($poolId);
if ($signatory !== null) {
$jwks[] = self::buildEcdsaP256JwkArray($signatory->getPublicKey(), $signatory->getKeyId());
}
}
return $jwks;
}
/**
* Generate a pending keypair (advertised in JWKS, not yet used for
* outbound signing).
*
* @throws \RuntimeException if pending is already populated
*/
public function stageJwksKey(): Signatory {
if ($this->getSlotPool(self::SLOT_PENDING) !== null) {
throw new \RuntimeException('a pending JWKS key already exists; activate or retire it first');
}
// Need an active key first; staging a next from nothing makes no sense.
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
$this->getLocalJwksSignatory();
}
$poolId = $this->generatePool($this->nextPoolKid());
$this->setSlotPool(self::SLOT_PENDING, $poolId);
$signatory = $this->signatoryFromPool($poolId);
if ($signatory === null) {
throw new \RuntimeException('failed to materialise newly staged JWKS key');
}
return $signatory;
}
/**
* pending -> active, previous active -> retiring. The retiring slot
* stays in JWKS until {@see retireJwksKey} is run.
*
* @throws \RuntimeException if no pending key is staged, or retiring is occupied
*/
public function activateStagedJwksKey(): void {
$pending = $this->getSlotPool(self::SLOT_PENDING);
if ($pending === null) {
throw new \RuntimeException('no pending JWKS key to activate; run `ocm:keys:stage` first');
}
if ($this->getSlotPool(self::SLOT_RETIRING) !== null) {
throw new \RuntimeException('a retiring JWKS key still exists; retire it before activating a new one');
}
$active = $this->getSlotPool(self::SLOT_ACTIVE);
$this->setSlotPool(self::SLOT_ACTIVE, $pending);
$this->clearSlot(self::SLOT_PENDING);
if ($active !== null) {
$this->setSlotPool(self::SLOT_RETIRING, $active);
}
}
/**
* Delete the retiring key. In-flight signatures referencing its kid
* stop verifying after this returns.
*
* @throws \RuntimeException if retiring is empty
*/
public function retireJwksKey(): void {
$poolId = $this->getSlotPool(self::SLOT_RETIRING);
if ($poolId === null) {
throw new \RuntimeException('no retiring JWKS key to remove');
}
$this->identityProofManager->deleteAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId);
$this->appConfig->deleteKey('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId);
$this->clearSlot(self::SLOT_RETIRING);
}
/**
* Diagnostics snapshot. `slot` is null for orphaned pools.
*
* @return list<array{poolId: int, kid: string, slot: ?string}>
*/
public function listJwksKeys(): array {
$bySlot = [];
foreach (self::JWKS_SLOTS as $slot) {
$id = $this->getSlotPool($slot);
if ($id !== null) {
$bySlot[$id] = $slot;
}
}
$max = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0);
$entries = [];
for ($id = 1; $id <= $max; $id++) {
if (!$this->identityProofManager->hasAppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $id)) {
continue;
}
$entries[] = [
'poolId' => $id,
'kid' => $this->canonicalKid(
$this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $id, ''),
),
'slot' => $bySlot[$id] ?? null,
];
}
return $entries;
}
/**
* Generate keypair into a new pool. Kid is canonicalised through
* {@see Signatory::setKeyId} so admin output and wire form agree.
*/
private function generatePool(string $kid): int {
$poolId = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1;
$this->appConfig->setValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, $poolId);
$this->identityProofManager->generateEcdsaP256AppKey('core', self::APPKEY_JWKS_POOL_PREFIX . $poolId);
$this->appConfig->setValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, $this->canonicalKid($kid));
return $poolId;
}
/** Canonical wire-form via a transient {@see Signatory::setKeyId} round-trip. */
private function canonicalKid(string $kid): string {
$probe = new Signatory(true);
$probe->setKeyId($kid);
return $probe->getKeyId();
}
/**
* Build the next kid. Identity portion is derived once and persisted so
* CLI-triggered rotations stay on the same hostname.
*
* @throws \RuntimeException if no instance identity can be derived
*/
private function nextPoolKid(): string {
$base = $this->resolveKidBase();
$next = $this->appConfig->getValueInt('core', self::APPCONFIG_JWKS_POOL_COUNTER, 0) + 1;
return $base . '-' . $next;
}
/**
* Stable identity portion (before the `-N` suffix). Resolution order:
* stored APPCONFIG_JWKS_KID_BASE > active pool's kid sans suffix >
* fresh from {@see buildLocalKeyId}. Persisted so CLI rotations stay
* on one hostname.
*
* @throws \RuntimeException if no instance identity can be derived
*/
private function resolveKidBase(): string {
$base = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_KID_BASE, '');
if ($base !== '') {
return $base;
}
$activePool = $this->getSlotPool(self::SLOT_ACTIVE);
if ($activePool !== null) {
$kid = $this->canonicalKid(
$this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $activePool, ''),
);
$pos = strrpos($kid, '-');
if ($pos !== false) {
$base = substr($kid, 0, $pos);
}
}
if ($base === '') {
try {
$base = $this->canonicalKid($this->buildLocalKeyId(self::KEYID_FRAGMENT_JWKS));
} catch (IdentityNotFoundException $e) {
throw new \RuntimeException('cannot derive instance identity for JWKS kid', 0, $e);
}
}
$this->appConfig->setValueString('core', self::APPCONFIG_JWKS_KID_BASE, $base);
return $base;
}
private function getSlotPool(string $slot): ?int {
$key = 'ocm_jwks_slot_' . $slot;
if (!$this->appConfig->hasKey('core', $key)) {
return null;
}
$value = $this->appConfig->getValueInt('core', $key, 0);
return $value > 0 ? $value : null;
}
private function setSlotPool(string $slot, int $poolId): void {
$this->appConfig->setValueInt('core', 'ocm_jwks_slot_' . $slot, $poolId);
}
private function clearSlot(string $slot): void {
$this->appConfig->deleteKey('core', 'ocm_jwks_slot_' . $slot);
}
/** Returns null if the underlying appkey was manually deleted. */
private function signatoryFromPool(int $poolId): ?Signatory {
$appKey = self::APPKEY_JWKS_POOL_PREFIX . $poolId;
if (!$this->identityProofManager->hasAppKey('core', $appKey)) {
return null;
}
$kid = $this->appConfig->getValueString('core', self::APPCONFIG_JWKS_POOL_KID_PREFIX . $poolId, '');
if ($kid === '') {
return null;
}
$keyPair = $this->identityProofManager->getAppKey('core', $appKey);
$signatory = new Signatory(true);
$signatory->setKeyId($kid);
$signatory->setPublicKey($keyPair->getPublic());
$signatory->setPrivateKey($keyPair->getPrivate());
return $signatory;
}
/**
* @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 {
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;
}
try {
return $this->signatureManager->generateKeyIdFromConfig('/ocm#' . $fragment);
} 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) : '';
return 'https://' . $identity . $sub . '/ocm#' . $fragment;
}
/**
* @inheritDoc
*
* @param string $remote
*
* @return Signatory|null must be NULL if no signatory is found
* @since 31.0.0
*/
#[\Override]
public function getRemoteSignatory(string $remote): ?Signatory {
try {
$ocmProvider = Server::get(OCMDiscoveryService::class)->discover($remote, true);
/**
* @experimental 31.0.0
* @psalm-suppress UndefinedInterfaceMethod
*/
$signatory = $ocmProvider->getSignatory();
$signatory?->setSignatoryType(SignatoryType::TRUSTED);
return $signatory;
} catch (NotFoundExceptionInterface|ContainerExceptionInterface|OCMProviderException $e) {
$this->logger->warning('fail to get remote signatory', ['exception' => $e, 'remote' => $remote]);
return null;
}
}
/**
* Resolve a peer's JWK by kid. Cached per-origin for {@see JWKS_CACHE_TTL}s
* with a single refetch on cache-hit-but-kid-missing so rotations propagate.
*/
#[\Override]
public function getRemoteKey(string $origin, string $keyId): ?Key {
$keys = $this->readCachedJwks($origin);
$fromCache = $keys !== null;
if (!$fromCache) {
$keys = $this->fetchJwks($origin);
if ($keys !== null) {
$this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL);
}
}
$key = $this->findKid($keys, $keyId);
if ($key !== null) {
return $key;
}
// Only refetch when the miss came from cache; fresh is authoritative.
if (!$fromCache) {
return null;
}
$keys = $this->fetchJwks($origin);
if ($keys === null) {
return null;
}
$this->jwksCache->set($origin, json_encode($keys), self::JWKS_CACHE_TTL);
return $this->findKid($keys, $keyId);
}
/** @return list<array<string, mixed>>|null null on cold/corrupt cache */
private function readCachedJwks(string $origin): ?array {
$cached = $this->jwksCache->get($origin);
if (!is_string($cached)) {
return null;
}
try {
$decoded = json_decode($cached, true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return null;
}
if (!is_array($decoded)) {
return null;
}
/** @var list<array<string, mixed>> $decoded */
return array_values(array_filter($decoded, 'is_array'));
}
/**
* @return list<array<string, mixed>>|null
*/
private function fetchJwks(string $origin): ?array {
$url = 'https://' . $origin . '/.well-known/jwks.json';
$options = [
'timeout' => 10,
'connect_timeout' => 10,
];
if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates') === true) {
$options['verify'] = false;
}
try {
$response = $this->clientService->newClient()->get($url, $options);
} catch (Throwable $e) {
$this->logger->warning('failed to fetch remote JWKS', ['exception' => $e, 'url' => $url]);
return null;
}
try {
$decoded = json_decode((string)$response->getBody(), true, 8, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->logger->warning('remote JWKS is not valid JSON', ['exception' => $e, 'url' => $url]);
return null;
}
if (!is_array($decoded) || !is_array($decoded['keys'] ?? null)) {
return null;
}
return array_values(array_filter($decoded['keys'], 'is_array'));
}
/**
* @param list<array<string, mixed>>|null $keys
*/
private function findKid(?array $keys, string $keyId): ?Key {
if ($keys === null) {
return null;
}
foreach ($keys as $entry) {
if (($entry['kid'] ?? null) !== $keyId) {
continue;
}
try {
return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry));
} catch (Throwable $e) {
$this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]);
return null;
}
}
return null;
}
/**
* Build an EC P-256 JWK (RFC 7518 §6.2) from a PEM public key. The raw x/y
* coordinates from openssl are zero-padded to 32 bytes per RFC 7518 §6.2.1.2.
*
* @return array<string, string>
*/
private static function buildEcdsaP256JwkArray(string $publicKeyPem, string $kid): array {
$details = openssl_pkey_get_details(openssl_pkey_get_public($publicKeyPem) ?: throw new \RuntimeException('invalid EC public key'));
if ($details === false || !isset($details['ec']['x'], $details['ec']['y'])) {
throw new \RuntimeException('invalid EC public key');
}
$x = str_pad($details['ec']['x'], 32, "\x00", STR_PAD_LEFT);
$y = str_pad($details['ec']['y'], 32, "\x00", STR_PAD_LEFT);
return [
'kty' => 'EC',
'crv' => 'P-256',
'kid' => $kid,
'alg' => 'ES256',
'use' => 'sig',
'x' => JWT::urlsafeB64Encode($x),
'y' => JWT::urlsafeB64Encode($y),
];
}
}