diff --git a/mod/salmon.php b/mod/salmon.php deleted file mode 100644 index a4eb3f81e2..0000000000 --- a/mod/salmon.php +++ /dev/null @@ -1,183 +0,0 @@ -. - * - */ - -use Friendica\App; -use Friendica\Core\Logger; -use Friendica\Core\Protocol; -use Friendica\Database\DBA; -use Friendica\DI; -use Friendica\Model\GServer; -use Friendica\Model\Post; -use Friendica\Protocol\ActivityNamespace; -use Friendica\Protocol\OStatus; -use Friendica\Protocol\Salmon; -use Friendica\Util\Crypto; -use Friendica\Util\Network; -use Friendica\Util\Strings; - -function salmon_post(App $a, $xml = '') { - - if (empty($xml)) { - $xml = Network::postdata(); - } - - Logger::debug('new salmon ' . $xml); - - $nick = trim(DI::args()->getArgv()[1] ?? ''); - - $importer = DBA::selectFirst('user', [], ['nickname' => $nick, 'account_expired' => false, 'account_removed' => false]); - if (! DBA::isResult($importer)) { - throw new \Friendica\Network\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)) { - Logger::notice('unable to locate salmon data in xml'); - throw new \Friendica\Network\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) { - Logger::notice('Could not retrieve author URI.'); - throw new \Friendica\Network\HTTPException\BadRequestException(); - } - - // Once we have the author URI, go to the web and try to find their public key - - Logger::notice('Fetching key for ' . $author_link); - - $key = Salmon::getKey($author_link, $keyhash); - - if(! $key) { - Logger::notice('Could not retrieve author key.'); - throw new \Friendica\Network\HTTPException\BadRequestException(); - } - - $key_info = explode('.',$key); - - $m = Strings::base64UrlDecode($key_info[1]); - $e = Strings::base64UrlDecode($key_info[2]); - - 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) { - Logger::notice('message did not verify using protocol. Trying compliant format.'); - $verify = Crypto::rsaVerify($compliant_format, $signature, $pubkey); - $mode = 2; - } - - if (! $verify) { - Logger::notice('message did not verify using padding. Trying old statusnet format.'); - $verify = Crypto::rsaVerify($stnet_signed_data, $signature, $pubkey); - $mode = 3; - } - - if (! $verify) { - Logger::notice('Message did not verify. Discarding.'); - throw new \Friendica\Network\HTTPException\BadRequestException(); - } - - 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 = DBA::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'])) { - Logger::notice('Ignoring this author.'); - throw new \Friendica\Network\HTTPException\AcceptedException(); - } - - // Placeholder for hub discovery. - $hub = ''; - - $contact = $contact ?: []; - - OStatus::import($data, $importer, $contact, $hub); - - throw new \Friendica\Network\HTTPException\OKException(); -} diff --git a/src/Module/DFRN/Notify.php b/src/Module/DFRN/Notify.php index 08ec7340cb..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(); @@ -52,37 +69,46 @@ class Notify extends BaseModule if (empty($user)) { throw new \Friendica\Network\HTTPException\InternalServerErrorException(); } - self::dispatchPrivate($user, $postdata); - } elseif (!self::dispatchPublic($postdata)) { - require_once 'mod/salmon.php'; - salmon_post(DI::app(), $postdata); + $this->dispatchPrivate($user, $postdata); + } elseif (!$this->dispatchPublic($postdata)) { + (new Salmon( + $this->database, + $this->l10n, + $this->baseUrl, + $this->args, + $this->logger, + $this->profiler, + $this->response, + $this->server, + $this->parameters + ))->rawContent($request); } } - private static function dispatchPublic(string $postdata): bool + private function dispatchPublic(string $postdata): bool { $msg = Diaspora::decodeRaw($postdata, '', true); if (!is_array($msg)) { // We have to fail silently to be able to hand it over to the salmon parser - Logger::warning('Diaspora::decodeRaw() has failed for some reason.'); + $this->logger->warning('Diaspora::decodeRaw() has failed for some reason.'); return false; } // Fetch the corresponding public contact $contact_id = Contact::getIdForURL($msg['author']); if (empty($contact_id)) { - Logger::notice('Contact not found', ['address' => $msg['author']]); + $this->logger->notice('Contact not found', ['address' => $msg['author']]); System::xmlExit(3, 'Contact ' . $msg['author'] . ' not found'); } // Fetch the importer (Mixture of sender and receiver) $importer = DFRN::getImporter($contact_id); if (empty($importer)) { - Logger::notice('Importer contact not found', ['address' => $msg['author']]); + $this->logger->notice('Importer contact not found', ['address' => $msg['author']]); System::xmlExit(3, 'Contact ' . $msg['author'] . ' not found'); } - Logger::debug('Importing post with the public envelope.', ['transmitter' => $msg['author']]); + $this->logger->debug('Importing post with the public envelope.', ['transmitter' => $msg['author']]); // Now we should be able to import it $ret = DFRN::import($msg['message'], $importer, Conversation::PARCEL_DIASPORA_DFRN, Conversation::RELAY); @@ -91,7 +117,7 @@ class Notify extends BaseModule return true; } - private static function dispatchPrivate(array $user, string $postdata) + private function dispatchPrivate(array $user, string $postdata) { $msg = Diaspora::decodeRaw($postdata, $user['prvkey'] ?? ''); if (!is_array($msg)) { @@ -101,23 +127,23 @@ class Notify extends BaseModule // Fetch the contact $contact = Contact::getByURLForUser($msg['author'], $user['uid'], null, ['id', 'blocked', 'pending']); if (empty($contact['id'])) { - Logger::notice('Contact not found', ['address' => $msg['author']]); + $this->logger->notice('Contact not found', ['address' => $msg['author']]); System::xmlExit(3, 'Contact ' . $msg['author'] . ' not found'); } if ($contact['pending'] || $contact['blocked']) { - Logger::notice('Contact is blocked or pending', ['address' => $msg['author'], 'contact' => $contact]); + $this->logger->notice('Contact is blocked or pending', ['address' => $msg['author'], 'contact' => $contact]); System::xmlExit(3, 'Contact ' . $msg['author'] . ' not found'); } // Fetch the importer (Mixture of sender and receiver) $importer = DFRN::getImporter($contact['id'], $user['uid']); if (empty($importer)) { - Logger::notice('Importer contact not found for user', ['uid' => $user['uid'], 'cid' => $contact['id'], 'address' => $msg['author']]); + $this->logger->notice('Importer contact not found for user', ['uid' => $user['uid'], 'cid' => $contact['id'], 'address' => $msg['author']]); System::xmlExit(3, 'Contact ' . $msg['author'] . ' not found'); } - Logger::debug('Importing post with the private envelope.', ['transmitter' => $msg['author'], 'receiver' => $user['nickname']]); + $this->logger->debug('Importing post with the private envelope.', ['transmitter' => $msg['author'], 'receiver' => $user['nickname']]); // Now we should be able to import it $ret = DFRN::import($msg['message'], $importer, Conversation::PARCEL_DIASPORA_DFRN, Conversation::PUSH); diff --git a/src/Module/OStatus/Salmon.php b/src/Module/OStatus/Salmon.php new file mode 100644 index 0000000000..f674f9300b --- /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]],