diff --git a/components/Notification/Entity/Notification.php b/components/Notification/Entity/Notification.php index 56e876c2a0..a766abd8c3 100644 --- a/components/Notification/Entity/Notification.php +++ b/components/Notification/Entity/Notification.php @@ -112,7 +112,7 @@ class Notification extends Entity */ public function getTarget(): Actor { - return Actor::getById($this->getActorId()); + return Actor::getById($this->getTargetId()); } /** diff --git a/components/Tag/Controller/Tag.php b/components/Tag/Controller/Tag.php index 09d2694715..b79ab69b4a 100644 --- a/components/Tag/Controller/Tag.php +++ b/components/Tag/Controller/Tag.php @@ -72,11 +72,11 @@ class Tag extends Controller ); } - public function multi_actor_tag(string $tag) + public function multi_actor_tag(string $tags) { $tags = explode(',', $tags); return $this->process( - tag_or_tags: $tag, + tag_or_tags: $tags, key: fn ($canonical) => 'actor-tags-feed-' . implode('-', $canonical), query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC', template: 'actor_tag_feed.html.twig', diff --git a/plugins/ActivityPub/ActivityPub.php b/plugins/ActivityPub/ActivityPub.php index 8476ed4e18..44796a1c5c 100644 --- a/plugins/ActivityPub/ActivityPub.php +++ b/plugins/ActivityPub/ActivityPub.php @@ -26,6 +26,7 @@ use Exception; use Plugin\ActivityPub\Controller\Inbox; use Plugin\ActivityPub\Entity\ActivitypubActor; use Plugin\ActivityPub\Util\Explorer; +use Plugin\ActivityPub\Util\HTTPSignature; use Plugin\ActivityPub\Util\Model\EntityToType\EntityToType; use Plugin\ActivityPub\Util\Response\ActorResponse; use Plugin\ActivityPub\Util\Response\NoteResponse; @@ -128,7 +129,7 @@ class ActivityPub extends Plugin $to_failed = []; // TODO: Implement failed queues foreach ($to_addr as $inbox => $dummy) { try { - $res = self::postman($sender->getUri(), EntityToType::translate($activity), $inbox); + $res = self::postman($sender, EntityToType::translate($activity), $inbox); // accummulate errors for later use, if needed $status_code = $res->getStatusCode(); @@ -154,19 +155,19 @@ class ActivityPub extends Plugin } /** - * @param string $sender + * @param Actor $sender * @param Type $activity * @param string $inbox * @param string $method * @return ResponseInterface */ - public static function postman(string $sender, mixed $activity, string $inbox, string $method = 'post'): ResponseInterface + public static function postman(Actor $sender, mixed $activity, string $inbox, string $method = 'post'): ResponseInterface { $data = $activity->toJson(); Log::debug('ActivityPub Postman: Delivering ' . $data . ' to ' . $inbox); - $headers = []; //HttpSignature::sign($sender, $inbox, $data); - Log::debug('ActivityPub Postman: Delivery headers were: '.print_r($headers, true)); + $headers = HTTPSignature::sign($sender, $inbox, $data); + Log::debug('ActivityPub Postman: Delivery headers were: ' . print_r($headers, true)); $response = HTTPClient::$method($inbox, ['headers' => $headers, 'body' => $data]); Log::debug('ActivityPub Postman: Delivery result with status code '.$response->getStatusCode().': '.$response->getContent()); diff --git a/plugins/ActivityPub/Controller/Inbox.php b/plugins/ActivityPub/Controller/Inbox.php index fd9295070b..a51d58b703 100644 --- a/plugins/ActivityPub/Controller/Inbox.php +++ b/plugins/ActivityPub/Controller/Inbox.php @@ -25,6 +25,14 @@ namespace Plugin\ActivityPub\Controller; use App\Core\Controller; use App\Core\DB\DB; +use App\Core\Log; +use App\Core\Router\Router; +use App\Entity\Actor; +use Exception; +use Plugin\ActivityPub\Entity\ActivitypubActor; +use Plugin\ActivityPub\Entity\ActivitypubRsa; +use Plugin\ActivityPub\Util\Explorer; +use Plugin\ActivityPub\Util\HTTPSignature; use function App\Core\I18n\_m; use App\Util\Exception\ClientException; use Plugin\ActivityPub\ActivityPub; @@ -40,23 +48,81 @@ class Inbox extends Controller */ public function handle(?int $gsactor_id = null): TypeResponse { + $path = Router::url('activitypub_inbox', type: Router::ABSOLUTE_PATH); + if (!\is_null($gsactor_id)) { - $user = DB::find('local_user', ['id' => $gsactor_id]); - if (\is_null($user)) { - throw new ClientException(_m('No such actor.'), 404); + try { + $user = DB::findOneBy('local_user', ['id' => $gsactor_id]); + $path = Router::url('activitypub_actor_inbox', ['gsactor_id' => $user->getId()], type: Router::ABSOLUTE_PATH); + } catch (Exception $e) { + throw new ClientException(_m('No such actor.'), 404, $e); } } - // TODO: Check if Actor can post + Log::debug('ActivityPub Inbox: Received a POST request.'); + $body = (string) $this->request->getContent(); + $type = Type::fromJson($body); -// // Get content -// $payload = Util::decodeJson( -// (string) $this->request->getContent(), -// ); -// -// // Cast as an ActivityStreams type -// $type = Type::create($payload); - $type = Type::fromJson((string) $this->request->getContent()); + if ($type->has('actor') === false) { + ActivityPubReturn::error('Actor not found in the request.'); + } + + try { + $ap_actor = ActivitypubActor::fromUri($type->get('actor')); + $actor = Actor::getById($ap_actor->getActorId()); + DB::flush(); + } catch (Exception) { + ActivityPubReturn::error('Invalid actor.'); + } + + $actor_public_key = ActivitypubRsa::getByActor($actor)->getPublicKey(); + + $headers = $this->request->headers->all(); + // Flattify headers + foreach ($headers as $key => $val) { + $headers[$key] = $val[0]; + } + + if (!isset($headers['signature'])) { + Log::debug('ActivityPub Inbox: HTTP Signature: Missing Signature header.'); + ActivityPubReturn::error('Missing Signature header.', 400); + // TODO: support other methods beyond HTTP Signatures + } + + // Extract the signature properties + $signatureData = HTTPSignature::parseSignatureHeader($headers['signature']); + Log::debug('ActivityPub Inbox: HTTP Signature Data: ' . print_r($signatureData, true)); + if (isset($signatureData['error'])) { + Log::debug('ActivityPub Inbox: HTTP Signature: ' . json_encode($signatureData, JSON_PRETTY_PRINT)); + ActivityPubReturn::error(json_encode($signatureData, JSON_PRETTY_PRINT), 400); + } + + list($verified, /*$headers*/) = HTTPSignature::verify($actor_public_key, $signatureData, $headers, $path, $body); + + // If the signature fails verification the first time, update profile as it might have changed public key + if ($verified !== 1) { + try { + $res = Explorer::get_remote_user_activity($ap_actor->getUri()); + } catch (Exception) { + ActivityPubReturn::error('Invalid remote actor.'); + } + try { + $actor = ActivitypubActor::update_profile($ap_actor, $res); + } catch (Exception) { + ActivityPubReturn::error('Failed to updated remote actor information.'); + } + + [$verified, /*$headers*/] = HTTPSignature::verify($actor_public_key, $signatureData, $headers, $path, $body); + } + + // If it still failed despite profile update + if ($verified !== 1) { + Log::debug('ActivityPub Inbox: HTTP Signature: Invalid signature.'); + ActivityPubReturn::error('Invalid signature.'); + } + + // HTTP signature checked out, make sure the "actor" of the activity matches that of the signature + Log::debug('ActivityPub Inbox: HTTP Signature: Authorized request. Will now start the inbox handler.'); // TODO: Check if Actor has authority over payload diff --git a/plugins/ActivityPub/Entity/ActivitypubRsa.php b/plugins/ActivityPub/Entity/ActivitypubRsa.php index 132f4bba6d..727362dc55 100644 --- a/plugins/ActivityPub/Entity/ActivitypubRsa.php +++ b/plugins/ActivityPub/Entity/ActivitypubRsa.php @@ -22,6 +22,7 @@ use App\Core\Log; use App\Entity\Actor; use App\Util\Exception\ServerException; use DateTimeInterface; +use Doctrine\ORM\UnitOfWork; use Exception; /** @@ -143,16 +144,16 @@ class ActivitypubRsa extends Entity public static function getByActor(Actor $gsactor, bool $fetch = true): self { $apRSA = self::getWithPK(['actor_id' => ($actor_id = $gsactor->getId())]); - if (!$apRSA instanceof self) { + if (is_null($apRSA)) { // Nonexistent key pair for this profile if ($gsactor->getIsLocal()) { - self::generateKeys($private_key, $public_key); - - $apRSA = new self(); - $apRSA->setActorId($actor_id); - $apRSA->setPrivateKey($private_key); - $apRSA->setPublicKey($public_key); + $apRSA = self::create([ + 'actor_id' => $actor_id, + 'private_key' => $private_key, + 'public_key' => $public_key, + ]); + DB::wrapInTransaction(fn() => DB::persist($apRSA)); } else { // ASSERT: This should never happen, but try to recover! Log::error("Activitypub_rsa: An impossible thing has happened... Please let the devs know."); diff --git a/plugins/ActivityPub/Util/HTTPSignature.php b/plugins/ActivityPub/Util/HTTPSignature.php new file mode 100644 index 0000000000..8cbd37f321 --- /dev/null +++ b/plugins/ActivityPub/Util/HTTPSignature.php @@ -0,0 +1,180 @@ + + * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 + * @link https://github.com/aaronpk/Nautilus/blob/master/app/ActivityPub/HTTPSignature.php + */ + +namespace Plugin\ActivityPub\Util; + +use App\Entity\Actor; +use DateTime; +use Exception; +use Plugin\ActivityPub\Entity\ActivitypubRsa; + +class HTTPSignature +{ + /** + * Sign a message with an Actor + * + * @param Actor $user Actor signing + * @param string $url Inbox url + * @param string|bool $body Data to sign (optional) + * @param array $addlHeaders Additional headers (optional) + * @return array Headers to be used in request + * @throws Exception Attempted to sign something that belongs to an Actor we don't own + */ + public static function sign(Actor $user, string $url, string|bool $body = false, array $addlHeaders = []): array + { + $digest = false; + if ($body) { + $digest = self::_digest($body); + } + $headers = self::_headersToSign($url, $digest); + $headers = array_merge($headers, $addlHeaders); + $stringToSign = self::_headersToSigningString($headers); + $signedHeaders = implode(' ', array_map('strtolower', array_keys($headers))); + $actor_private_key = ActivitypubRsa::getByActor($user)->getPrivateKey(); + // Intentionally unhandled exception, we want this to explode if that happens as it would be a bug + $key = openssl_pkey_get_private($actor_private_key); + openssl_sign($stringToSign, $signature, $key, OPENSSL_ALGO_SHA256); + $signature = base64_encode($signature); + $signatureHeader = 'keyId="' . $user->getUri() . '#public-key' . '",headers="' . $signedHeaders . '",algorithm="rsa-sha256",signature="' . $signature . '"'; + unset($headers['(request-target)']); + $headers['Signature'] = $signatureHeader; + + return $headers; + } + + /** + * @param array|string array or json string $body + * @return string + */ + private static function _digest(array|string $body): string + { + if (is_array($body)) { + $body = json_encode($body); + } + return base64_encode(hash('sha256', $body, true)); + } + + /** + * @param string $url + * @param string|bool $digest + * @return array + * @throws Exception + */ + protected static function _headersToSign(string $url, string|bool $digest = false): array + { + $date = new DateTime('UTC'); + + $headers = [ + '(request-target)' => 'post ' . parse_url($url, PHP_URL_PATH), + 'Date' => $date->format('D, d M Y H:i:s \G\M\T'), + 'Host' => parse_url($url, PHP_URL_HOST), + 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, application/json', + 'User-Agent' => 'GNU social ActivityPub Plugin - ' . GNUSOCIAL_ENGINE_URL, + 'Content-Type' => 'application/activity+json' + ]; + + if ($digest) { + $headers['Digest'] = 'SHA-256=' . $digest; + } + + return $headers; + } + + /** + * @param array $headers + * @return string + */ + private static function _headersToSigningString(array $headers): string + { + return implode("\n", array_map(function ($k, $v) { + return strtolower($k) . ': ' . $v; + }, array_keys($headers), $headers)); + } + + /** + * @param string $signature + * @return array + */ + public static function parseSignatureHeader(string $signature): array + { + $parts = explode(',', $signature); + $signatureData = []; + + foreach ($parts as $part) { + if (preg_match('/(.+)="(.+)"/', $part, $match)) { + $signatureData[$match[1]] = $match[2]; + } + } + + if (!isset($signatureData['keyId'])) { + return [ + 'error' => 'No keyId was found in the signature header. Found: ' . implode(', ', array_keys($signatureData)) + ]; + } + + if (!filter_var($signatureData['keyId'], FILTER_VALIDATE_URL)) { + return [ + 'error' => 'keyId is not a URL: ' . $signatureData['keyId'] + ]; + } + + if (!isset($signatureData['headers']) || !isset($signatureData['signature'])) { + return [ + 'error' => 'Signature is missing headers or signature parts' + ]; + } + + return $signatureData; + } + + /** + * @param string $publicKey + * @param array $signatureData + * @param array $inputHeaders + * @param string $path + * @param string $body + * @return array + */ + public static function verify(string $publicKey, array $signatureData, array $inputHeaders, string $path, string $body): array + { + // We need this because the used Request headers fields specified by Signature are in lower case. + $headersContent = array_change_key_case($inputHeaders, CASE_LOWER); + $digest = 'SHA-256=' . base64_encode(hash('sha256', $body, true)); + $headersToSign = []; + foreach (explode(' ', $signatureData['headers']) as $h) { + if ($h == '(request-target)') { + $headersToSign[$h] = 'post ' . $path; + } elseif ($h == 'digest') { + $headersToSign[$h] = $digest; + } elseif (array_key_exists($h, $headersContent)) { + $headersToSign[$h] = $headersContent[$h]; + } + } + $signingString = self::_headersToSigningString($headersToSign); + + $verified = openssl_verify($signingString, base64_decode($signatureData['signature']), $publicKey, OPENSSL_ALGO_SHA256); + + return [$verified, $signingString]; + } +} diff --git a/plugins/ActivityPub/Util/Model/EntityToType/ActivityToType.php b/plugins/ActivityPub/Util/Model/EntityToType/ActivityToType.php index bc9f249c66..33ef5f02e4 100644 --- a/plugins/ActivityPub/Util/Model/EntityToType/ActivityToType.php +++ b/plugins/ActivityPub/Util/Model/EntityToType/ActivityToType.php @@ -31,7 +31,7 @@ class ActivityToType '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => Router::url('activity_view', ['id' => $activity->getId()], Router::ABSOLUTE_URL), 'published' => $activity->getCreated()->format(DateTimeInterface::RFC3339), - 'attributedTo' => $activity->getActor()->getUri(Router::ABSOLUTE_URL), + 'actor' => $activity->getActor()->getUri(Router::ABSOLUTE_URL), //'to' => $to, //'cc' => $cc, 'object' => $activity->getObject()->getUrl(), diff --git a/src/Core/DB/DB.php b/src/Core/DB/DB.php index c71da70438..e07956fd6b 100644 --- a/src/Core/DB/DB.php +++ b/src/Core/DB/DB.php @@ -57,6 +57,7 @@ use Functional as F; * @method static void persist(object $entity) // Tells the EntityManager to make an instance managed and persistent. * @method static bool contains(object $entity) // Determines whether an entity instance is managed in this EntityManager. * @method static void flush() // Flushes the in-memory state of persisted objects to the database. + * @method mixed wrapInTransaction(callable $func) // Executes a function in a transaction. */ class DB { diff --git a/src/Core/Router/Router.php b/src/Core/Router/Router.php index 53742c420c..59104d836d 100644 --- a/src/Core/Router/Router.php +++ b/src/Core/Router/Router.php @@ -32,6 +32,7 @@ declare(strict_types = 1); namespace App\Core\Router; +use App\Core\Log; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Router as SymfonyRouter; @@ -82,6 +83,9 @@ abstract class Router */ public static function url(string $id, array $args = [], int $type = self::ABSOLUTE_PATH): string { + if ($type === self::RELATIVE_PATH) { + Log::debug('Requested relative path which is not an absolute path... just saying...'); + } return self::$router->generate($id, $args, $type); } diff --git a/src/Entity/Note.php b/src/Entity/Note.php index 66f5723b79..e26493cd71 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -302,6 +302,7 @@ class Note extends Entity public function getNotificationTargets(array $ids_already_known = []): array { + $rendered = null; $mentions = []; Event::handle('RenderNoteContent', [$this->getContent(), $this->getContentType(), &$rendered, &$mentions, $this->getActor(), Language::getFromId($this->getLanguageId())->getLocale()]); $mentioned = []; diff --git a/src/Util/Formatting.php b/src/Util/Formatting.php index 9c6cece44e..b6df0be69d 100644 --- a/src/Util/Formatting.php +++ b/src/Util/Formatting.php @@ -410,7 +410,7 @@ abstract class Formatting * @param string $text partially-rendered HTML * @param Actor $author the Actor that is composing the current notice * - * @return string partially-rendered HTML + * @return array [partially-rendered HTML, array of mentions] */ public static function linkifyMentions(string $text, Actor $author, string $language): array {