social/lib/Service/SignatureService.php

646 wiersze
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* Nextcloud - Social Support
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Maxence Lange <maxence@artificial-owl.com>
* @copyright 2018, Maxence Lange <maxence@artificial-owl.com>
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Social\Service;
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 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 OCP\AppFramework\Http;
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 Psr\Log\LoggerInterface;
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 LoggerInterface $logger;
public function __construct(
ActorsRequest $actorsRequest,
CacheActorService $cacheActorService,
CurlService $curlService,
ConfigService $configService,
LoggerInterface $logger
) {
$this->actorsRequest = $actorsRequest;
$this->cacheActorService = $cacheActorService;
$this->curlService = $curlService;
$this->configService = $configService;
$this->logger = $logger;
}
/**
* @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
*/
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];
if ($element !== '(request-target)') {
$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", $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 -- sent: ' .
$request->getHeader('digest') . ', expected: ' . $this->generateDigest($data)
);
}
try {
return $this->checkRequestSignature($request, $data);
} catch (SignatureException $e) {
} catch (RequestContentException $e) {
if ($e->getCode() === Http::STATUS_GONE) {
throw new SignatureIsGoneException();
}
}
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) {
}
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(string $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(string $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) {
}
}
}
}