* @copyright 2018, Maxence Lange * @license GNU AGPL version 3 or any later version * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * */ namespace OCA\Social\Service; use OCA\Social\Tools\Exceptions\DateTimeException; use OCA\Social\Tools\Exceptions\MalformedArrayException; use OCA\Social\Tools\Exceptions\RequestContentException; use OCA\Social\Tools\Exceptions\RequestNetworkException; use OCA\Social\Tools\Exceptions\RequestResultNotJsonException; use OCA\Social\Tools\Exceptions\RequestResultSizeException; use OCA\Social\Tools\Exceptions\RequestServerException; use OCA\Social\Tools\Model\NCRequest; use OCA\Social\Tools\Traits\TArrayTools; use DateTime; use Exception; use JsonLdException; use OCA\Social\AppInfo\Application; use OCA\Social\Db\ActorsRequest; use OCA\Social\Exceptions\ActorDoesNotExistException; use OCA\Social\Exceptions\InvalidOriginException; use OCA\Social\Exceptions\InvalidResourceException; use OCA\Social\Exceptions\ItemUnknownException; use OCA\Social\Exceptions\LinkedDataSignatureMissingException; use OCA\Social\Exceptions\RedundancyLimitException; use OCA\Social\Exceptions\SignatureException; use OCA\Social\Exceptions\SignatureIsGoneException; use OCA\Social\Exceptions\SocialAppConfigException; use OCA\Social\Exceptions\UnauthorizedFediverseException; use OCA\Social\Model\ActivityPub\ACore; use OCA\Social\Model\ActivityPub\Actor\Person; use OCA\Social\Model\LinkedDataSignature; use OCA\Social\Model\RequestQueue; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; use OCP\IRequest; use OCP\Server; use stdClass; class SignatureService { use TArrayTools; public const ORIGIN_HEADER = 1; public const ORIGIN_SIGNATURE = 2; public const ORIGIN_REQUEST = 3; public const DATE_HEADER = 'D, d M Y H:i:s T'; public const DATE_OBJECT = 'Y-m-d\TH:i:s\Z'; public const DATE_DELAY = 300; private CacheActorService $cacheActorService; private ActorsRequest $actorsRequest; private CurlService $curlService; private ConfigService $configService; private MiscService $miscService; public function __construct( ActorsRequest $actorsRequest, CacheActorService $cacheActorService, CurlService $curlService, ConfigService $configService, MiscService $miscService ) { $this->actorsRequest = $actorsRequest; $this->cacheActorService = $cacheActorService; $this->curlService = $curlService; $this->configService = $configService; $this->miscService = $miscService; } /** * @param Person $actor */ public function generateKeys(Person &$actor) { $res = openssl_pkey_new( [ "digest_alg" => "rsa", "private_key_bits" => 2048, "private_key_type" => OPENSSL_KEYTYPE_RSA, ] ); openssl_pkey_export($res, $privateKey); $publicKey = openssl_pkey_get_details($res)['key']; $actor->setPublicKey($publicKey); $actor->setPrivateKey($privateKey); } /** * @param NCRequest $request * @param RequestQueue $queue * * @throws ActorDoesNotExistException * @throws SocialAppConfigException // TODO: implement in TNCRequest ? */ public function signRequest(NCRequest $request, RequestQueue $queue): void { $date = gmdate(self::DATE_HEADER); $path = $queue->getInstance(); $localActor = $this->actorsRequest->getFromId($queue->getAuthor()); $headersElements = ['(request-target)', 'content-length', 'date', 'host', 'digest']; $allElements = [ '(request-target)' => 'post ' . $path->getPath(), 'date' => $date, 'host' => $path->getAddress(), 'digest' => $this->generateDigest($request->getDataBody()), 'content-length' => strlen($request->getDataBody()) ]; $signing = $this->generateHeaders($headersElements, $allElements, $request); openssl_sign($signing, $signed, $localActor->getPrivateKey(), OPENSSL_ALGO_SHA256); $signed = base64_encode($signed); $signature = $this->generateSignature($headersElements, $localActor->getId(), $signed); $request->addHeader('Signature', $signature); } /** * @param array $elements * @param array $data * @param NCRequest $request * * @return string */ private function generateHeaders(array $elements, array $data, NCRequest $request): string { $signingElements = []; foreach ($elements as $element) { $signingElements[] = $element . ': ' . $data[$element]; $request->addHeader($element, $data[$element]); } return implode("\n", $signingElements); } /** * @param array $elements * @param string $actorId * @param string $signed * * @return string */ private function generateSignature(array $elements, string $actorId, string $signed): string { $signatureElements[] = 'keyId="' . $actorId . '#main-key"'; $signatureElements[] = 'algorithm="rsa-sha256"'; $signatureElements[] = 'headers="' . implode(' ', $elements) . '"'; $signatureElements[] = 'signature="' . $signed . '"'; return implode(',', $signatureElements); } /** * @param string $data * * @return string */ private function generateDigest(string $data): string { $encoded = hash("sha256", utf8_encode($data), true); return 'SHA-256=' . base64_encode($encoded); } /** * @param IRequest $request * * @param string $data * @param int $time * * @return string * @throws DateTimeException * @throws InvalidOriginException * @throws InvalidResourceException * @throws ItemUnknownException * @throws MalformedArrayException * @throws RedundancyLimitException * @throws RequestNetworkException * @throws RequestResultNotJsonException * @throws RequestResultSizeException * @throws RequestServerException * @throws SignatureException * @throws SignatureIsGoneException * @throws SocialAppConfigException * @throws UnauthorizedFediverseException */ public function checkRequest(IRequest $request, string $data, int &$time = 0): string { try { $dTime = new DateTime($request->getHeader('date')); $time = $dTime->getTimestamp(); } catch (Exception $e) { throw new DateTimeException( 'datetime exception: ' . $e->getMessage() . ' - ' . $request->getHeader('date') ); } if ($time < (time() - self::DATE_DELAY)) { throw new SignatureException('object is too old'); } if (strlen($data) !== (int)$request->getHeader('content-length')) { throw new SignatureException('issue with content-length'); } if ($this->generateDigest($data) !== $request->getHeader('digest')) { throw new SignatureException('issue with digest'); } try { return $this->checkRequestSignature($request, $data); } catch (RequestContentException $e) { throw new SignatureIsGoneException(); } catch (SignatureException $e) { } return ''; } /** * @param ACore $object * * @return bool * @throws InvalidOriginException * @throws InvalidResourceException * @throws MalformedArrayException * @throws RedundancyLimitException * @throws RequestContentException * @throws RequestNetworkException * @throws RequestResultSizeException * @throws RequestServerException * @throws SocialAppConfigException * @throws ItemUnknownException * @throws RequestResultNotJsonException * @throws DateTimeException * @throws UnauthorizedFediverseException */ public function checkObject(ACore $object): bool { try { $actorId = $object->getActorId(); $signature = new LinkedDataSignature(); $signature->import(json_decode($object->getSource(), true)); $signature->setPublicKey($this->retrieveKey($actorId)); if (!$signature->verify()) { $signature->setPublicKey($this->retrieveKey($actorId, true)); if (!$signature->verify()) { return false; } } try { $dTime = new DateTime($signature->getCreated()); $time = $dTime->getTimestamp(); } catch (Exception $e) { throw new DateTimeException( 'datetime exception: ' . $e->getMessage() . ' - ' . $signature->getCreated() ); } $object->setOrigin( $this->getKeyOrigin($actorId), SignatureService::ORIGIN_SIGNATURE, $time ); return true; } catch (LinkedDataSignatureMissingException $e) { $this->miscService->log( 'Notice: LinkedDataSignatureMissingException while checkObject : ' . $e->getMessage() . ' --- ' . json_encode($object), 1 ); } return false; } /** * @param Person $actor * @param ACore $object */ public function signObject(Person $actor, ACore &$object) { $signature = new LinkedDataSignature(); $signature->setPrivateKey($actor->getPrivateKey()); $signature->setType('RsaSignature2017'); $signature->setCreator($actor->getId() . '#main-key'); $signature->setCreated($date = gmdate(self::DATE_OBJECT)); $signature->setObject(json_decode(json_encode($object), true)); try { $signature->sign(); $object->setSignature($signature); } catch (Exception $e) { } } /** * @param IRequest $request * * @param string $data * * @return string * @throws InvalidOriginException * @throws InvalidResourceException * @throws ItemUnknownException * @throws MalformedArrayException * @throws RedundancyLimitException * @throws RequestContentException * @throws RequestNetworkException * @throws RequestResultNotJsonException * @throws RequestResultSizeException * @throws RequestServerException * @throws SocialAppConfigException * @throws UnauthorizedFediverseException * @throws SignatureException */ private function checkRequestSignature(IRequest $request, string $data): string { $signatureHeader = $request->getHeader('Signature'); $sign = $this->parseSignatureHeader($signatureHeader); $this->mustContains(['keyId', 'headers', 'signature'], $sign); $keyId = $sign['keyId']; $origin = $this->getKeyOrigin($keyId); $headers = $sign['headers']; $signed = base64_decode($sign['signature']); $estimated = $this->generateEstimatedSignature($headers, $request); try { $publicKey = $this->retrieveKey($keyId); $this->checkRequestSignatureUsingPublicKey($publicKey, $sign, $estimated, $signed); } catch (SignatureException $e) { $publicKey = $this->retrieveKey($keyId, true); $this->checkRequestSignatureUsingPublicKey($publicKey, $sign, $estimated, $signed); } return $origin; } /** * @param string $publicKey * @param array $sign * @param string $estimated * @param string $signed * * @throws SignatureException */ private function checkRequestSignatureUsingPublicKey( string $publicKey, array $sign, string $estimated, string $signed ) { $algorithm = $this->getAlgorithmFromSignature($sign); if ($publicKey === '' || openssl_verify($estimated, $signed, $publicKey, $algorithm) !== 1) { throw new SignatureException( 'signature cannot be checked - signed: ' . $signed . ' - key: ' . $publicKey . ' - algo: ' . $algorithm . ' - estimated: ' . $estimated ); } } /** * @param string $headers * @param IRequest $request * * @return string * @throws SocialAppConfigException * @throws SignatureException */ private function generateEstimatedSignature(string $headers, IRequest $request): string { $keys = explode(' ', $headers); if (!empty(array_diff(['(request-target)', 'date', 'digest', 'host'], $keys))) { throw new SignatureException('missing elements in \'headers\''); } $target = ''; try { $target = strtolower($request->getMethod()) . " " . $request->getRequestUri(); } catch (Exception $e) { } $estimated = ''; foreach ($keys as $key) { if ($key === '(request-target)') { $estimated .= "(request-target): " . $target . "\n"; continue; } $value = $request->getHeader($key); if ($key === 'host') { $value = $this->configService->getCloudHost(); } $estimated .= $key . ': ' . $value . "\n"; } return trim($estimated, "\n"); } /** * @param $signatureHeader * * @return array */ private function parseSignatureHeader($signatureHeader) { $sign = []; $entries = explode(',', $signatureHeader); foreach ($entries as $entry) { if ($entry === '' || !strpos($entry, '=')) { continue; } list($k, $v) = explode('=', $entry, 2); preg_match('/"([^"]+)"/', $v, $varr); if ($varr[0] !== null) { $v = trim($varr[0], '"'); } $sign[$k] = $v; } return $sign; } /** * @param string $keyId * * @param bool $refresh * * @return string * @throws InvalidOriginException * @throws InvalidResourceException * @throws ItemUnknownException * @throws MalformedArrayException * @throws RedundancyLimitException * @throws RequestContentException * @throws RequestNetworkException * @throws RequestResultNotJsonException * @throws RequestResultSizeException * @throws RequestServerException * @throws SocialAppConfigException * @throws UnauthorizedFediverseException */ private function retrieveKey(string $keyId, bool $refresh = false): string { $actor = $this->cacheActorService->getFromId($keyId, $refresh); return $actor->getPublicKey(); } /** * @param $id * * @return string * @throws InvalidOriginException */ private function getKeyOrigin($id) { $host = parse_url($id, PHP_URL_HOST); if (is_string($host) && ($host !== '')) { return $host; } throw new InvalidOriginException( 'SignatureService::getKeyOrigin - host: ' . $host . ' - id: ' . $id ); } /** * @param array $sign * * @return string */ private function getAlgorithmFromSignature(array $sign): string { switch ($this->get('algorithm', $sign, '')) { case 'rsa-sha512': return 'sha512'; case 'rsa-sha256': return 'sha256'; default: return 'sha256'; } } /** * @param string $url * * @return stdClass * @throws NotPermittedException * @throws JsonLdException * @throws NotFoundException */ public static function documentLoader($url): stdClass { $recursion = 0; $x = debug_backtrace(); if ($x) { foreach ($x as $n) { if ($n['function'] === __FUNCTION__) { $recursion++; } } } if ($recursion > 5) { exit(); } $folder = self::getContextCacheFolder(); $filename = parse_url($url, PHP_URL_HOST) . parse_url($url, PHP_URL_PATH); $filename = str_replace('/', '.', $filename) . '.json'; try { $cache = $folder->getFile($filename); self::updateContextCacheDocument($cache, $url); $data = json_decode($cache->getContent()); } catch (NotFoundException $e) { $data = self::generateContextCacheDocument($folder, $filename, $url); } return $data; } /** * @return ISimpleFolder * @throws NotPermittedException */ private static function getContextCacheFolder(): ISimpleFolder { $path = 'context'; $appData = Server::get(IAppDataFactory::class)->get(Application::APP_NAME); try { $folder = $appData->getFolder($path); } catch (NotFoundException $e) { $folder = $appData->newFolder($path); } return $folder; } /** * @param ISimpleFolder $folder * @param string $filename * * @param string $url * * @return stdClass * @throws JsonLdException * @throws NotPermittedException * @throws NotFoundException */ private static function generateContextCacheDocument( ISimpleFolder $folder, string $filename, string $url ): stdClass { try { $data = jsonld_default_document_loader($url); $content = json_encode($data); } catch (JsonLdException $e) { $context = file_get_contents(__DIR__ . '/../../context/' . $filename); if (is_bool($context)) { throw $e; } $content = $context; $data = json_decode($context); } $cache = $folder->newFile($filename); $cache->putContent($content); return $data; } /** * @param ISimpleFile $cache * @param string $url * * @throws NotPermittedException * @throws NotFoundException */ private static function updateContextCacheDocument(ISimpleFile $cache, string $url) { if ($cache->getMTime() < (time() - 98765)) { try { $data = jsonld_default_document_loader($url); $cache->putContent(json_encode($data)); } catch (JsonLdException $e) { } } } }