Files
Micke Nordin dae8a36460 fix(ocm): treat RFC 9421 keyid as opaque, verify by sender origin
RFC 7517 §4.5 leaves the keyid structure unspecified, so the receiver
must not parse it. The signer origin now comes from the trusted OCM
share/sender identity; the JWK Set is resolved against that origin and
the keyid is matched opaquely to the JWKS kid. Aligns with the OCM
verification procedure and fixes the origin-mismatch / flaky-kid
failures in the two-port integration rig.

Reverts the per-request host-based kid back to a stable persisted kid,
drops keyid->host parsing, threads a sender-origin parameter through
verification, and resolves that origin in every OCM inbound entry point
(notifications, shares, token exchange, OCM requests) and the federation
rate limiter. Cavage is unchanged.

Assisted-by: ClaudeCode:glm-5.2

Signed-off-by: Micke Nordin <kano@sunet.se>
2026-08-09 16:21:06 +02:00

497 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\Security\Signature;
use OC\Security\Signature\Db\SignatoryMapper;
use OC\Security\Signature\Model\IncomingSignedRequest;
use OC\Security\Signature\Model\OutgoingSignedRequest;
use OC\Security\Signature\Model\Rfc9421IncomingSignedRequest;
use OC\Security\Signature\Model\Rfc9421OutgoingSignedRequest;
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
use OCP\DB\Exception as DBException;
use OCP\IAppConfig;
use OCP\IRequest;
use OCP\Security\Signature\Enum\SignatoryType;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\InvalidKeyOriginException;
use OCP\Security\Signature\Exceptions\InvalidSignatureException;
use OCP\Security\Signature\Exceptions\SignatoryConflictException;
use OCP\Security\Signature\Exceptions\SignatoryException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\Exceptions\SignatureElementNotFoundException;
use OCP\Security\Signature\Exceptions\SignatureException;
use OCP\Security\Signature\Exceptions\SignatureNotFoundException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Security\Signature\IOutgoingSignedRequest;
use OCP\Security\Signature\ISignatoryManager;
use OCP\Security\Signature\ISignatureManager;
use OCP\Security\Signature\Model\Signatory;
use Psr\Log\LoggerInterface;
/**
* ISignatureManager is a service integrated to core that provide tools
* to set/get authenticity of/from outgoing/incoming request.
*
* Quick description of the signature, added to the headers
* {
* "(request-target)": "post /path",
* "content-length": 385,
* "date": "Mon, 08 Jul 2024 14:16:20 GMT",
* "digest": "SHA-256=U7gNVUQiixe5BRbp4Tg0xCZMTcSWXXUZI2\\/xtHM40S0=",
* "host": "hostname.of.the.recipient",
* "Signature": "keyId=\"https://author.hostname/key\",algorithm=\"sha256\",headers=\"content-length
* date digest host\",signature=\"DzN12OCS1rsA[...]o0VmxjQooRo6HHabg==\""
* }
*
* 'content-length' is the total length of the data/content
* 'date' is the datetime the request have been initiated
* 'digest' is a checksum of the data/content
* 'host' is the hostname of the recipient of the request (remote when signing outgoing request, local on
* incoming request)
* 'Signature' contains the signature generated using the private key, and metadata:
* - 'keyId' is a unique id, formatted as an url. hostname is used to retrieve the public key via custom
* discovery
* - 'algorithm' define the algorithm used to generate signature
* - 'headers' contains a list of element used during the generation of the signature
* - 'signature' is the encrypted string, using local private key, of an array containing elements
* listed in 'headers' and their value. Some elements (content-length date digest host) are mandatory
* to ensure authenticity override protection.
*
* @since 31.0.0
*/
class SignatureManager implements ISignatureManager {
public const DATE_HEADER = 'D, d M Y H:i:s T';
public const DATE_TTL = 300;
public const SIGNATORY_TTL = 86400 * 3;
public const BODY_MAXSIZE = 50000; // max size of the payload of the request
public const APPCONFIG_IDENTITY = 'security.signature.identity';
public function __construct(
private readonly IRequest $request,
private readonly SignatoryMapper $mapper,
private readonly IAppConfig $appConfig,
private readonly LoggerInterface $logger,
) {
}
/**
* @inheritDoc
*
* @param ISignatoryManager $signatoryManager used to get details about remote instance
* @param string|null $body if NULL, body will be extracted from php://input
*
* @return IIncomingSignedRequest
* @throws IncomingRequestException if anything looks wrong with the incoming request
* @throws SignatureNotFoundException if incoming request is not signed
* @throws SignatureException if signature could not be confirmed
* @since 31.0.0
*/
#[\Override]
public function getIncomingSignedRequest(
ISignatoryManager $signatoryManager,
?string $body = null,
?string $origin = null,
): IIncomingSignedRequest {
$body = $body ?? file_get_contents('php://input');
$options = $signatoryManager->getOptions();
if (strlen($body) > ($options['bodyMaxSize'] ?? self::BODY_MAXSIZE)) {
throw new IncomingRequestException('content of request is too big');
}
// `Signature-Input` is unique to RFC 9421; cavage uses `Signature` only.
if ($this->request->getHeader('Signature-Input') !== '') {
return $this->getRfc9421IncomingSignedRequest($signatoryManager, $body, $options, $origin);
}
// generate IncomingSignedRequest based on body and request
$signedRequest = new IncomingSignedRequest($body, $this->request, $options);
try {
// confirm the validity of content and identity of the incoming request
$this->confirmIncomingRequestSignature($signedRequest, $signatoryManager, $options['ttlSignatory'] ?? self::SIGNATORY_TTL);
} catch (SignatureException $e) {
$this->logger->warning(
'signature could not be verified', [
'exception' => $e,
'signedRequest' => $signedRequest,
'signatoryManager' => get_class($signatoryManager)
]
);
throw $e;
}
return $signedRequest;
}
/**
* RFC 9421 inbound path. Requires {@see IJwkResolvingSignatoryManager}.
*
* @param ISignatoryManager $signatoryManager
* @param string $body request body
* @param array $options signatory manager options
* @param string|null $origin signer origin from the caller (the keyid is opaque)
*
* @throws IncomingRequestException
* @throws SignatureException
* @throws SignatureNotFoundException
*/
private function getRfc9421IncomingSignedRequest(
ISignatoryManager $signatoryManager,
string $body,
array $options,
?string $origin,
): IIncomingSignedRequest {
if (!($signatoryManager instanceof IJwkResolvingSignatoryManager)) {
throw new IncomingRequestException('RFC 9421 inbound is not supported by ' . get_class($signatoryManager));
}
if ($origin === null || $origin === '') {
// The keyid is opaque; the caller must supply the signer origin.
throw new IncomingRequestException('RFC 9421 verification requires the sender origin');
}
$signedRequest = new Rfc9421IncomingSignedRequest($body, $this->request, $options);
$signedRequest->setOrigin($origin);
try {
$key = $signatoryManager->getRemoteKey($signedRequest->getOrigin(), $signedRequest->getKeyId());
if ($key === null) {
// a present signature MUST be verified; an unresolvable key
// is a verification failure, not an unsigned request
throw new IncomingRequestException('no JWK resolved for keyid ' . $signedRequest->getKeyId());
}
$signedRequest->setKey($key);
$signedRequest->verify();
} catch (SignatureException $e) {
$this->logger->warning(
'RFC 9421 signature could not be verified', [
'exception' => $e,
'signedRequest' => $signedRequest,
'signatoryManager' => get_class($signatoryManager),
]
);
throw $e;
}
return $signedRequest;
}
/**
* confirm that the Signature is signed using the correct private key, using
* clear version of the Signature and the public key linked to the keyId
*
* @param IIncomingSignedRequest $signedRequest
* @param ISignatoryManager $signatoryManager
*
* @throws SignatoryNotFoundException
* @throws SignatureException
*/
private function confirmIncomingRequestSignature(
IIncomingSignedRequest $signedRequest,
ISignatoryManager $signatoryManager,
int $ttlSignatory,
): void {
$knownSignatory = null;
try {
$knownSignatory = $this->getStoredSignatory($signedRequest->getKeyId());
// refreshing ttl and compare with previous public key
if ($ttlSignatory > 0 && $knownSignatory->getLastUpdated() < (time() - $ttlSignatory)) {
$signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest);
$this->updateSignatoryMetadata($signatory);
$knownSignatory->setMetadata($signatory->getMetadata() ?? []);
}
$signedRequest->setSignatory($knownSignatory);
$signedRequest->verify();
} catch (InvalidKeyOriginException $e) {
throw $e; // issue while requesting remote instance also means there is no 2nd try
} catch (SignatoryNotFoundException) {
// if no signatory in cache, we retrieve the one from the remote instance (using
// $signatoryManager), check its validity with current signature and store it
$signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest);
$signedRequest->setSignatory($signatory);
$signedRequest->verify();
$this->storeSignatory($signatory);
} catch (SignatureException) {
// if public key (from cache) is not valid, we try to refresh it (based on SignatoryType)
try {
$signatory = $this->getSaneRemoteSignatory($signatoryManager, $signedRequest);
} catch (SignatoryNotFoundException $e) {
$this->manageDeprecatedSignatory($knownSignatory);
throw $e;
}
$signedRequest->setSignatory($signatory);
try {
$signedRequest->verify();
} catch (InvalidSignatureException $e) {
$this->logger->debug('signature issue', ['signed' => $signedRequest, 'exception' => $e]);
throw $e;
}
$this->storeSignatory($signatory);
}
}
/**
* @inheritDoc
*
* @param ISignatoryManager $signatoryManager
* @param string $content body to be signed
* @param string $method needed in the signature
* @param string $uri needed in the signature
*
* @return IOutgoingSignedRequest
* @throws IdentityNotFoundException
* @throws SignatoryException
* @throws SignatoryNotFoundException
* @since 31.0.0
*/
#[\Override]
public function getOutgoingSignedRequest(
ISignatoryManager $signatoryManager,
string $content,
string $method,
string $uri,
): IOutgoingSignedRequest {
$options = $signatoryManager->getOptions();
$signedRequest = ($options['rfc9421.format'] ?? false)
? new Rfc9421OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
$uri,
)
: new OutgoingSignedRequest(
$content,
$signatoryManager,
$this->extractIdentityFromUri($uri),
$method,
parse_url($uri, PHP_URL_PATH) ?? '/',
);
$signedRequest->sign();
return $signedRequest;
}
/**
* @inheritDoc
*
* @param ISignatoryManager $signatoryManager
* @param array $payload original payload, will be used to sign and completed with new headers with
* signature elements
* @param string $method needed in the signature
* @param string $uri needed in the signature
*
* @return array new payload to be sent, including original payload and signature elements in headers
* @since 31.0.0
*/
#[\Override]
public function signOutgoingRequestIClientPayload(
ISignatoryManager $signatoryManager,
array $payload,
string $method,
string $uri,
): array {
$signedRequest = $this->getOutgoingSignedRequest($signatoryManager, $payload['body'], $method, $uri);
$payload['headers'] = array_merge($payload['headers'], $signedRequest->getHeaders());
return $payload;
}
/**
* @inheritDoc
*
* @param string $host remote host
* @param string $account linked account, should be used when multiple signature can exist for the same
* host
*
* @return Signatory
* @throws SignatoryNotFoundException if entry does not exist in local database
* @since 31.0.0
*/
#[\Override]
public function getSignatory(string $host, string $account = ''): Signatory {
return $this->mapper->getByHost($host, $account);
}
/**
* @inheritDoc
*
* keyId is set using app config 'core/security.signature.identity'
*
* @param string $path
*
* @return string
* @throws IdentityNotFoundException is identity is not set in app config
* @since 31.0.0
*/
#[\Override]
public function generateKeyIdFromConfig(string $path): string {
if (!$this->appConfig->hasKey('core', self::APPCONFIG_IDENTITY, true)) {
throw new IdentityNotFoundException(self::APPCONFIG_IDENTITY . ' not set');
}
$identity = trim($this->appConfig->getValueString('core', self::APPCONFIG_IDENTITY, lazy: true), '/');
return 'https://' . $identity . '/' . ltrim($path, '/');
}
/**
* @inheritDoc
*
* @param string $uri
*
* @return string
* @throws IdentityNotFoundException if identity cannot be extracted
* @since 31.0.0
*/
#[\Override]
public function extractIdentityFromUri(string $uri): string {
return Signatory::extractIdentityFromUri($uri);
}
/**
* get remote signatory using the ISignatoryManager
* and confirm the validity of the keyId
*
* @param ISignatoryManager $signatoryManager
* @param IIncomingSignedRequest $signedRequest
*
* @return Signatory
* @throws InvalidKeyOriginException
* @throws SignatoryNotFoundException
* @see ISignatoryManager::getRemoteSignatory
*/
private function getSaneRemoteSignatory(
ISignatoryManager $signatoryManager,
IIncomingSignedRequest $signedRequest,
): Signatory {
$signatory = $signatoryManager->getRemoteSignatory($signedRequest->getOrigin());
if ($signatory === null) {
throw new SignatoryNotFoundException('empty result from getRemoteSignatory');
}
try {
if ($signatory->getKeyId() !== $signedRequest->getKeyId()) {
throw new InvalidKeyOriginException('keyId from signatory not related to the one from request');
}
} catch (SignatureElementNotFoundException) {
throw new InvalidKeyOriginException('missing keyId');
}
$signatory->setProviderId($signatoryManager->getProviderId());
return $signatory;
}
/**
* @param string $keyId
*
* @return Signatory
* @throws SignatoryNotFoundException
*/
private function getStoredSignatory(string $keyId): Signatory {
return $this->mapper->getByKeyId($keyId);
}
/**
* @param Signatory $signatory
*/
private function storeSignatory(Signatory $signatory): void {
try {
$this->insertSignatory($signatory);
} catch (DBException $e) {
if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
$this->logger->warning('exception while storing signature', ['exception' => $e]);
throw $e;
}
try {
$this->updateKnownSignatory($signatory);
} catch (SignatoryNotFoundException $e) {
$this->logger->warning('strange behavior, signatory not found ?', ['exception' => $e]);
}
}
}
/**
* @param Signatory $signatory
*/
private function insertSignatory(Signatory $signatory): void {
$time = time();
$signatory->setCreation($time);
$signatory->setLastUpdated($time);
$signatory->setMetadata($signatory->getMetadata() ?? []); // trigger insert on field metadata using current or default value
$this->mapper->insert($signatory);
}
/**
* @param Signatory $signatory
*
* @throws SignatoryNotFoundException
* @throws SignatoryConflictException
*/
private function updateKnownSignatory(Signatory $signatory): void {
$knownSignatory = $this->getStoredSignatory($signatory->getKeyId());
switch ($signatory->getType()) {
case SignatoryType::FORGIVABLE:
$this->deleteSignatory($knownSignatory->getKeyId());
$this->insertSignatory($signatory);
return;
case SignatoryType::REFRESHABLE:
$this->updateSignatoryPublicKey($signatory);
$this->updateSignatoryMetadata($signatory);
break;
case SignatoryType::TRUSTED:
// TODO: send notice to admin
throw new SignatoryConflictException();
case SignatoryType::STATIC:
// TODO: send warning to admin
throw new SignatoryConflictException();
}
}
/**
* This is called when a remote signatory does not exist anymore
*
* @param Signatory|null $knownSignatory NULL is not known
*
* @throws SignatoryConflictException
* @throws SignatoryNotFoundException
*/
private function manageDeprecatedSignatory(?Signatory $knownSignatory): void {
switch ($knownSignatory?->getType()) {
case null: // unknown in local database
case SignatoryType::FORGIVABLE: // who cares ?
throw new SignatoryNotFoundException(); // meaning we just return the correct exception
case SignatoryType::REFRESHABLE:
// TODO: send notice to admin
throw new SignatoryConflictException(); // while it can be refreshed, it must exist
case SignatoryType::TRUSTED:
case SignatoryType::STATIC:
// TODO: send warning to admin
throw new SignatoryConflictException(); // no way.
}
}
private function updateSignatoryPublicKey(Signatory $signatory): void {
$this->mapper->updatePublicKey($signatory);
}
private function updateSignatoryMetadata(Signatory $signatory): void {
$this->mapper->updateMetadata($signatory);
}
private function deleteSignatory(string $keyId): void {
$this->mapper->deleteByKeyId($keyId);
}
}