aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Security/Signature
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Security/Signature')
-rw-r--r--lib/private/Security/Signature/Db/SignatoryMapper.php114
-rw-r--r--lib/private/Security/Signature/Model/IncomingSignedRequest.php268
-rw-r--r--lib/private/Security/Signature/Model/OutgoingSignedRequest.php229
-rw-r--r--lib/private/Security/Signature/Model/SignedRequest.php216
-rw-r--r--lib/private/Security/Signature/SignatureManager.php426
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);
+ }
+}