diff options
Diffstat (limited to 'lib/private/Security/Signature')
5 files changed, 1253 insertions, 0 deletions
diff --git a/lib/private/Security/Signature/Db/SignatoryMapper.php b/lib/private/Security/Signature/Db/SignatoryMapper.php new file mode 100644 index 00000000000..47b79320548 --- /dev/null +++ b/lib/private/Security/Signature/Db/SignatoryMapper.php @@ -0,0 +1,114 @@ +<?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\Db; + +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Model\Signatory; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<Signatory> + */ +class SignatoryMapper extends QBMapper { + public const TABLE = 'sec_signatory'; + + public function __construct( + IDBConnection $db, + ) { + parent::__construct($db, self::TABLE, Signatory::class); + } + + /** + * + */ + public function getByHost(string $host, string $account = ''): Signatory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('host', $qb->createNamedParameter($host))) + ->andWhere($qb->expr()->eq('account', $qb->createNamedParameter($account))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + throw new SignatoryNotFoundException('no signatory found'); + } + } + + /** + */ + public function getByKeyId(string $keyId): Signatory { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + throw new SignatoryNotFoundException('no signatory found'); + } + } + + /** + * @param string $keyId + * + * @return int + * @throws Exception + */ + public function deleteByKeyId(string $keyId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($keyId)))); + + return $qb->executeStatement(); + } + + /** + * @param Signatory $signatory + * + * @return int + */ + public function updateMetadata(Signatory $signatory): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('metadata', $qb->createNamedParameter(json_encode($signatory->getMetadata()))) + ->set('last_updated', $qb->createNamedParameter(time())); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId())))); + + return $qb->executeStatement(); + } + + /** + * @param Signatory $signator + */ + public function updatePublicKey(Signatory $signatory): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('signatory', $qb->createNamedParameter($signatory->getPublicKey())) + ->set('last_updated', $qb->createNamedParameter(time())); + $qb->where($qb->expr()->eq('key_id_sum', $qb->createNamedParameter($this->hashKeyId($signatory->getKeyId())))); + + return $qb->executeStatement(); + } + + /** + * returns a hash version for keyId for better index in the database + * + * @param string $keyId + * + * @return string + */ + private function hashKeyId(string $keyId): string { + return hash('sha256', $keyId); + } +} diff --git a/lib/private/Security/Signature/Model/IncomingSignedRequest.php b/lib/private/Security/Signature/Model/IncomingSignedRequest.php new file mode 100644 index 00000000000..0f7dc7cb771 --- /dev/null +++ b/lib/private/Security/Signature/Model/IncomingSignedRequest.php @@ -0,0 +1,268 @@ +<?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\Model; + +use JsonSerializable; +use NCU\Security\Signature\Enum\DigestAlgorithm; +use NCU\Security\Signature\Enum\SignatureAlgorithm; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\InvalidSignatureException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\ISignatureManager; +use NCU\Security\Signature\Model\Signatory; +use OC\Security\Signature\SignatureManager; +use OCP\IRequest; +use ValueError; + +/** + * @inheritDoc + * + * @see ISignatureManager for details on signature + * @since 31.0.0 + */ +class IncomingSignedRequest extends SignedRequest implements + IIncomingSignedRequest, + JsonSerializable { + private string $origin = ''; + + /** + * @param string $body + * @param IRequest $request + * @param array $options + * + * @throws IncomingRequestException if incoming request is wrongly signed + * @throws SignatureException if signature is faulty + * @throws SignatureNotFoundException if signature is not implemented + */ + public function __construct( + string $body, + private readonly IRequest $request, + private readonly array $options = [], + ) { + parent::__construct($body); + $this->verifyHeaders(); + $this->extractSignatureHeader(); + $this->reconstructSignatureData(); + + try { + // we set origin based on the keyId defined in the Signature header of the request + $this->setOrigin(Signatory::extractIdentityFromUri($this->getSigningElement('keyId'))); + } catch (IdentityNotFoundException $e) { + throw new IncomingRequestException($e->getMessage()); + } + } + + /** + * confirm that: + * + * - date is available in the header and its value is less than 5 minutes old + * - content-length is available and is the same as the payload size + * - digest is available and fit the checksum of the payload + * + * @throws IncomingRequestException + * @throws SignatureNotFoundException + */ + private function verifyHeaders(): void { + if ($this->request->getHeader('Signature') === '') { + throw new SignatureNotFoundException('missing Signature in header'); + } + + // confirm presence of date, content-length, digest and Signature + $date = $this->request->getHeader('date'); + if ($date === '') { + throw new IncomingRequestException('missing date in header'); + } + $contentLength = $this->request->getHeader('content-length'); + if ($contentLength === '') { + throw new IncomingRequestException('missing content-length in header'); + } + $digest = $this->request->getHeader('digest'); + if ($digest === '') { + throw new IncomingRequestException('missing digest in header'); + } + + // confirm date + try { + $dTime = new \DateTime($date); + $requestTime = $dTime->getTimestamp(); + } catch (\Exception) { + throw new IncomingRequestException('datetime exception'); + } + if ($requestTime < (time() - ($this->options['ttl'] ?? SignatureManager::DATE_TTL))) { + throw new IncomingRequestException('object is too old'); + } + + // confirm validity of content-length + if (strlen($this->getBody()) !== (int)$contentLength) { + throw new IncomingRequestException('inexact content-length in header'); + } + + // confirm digest value, based on body + [$algo, ] = explode('=', $digest); + try { + $this->setDigestAlgorithm(DigestAlgorithm::from($algo)); + } catch (ValueError) { + throw new IncomingRequestException('unknown digest algorithm'); + } + if ($digest !== $this->getDigest()) { + throw new IncomingRequestException('invalid value for digest in header'); + } + } + + /** + * extract data from the header entry 'Signature' and convert its content from string to an array + * also confirm that it contains the minimum mandatory information + * + * @throws IncomingRequestException + */ + private function extractSignatureHeader(): void { + $details = []; + foreach (explode(',', $this->request->getHeader('Signature')) as $entry) { + if ($entry === '' || !strpos($entry, '=')) { + continue; + } + + [$k, $v] = explode('=', $entry, 2); + preg_match('/^"([^"]+)"$/', $v, $var); + if ($var[0] !== '') { + $v = trim($var[0], '"'); + } + $details[$k] = $v; + } + + $this->setSigningElements($details); + + try { + // confirm keys are in the Signature header + $this->getSigningElement('keyId'); + $this->getSigningElement('headers'); + $this->setSignature($this->getSigningElement('signature')); + } catch (SignatureElementNotFoundException $e) { + throw new IncomingRequestException($e->getMessage()); + } + } + + /** + * reconstruct signature data based on signature's metadata stored in the 'Signature' header + * + * @throws SignatureException + * @throws SignatureElementNotFoundException + */ + private function reconstructSignatureData(): void { + $usedHeaders = explode(' ', $this->getSigningElement('headers')); + $neededHeaders = array_merge(['date', 'host', 'content-length', 'digest'], + array_keys($this->options['extraSignatureHeaders'] ?? [])); + + $missingHeaders = array_diff($neededHeaders, $usedHeaders); + if ($missingHeaders !== []) { + throw new SignatureException('missing entries in Signature.headers: ' . json_encode($missingHeaders)); + } + + $estimated = ['(request-target): ' . strtolower($this->request->getMethod()) . ' ' . $this->request->getRequestUri()]; + foreach ($usedHeaders as $key) { + if ($key === '(request-target)') { + continue; + } + $value = (strtolower($key) === 'host') ? $this->request->getServerHost() : $this->request->getHeader($key); + if ($value === '') { + throw new SignatureException('missing header ' . $key . ' in request'); + } + + $estimated[] = $key . ': ' . $value; + } + + $this->setSignatureData($estimated); + } + + /** + * @inheritDoc + * + * @return IRequest + * @since 31.0.0 + */ + public function getRequest(): IRequest { + return $this->request; + } + + /** + * set the hostname at the source of the request, + * based on the keyId defined in the signature header. + * + * @param string $origin + * @since 31.0.0 + */ + private function setOrigin(string $origin): void { + $this->origin = $origin; + } + + /** + * @inheritDoc + * + * @return string + * @throws IncomingRequestException + * @since 31.0.0 + */ + public function getOrigin(): string { + if ($this->origin === '') { + throw new IncomingRequestException('empty origin'); + } + return $this->origin; + } + + /** + * returns the keyId extracted from the signature headers. + * keyId is a mandatory entry in the headers of a signed request. + * + * @return string + * @throws SignatureElementNotFoundException + * @since 31.0.0 + */ + public function getKeyId(): string { + return $this->getSigningElement('keyId'); + } + + /** + * @inheritDoc + * + * @throws SignatureException + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function verify(): void { + $publicKey = $this->getSignatory()->getPublicKey(); + if ($publicKey === '') { + throw new SignatoryNotFoundException('empty public key'); + } + + $algorithm = SignatureAlgorithm::tryFrom($this->getSigningElement('algorithm')) ?? SignatureAlgorithm::RSA_SHA256; + if (openssl_verify( + implode("\n", $this->getSignatureData()), + base64_decode($this->getSignature()), + $publicKey, + $algorithm->value + ) !== 1) { + throw new InvalidSignatureException('signature issue'); + } + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'options' => $this->options, + 'origin' => $this->origin, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/OutgoingSignedRequest.php b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php new file mode 100644 index 00000000000..dbfac3bfd34 --- /dev/null +++ b/lib/private/Security/Signature/Model/OutgoingSignedRequest.php @@ -0,0 +1,229 @@ +<?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\Model; + +use JsonSerializable; +use NCU\Security\Signature\Enum\DigestAlgorithm; +use NCU\Security\Signature\Enum\SignatureAlgorithm; +use NCU\Security\Signature\Exceptions\SignatoryException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\IOutgoingSignedRequest; +use NCU\Security\Signature\ISignatoryManager; +use NCU\Security\Signature\ISignatureManager; +use OC\Security\Signature\SignatureManager; + +/** + * extends ISignedRequest to add info requested at the generation of the signature + * + * @see ISignatureManager for details on signature + * @since 31.0.0 + */ +class OutgoingSignedRequest extends SignedRequest implements + IOutgoingSignedRequest, + JsonSerializable { + private string $host = ''; + private array $headers = []; + /** @var list<string> $headerList */ + private array $headerList = []; + private SignatureAlgorithm $algorithm; + public function __construct( + string $body, + ISignatoryManager $signatoryManager, + private readonly string $identity, + private readonly string $method, + private readonly string $path, + ) { + parent::__construct($body); + + $options = $signatoryManager->getOptions(); + $this->setHost($identity) + ->setAlgorithm($options['algorithm'] ?? SignatureAlgorithm::RSA_SHA256) + ->setSignatory($signatoryManager->getLocalSignatory()) + ->setDigestAlgorithm($options['digestAlgorithm'] ?? DigestAlgorithm::SHA256); + + $headers = array_merge([ + '(request-target)' => strtolower($method) . ' ' . $path, + 'content-length' => strlen($this->getBody()), + 'date' => gmdate($options['dateHeader'] ?? SignatureManager::DATE_HEADER), + 'digest' => $this->getDigest(), + 'host' => $this->getHost() + ], $options['extraSignatureHeaders'] ?? []); + + $signing = $headerList = []; + foreach ($headers as $element => $value) { + $signing[] = $element . ': ' . $value; + $headerList[] = $element; + if ($element !== '(request-target)') { + $this->addHeader($element, $value); + } + } + + $this->setHeaderList($headerList) + ->setSignatureData($signing); + } + + /** + * @inheritDoc + * + * @param string $host + * @return $this + * @since 31.0.0 + */ + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getHost(): string { + return $this->host; + } + + /** + * @inheritDoc + * + * @param string $key + * @param string|int|float $value + * + * @return self + * @since 31.0.0 + */ + public function addHeader(string $key, string|int|float $value): self { + $this->headers[$key] = $value; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getHeaders(): array { + return $this->headers; + } + + /** + * set the ordered list of used headers in the Signature + * + * @param list<string> $list + * + * @return self + * @since 31.0.0 + */ + public function setHeaderList(array $list): self { + $this->headerList = $list; + return $this; + } + + /** + * returns ordered list of used headers in the Signature + * + * @return list<string> + * @since 31.0.0 + */ + public function getHeaderList(): array { + return $this->headerList; + } + + /** + * @inheritDoc + * + * @param SignatureAlgorithm $algorithm + * + * @return self + * @since 31.0.0 + */ + public function setAlgorithm(SignatureAlgorithm $algorithm): self { + $this->algorithm = $algorithm; + return $this; + } + + /** + * @inheritDoc + * + * @return SignatureAlgorithm + * @since 31.0.0 + */ + public function getAlgorithm(): SignatureAlgorithm { + return $this->algorithm; + } + + /** + * @inheritDoc + * + * @return self + * @throws SignatoryException + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function sign(): self { + $privateKey = $this->getSignatory()->getPrivateKey(); + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + openssl_sign( + implode("\n", $this->getSignatureData()), + $signed, + $privateKey, + $this->getAlgorithm()->value + ); + + $this->setSignature(base64_encode($signed)); + $this->setSigningElements( + [ + 'keyId="' . $this->getSignatory()->getKeyId() . '"', + 'algorithm="' . $this->getAlgorithm()->value . '"', + 'headers="' . implode(' ', $this->getHeaderList()) . '"', + 'signature="' . $this->getSignature() . '"' + ] + ); + $this->addHeader('Signature', implode(',', $this->getSigningElements())); + + return $this; + } + + /** + * @param string $clear + * @param string $privateKey + * @param SignatureAlgorithm $algorithm + * + * @return string + * @throws SignatoryException + */ + private function signString(string $clear, string $privateKey, SignatureAlgorithm $algorithm): string { + if ($privateKey === '') { + throw new SignatoryException('empty private key'); + } + + openssl_sign($clear, $signed, $privateKey, $algorithm->value); + + return base64_encode($signed); + } + + public function jsonSerialize(): array { + return array_merge( + parent::jsonSerialize(), + [ + 'host' => $this->host, + 'headers' => $this->headers, + 'algorithm' => $this->algorithm->value, + 'method' => $this->method, + 'identity' => $this->identity, + 'path' => $this->path, + ] + ); + } +} diff --git a/lib/private/Security/Signature/Model/SignedRequest.php b/lib/private/Security/Signature/Model/SignedRequest.php new file mode 100644 index 00000000000..12a43f32bcc --- /dev/null +++ b/lib/private/Security/Signature/Model/SignedRequest.php @@ -0,0 +1,216 @@ +<?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\Model; + +use JsonSerializable; +use NCU\Security\Signature\Enum\DigestAlgorithm; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\ISignedRequest; +use NCU\Security\Signature\Model\Signatory; + +/** + * @inheritDoc + * + * @since 31.0.0 + */ +class SignedRequest implements ISignedRequest, JsonSerializable { + private string $digest = ''; + private DigestAlgorithm $digestAlgorithm = DigestAlgorithm::SHA256; + private array $signingElements = []; + private array $signatureData = []; + private string $signature = ''; + private ?Signatory $signatory = null; + + public function __construct( + private readonly string $body, + ) { + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getBody(): string { + return $this->body; + } + + /** + * set algorithm used to generate digest + * + * @param DigestAlgorithm $algorithm + * + * @return self + * @since 31.0.0 + */ + protected function setDigestAlgorithm(DigestAlgorithm $algorithm): self { + $this->digestAlgorithm = $algorithm; + return $this; + } + + /** + * @inheritDoc + * + * @return DigestAlgorithm + * @since 31.0.0 + */ + public function getDigestAlgorithm(): DigestAlgorithm { + return $this->digestAlgorithm; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getDigest(): string { + if ($this->digest === '') { + $this->digest = $this->digestAlgorithm->value . '=' + . base64_encode(hash($this->digestAlgorithm->getHashingAlgorithm(), $this->body, true)); + } + return $this->digest; + } + + /** + * @inheritDoc + * + * @param array $elements + * + * @return self + * @since 31.0.0 + */ + public function setSigningElements(array $elements): self { + $this->signingElements = $elements; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getSigningElements(): array { + return $this->signingElements; + } + + /** + * @param string $key + * + * @return string + * @throws SignatureElementNotFoundException + * @since 31.0.0 + * + */ + public function getSigningElement(string $key): string { // getSignatureDetail / getSignatureEntry() ? + if (!array_key_exists($key, $this->signingElements)) { + throw new SignatureElementNotFoundException('missing element ' . $key . ' in Signature header'); + } + + return $this->signingElements[$key]; + } + + /** + * store data used to generate signature + * + * @param array $data + * + * @return self + * @since 31.0.0 + */ + protected function setSignatureData(array $data): self { + $this->signatureData = $data; + return $this; + } + + /** + * @inheritDoc + * + * @return array + * @since 31.0.0 + */ + public function getSignatureData(): array { + return $this->signatureData; + } + + /** + * set the signed version of the signature + * + * @param string $signature + * + * @return self + * @since 31.0.0 + */ + protected function setSignature(string $signature): self { + $this->signature = $signature; + return $this; + } + + /** + * @inheritDoc + * + * @return string + * @since 31.0.0 + */ + public function getSignature(): string { + return $this->signature; + } + + /** + * @inheritDoc + * + * @param Signatory $signatory + * @return self + * @since 31.0.0 + */ + public function setSignatory(Signatory $signatory): self { + $this->signatory = $signatory; + return $this; + } + + /** + * @inheritDoc + * + * @return Signatory + * @throws SignatoryNotFoundException + * @since 31.0.0 + */ + public function getSignatory(): Signatory { + if ($this->signatory === null) { + throw new SignatoryNotFoundException(); + } + + return $this->signatory; + } + + /** + * @inheritDoc + * + * @return bool + * @since 31.0.0 + */ + public function hasSignatory(): bool { + return ($this->signatory !== null); + } + + public function jsonSerialize(): array { + return [ + 'body' => $this->body, + 'digest' => $this->getDigest(), + 'digestAlgorithm' => $this->getDigestAlgorithm()->value, + 'signingElements' => $this->signingElements, + 'signatureData' => $this->signatureData, + 'signature' => $this->signature, + 'signatory' => $this->signatory ?? false, + ]; + } +} diff --git a/lib/private/Security/Signature/SignatureManager.php b/lib/private/Security/Signature/SignatureManager.php new file mode 100644 index 00000000000..91a06e29b4a --- /dev/null +++ b/lib/private/Security/Signature/SignatureManager.php @@ -0,0 +1,426 @@ +<?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 NCU\Security\Signature\Enum\SignatoryType; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\InvalidKeyOriginException; +use NCU\Security\Signature\Exceptions\InvalidSignatureException; +use NCU\Security\Signature\Exceptions\SignatoryConflictException; +use NCU\Security\Signature\Exceptions\SignatoryException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureElementNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\IOutgoingSignedRequest; +use NCU\Security\Signature\ISignatoryManager; +use NCU\Security\Signature\ISignatureManager; +use NCU\Security\Signature\Model\Signatory; +use OC\Security\Signature\Db\SignatoryMapper; +use OC\Security\Signature\Model\IncomingSignedRequest; +use OC\Security\Signature\Model\OutgoingSignedRequest; +use OCP\DB\Exception as DBException; +use OCP\IAppConfig; +use OCP\IRequest; +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 + */ + public function getIncomingSignedRequest( + ISignatoryManager $signatoryManager, + ?string $body = 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'); + } + + // 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; + } + + /** + * 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 + */ + public function getOutgoingSignedRequest( + ISignatoryManager $signatoryManager, + string $content, + string $method, + string $uri, + ): IOutgoingSignedRequest { + $signedRequest = 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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); + } +} |