diff --git a/src/Module/DFRN/Notify.php b/src/Module/DFRN/Notify.php index 32c11beb77..34cf21f11d 100644 --- a/src/Module/DFRN/Notify.php +++ b/src/Module/DFRN/Notify.php @@ -21,23 +21,40 @@ namespace Friendica\Module\DFRN; +use Friendica\App; use Friendica\BaseModule; +use Friendica\Core\L10n; use Friendica\Core\Logger; use Friendica\Core\System; +use Friendica\Database\Database; use Friendica\DI; use Friendica\Model\Contact; use Friendica\Model\Conversation; use Friendica\Model\User; +use Friendica\Module\OStatus\Salmon; +use Friendica\Module\Response; use Friendica\Protocol\DFRN; use Friendica\Protocol\Diaspora; use Friendica\Util\Network; use Friendica\Network\HTTPException; +use Friendica\Util\Profiler; +use Psr\Log\LoggerInterface; /** * DFRN Notify */ class Notify extends BaseModule { + /** @var Database */ + private $database; + + public function __construct(Database $database, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) + { + parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); + + $this->database = $database; + } + protected function post(array $request = []) { $postdata = Network::postdata(); @@ -54,8 +71,17 @@ class Notify extends BaseModule } $this->dispatchPrivate($user, $postdata); } elseif (!$this->dispatchPublic($postdata)) { - require_once 'mod/salmon.php'; - salmon_post(DI::app(), $postdata); + (new Salmon( + $this->database, + $this->l10n, + $this->baseUrl, + $this->args, + $this->logger, + $this->profiler, + $this->response, + $this->server, + $this->parameters + ))->rawContent($request); } } diff --git a/src/Module/OStatus/Salmon.php b/src/Module/OStatus/Salmon.php new file mode 100644 index 0000000000..c530943573 --- /dev/null +++ b/src/Module/OStatus/Salmon.php @@ -0,0 +1,220 @@ +. + * + */ + +namespace Friendica\Module\OStatus; + +use Friendica\App; +use Friendica\Core\L10n; +use Friendica\Core\Protocol; +use Friendica\Database\Database; +use Friendica\Model\GServer; +use Friendica\Model\Post; +use Friendica\Module\Response; +use Friendica\Protocol\ActivityNamespace; +use Friendica\Protocol\OStatus; +use Friendica\Util\Crypto; +use Friendica\Util\Network; +use Friendica\Network\HTTPException; +use Friendica\Protocol\Salmon as SalmonProtocol; +use Friendica\Util\Profiler; +use Friendica\Util\Strings; +use Psr\Log\LoggerInterface; + +/** + * Technical endpoint for the Salmon protocol + */ +class Salmon extends \Friendica\BaseModule +{ + /** @var Database */ + private $database; + + public function __construct(Database $database, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = []) + { + parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters); + + $this->database = $database; + } + + /** + * @param array $request + * @return void + * @throws HTTPException\AcceptedException + * @throws HTTPException\BadRequestException + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\OKException + * @throws \ImagickException + */ + protected function rawContent(array $request = []) + { + $xml = Network::postdata(); + + $this->logger->debug('New Salmon', ['nickname' => $this->parameters['nickname'], 'xml' => $xml]); + + // Despite having a route with a mandatory nickname parameter, this method can also be called from + // \Friendica\Module\DFRN\Notify->post where the same parameter is optional 🤷‍ + $nickname = $this->parameters['nickname'] ?? ''; + + $importer = $this->database->selectFirst('user', [], ['nickname' => $nickname, 'account_expired' => false, 'account_removed' => false]); + if (!$this->database->isResult($importer)) { + throw new HTTPException\InternalServerErrorException(); + } + + // parse the xml + $dom = simplexml_load_string($xml, 'SimpleXMLElement', 0, ActivityNamespace::SALMON_ME); + + $base = null; + + // figure out where in the DOM tree our data is hiding + if (!empty($dom->provenance->data)) { + $base = $dom->provenance; + } elseif (!empty($dom->env->data)) { + $base = $dom->env; + } elseif (!empty($dom->data)) { + $base = $dom; + } + + if (empty($base)) { + $this->logger->notice('unable to locate salmon data in xml'); + throw new HTTPException\BadRequestException(); + } + + // Stash the signature away for now. We have to find their key or it won't be good for anything. + $signature = Strings::base64UrlDecode($base->sig); + + // unpack the data + + // strip whitespace so our data element will return to one big base64 blob + $data = str_replace([" ", "\t", "\r", "\n"], ["", "", "", ""], $base->data); + + // stash away some other stuff for later + + $type = $base->data[0]->attributes()->type[0]; + $keyhash = $base->sig[0]->attributes()->keyhash[0] ?? ''; + $encoding = $base->encoding; + $alg = $base->alg; + + // Salmon magic signatures have evolved and there is no way of knowing ahead of time which + // flavour we have. We'll try and verify it regardless. + + $stnet_signed_data = $data; + + $signed_data = $data . '.' . Strings::base64UrlEncode($type) . '.' . Strings::base64UrlEncode($encoding) . '.' . Strings::base64UrlEncode($alg); + + $compliant_format = str_replace('=', '', $signed_data); + + + // decode the data + $data = Strings::base64UrlDecode($data); + + $author = OStatus::salmonAuthor($data, $importer); + $author_link = $author["author-link"]; + if (!$author_link) { + $this->logger->notice('Could not retrieve author URI.'); + throw new HTTPException\BadRequestException(); + } + + // Once we have the author URI, go to the web and try to find their public key + + $this->logger->notice('Fetching key for ' . $author_link); + + $key = SalmonProtocol::getKey($author_link, $keyhash); + + if (!$key) { + $this->logger->notice('Could not retrieve author key.'); + throw new HTTPException\BadRequestException(); + } + + $key_info = explode('.', $key); + + $m = Strings::base64UrlDecode($key_info[1]); + $e = Strings::base64UrlDecode($key_info[2]); + + $this->logger->info('Key details', ['info' => $key_info]); + + $pubkey = Crypto::meToPem($m, $e); + + // We should have everything we need now. Let's see if it verifies. + + // Try GNU Social format + $verify = Crypto::rsaVerify($signed_data, $signature, $pubkey); + $mode = 1; + + if (!$verify) { + $this->logger->notice('Message did not verify using protocol. Trying compliant format.'); + $verify = Crypto::rsaVerify($compliant_format, $signature, $pubkey); + $mode = 2; + } + + if (!$verify) { + $this->logger->notice('Message did not verify using padding. Trying old statusnet format.'); + $verify = Crypto::rsaVerify($stnet_signed_data, $signature, $pubkey); + $mode = 3; + } + + if (!$verify) { + $this->logger->notice('Message did not verify. Discarding.'); + throw new HTTPException\BadRequestException(); + } + + $this->logger->notice('Message verified with mode ' . $mode); + + + /* + * + * If we reached this point, the message is good. Now let's figure out if the author is allowed to send us stuff. + * + */ + + $contact = $this->database->selectFirst( + 'contact', + [], + [ + "`network` IN (?, ?) + AND (`nurl` = ? OR `alias` = ? OR `alias` = ?) + AND `uid` = ?", + Protocol::OSTATUS, Protocol::DFRN, + Strings::normaliseLink($author_link), $author_link, Strings::normaliseLink($author_link), + $importer['uid'] + ] + ); + + if (!empty($contact['gsid'])) { + GServer::setProtocol($contact['gsid'], Post\DeliveryData::OSTATUS); + } + + // Have we ignored the person? + // If so we can not accept this post. + + if (!empty($contact['blocked'])) { + $this->logger->notice('Ignoring this author.'); + throw new HTTPException\AcceptedException(); + } + + // Placeholder for hub discovery. + $hub = ''; + + $contact = $contact ?: []; + + OStatus::import($data, $importer, $contact, $hub); + + throw new HTTPException\OKException(); + } +} diff --git a/static/routes.config.php b/static/routes.config.php index 3cb1b47e22..d72302da23 100644 --- a/static/routes.config.php +++ b/static/routes.config.php @@ -553,6 +553,8 @@ return [ '/{sub1}/{sub2}/{url}' => [Module\Proxy::class, [R::GET]], ], + '/salmon/{nickname}' => [Module\OStatus\Salmon::class, [ R::POST]], + '/search' => [ '[/]' => [Module\Search\Index::class, [R::GET]], '/acl' => [Module\Search\Acl::class, [R::GET, R::POST]],