diff --git a/config.ini b/config.ini index 5ade62e..9e2e5c0 100644 --- a/config.ini +++ b/config.ini @@ -21,7 +21,5 @@ username = 'federatoradmin' password = '*change*me*as*well' [keys] -headerSenderName = 'contentnation' -contentnationPublicKeyPath = '../contentnation.pub' federatorPrivateKeyPath = '../federator.key' federatorPublicKeyPath = '../federator.pub' \ No newline at end of file diff --git a/contentnation.ini b/contentnation.ini index 6f447fd..9f56c3f 100644 --- a/contentnation.ini +++ b/contentnation.ini @@ -3,4 +3,8 @@ service-uri = http://local.contentnation.net [userdata] path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here -url = 'https://userdata.contentnation.net' \ No newline at end of file +url = 'https://userdata.contentnation.net' + +[keys] +headerSenderName = 'contentnation' +publicKeyPath = '../contentnation.pub' \ No newline at end of file diff --git a/php/federator/api.php b/php/federator/api.php index 92566c9..6f901a1 100644 --- a/php/federator/api.php +++ b/php/federator/api.php @@ -107,6 +107,9 @@ class Api extends Main case 'dummy': $handler = new Api\V1\Dummy($this); break; + case 'newcontent': + $handler = new Api\V1\NewContent($this); + break; } break; } @@ -201,6 +204,15 @@ class Api extends Main */ public function checkSignature($headers) { + if (isset($headers['X-Sender'])) { + try { + return $this->connector->checkSignature($headers); + } catch (Exceptions\PermissionDenied $e) { + http_response_code(500); + throw $e; + } + } + $signatureHeader = $headers['Signature'] ?? null; if (!isset($signatureHeader)) { @@ -213,12 +225,11 @@ class Api extends Main $signature = base64_decode($signatureParts['signature']); $signedHeaders = explode(' ', $signatureParts['headers']); - if (isset($headers['X-Sender']) && $headers['X-Sender'] === $this->config['keys']['headerSenderName']) { - $pKeyPath = $_SERVER['DOCUMENT_ROOT'] . $this->config['keys']['contentnationPublicKeyPath']; - $publicKeyPem = file_get_contents($pKeyPath); - } else { - $keyId = $signatureParts['keyId']; + $keyId = $signatureParts['keyId']; + $publicKeyPem = $this->cache->getPublicKey($keyId); + + if (!isset($publicKeyPem) || $publicKeyPem === false) { // Fetch public key from `keyId` (usually actor URL + #main-key) [$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']); @@ -233,11 +244,14 @@ class Api extends Main } $publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null; - } - if (!isset($publicKeyPem) || $publicKeyPem === false) { - http_response_code(500); - throw new Exceptions\PermissionDenied("Public key couldn't be determined"); + if (!isset($publicKeyPem) || $publicKeyPem === false) { + http_response_code(500); + throw new Exceptions\PermissionDenied("Public key couldn't be determined"); + } + + // Cache the public key for 1 hour + $this->cache->savePublicKey($keyId, $publicKeyPem); } // Reconstruct the signed string @@ -264,7 +278,7 @@ class Api extends Main } if ($verified != 1) { http_response_code(500); - throw new Exceptions\PermissionDenied("Signature verification failed for publicKey"); + throw new Exceptions\PermissionDenied("Signature verification failed"); } // Signature is valid! diff --git a/php/federator/api/fedusers/inbox.php b/php/federator/api/fedusers/inbox.php index 3d9a28a..627cf30 100644 --- a/php/federator/api/fedusers/inbox.php +++ b/php/federator/api/fedusers/inbox.php @@ -3,7 +3,7 @@ * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-License-Identifier: GPL-3.0-or-later * - * @author Sascha Nitsch (grumpydeveloper) + * @author Yannis Vogel (vogeldevelopment) **/ namespace Federator\Api\FedUsers; diff --git a/php/federator/api/fedusers/outbox.php b/php/federator/api/fedusers/outbox.php index 7bd7450..2dc4458 100644 --- a/php/federator/api/fedusers/outbox.php +++ b/php/federator/api/fedusers/outbox.php @@ -90,182 +90,11 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface /** * handle post call * - * @param string|null $_user user to add data to outbox + * @param string|null $_user user to add data to outbox @unused-param * @return string|false response */ public function post($_user) { - $_rawInput = file_get_contents('php://input'); - - $allHeaders = getallheaders(); - try { - $this->main->checkSignature($allHeaders); - } catch (\Federator\Exceptions\PermissionDenied $e) { - error_log("Outbox::post Signature check failed: " . $e->getMessage()); - http_response_code(401); - return false; - } - - $input = is_string($_rawInput) ? json_decode($_rawInput, true) : null; - $host = $_SERVER['SERVER_NAME']; - if (!is_array($input)) { - error_log("Outbox::post Input wasn't of type array"); - return false; - } - - if (isset($allHeaders['X-Sender']) && $allHeaders['X-Sender'] === $this->main->getConfig()['keys']['headerSenderName']) { - $outboxActivity = $this->main->getConnector()->jsonToActivity($input); - } else { - $outboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($input); - } - - if ($outboxActivity === false) { - error_log("Outbox::post couldn't create outboxActivity"); - return false; - } - - $sendTo = $outboxActivity->getCC(); - if ($outboxActivity->getType() === 'Undo') { - $object = $outboxActivity->getObject(); - if ($object !== null) { - $sendTo = $object->getCC(); - } - } - - $users = []; - - foreach ($sendTo as $receiver) { - if ($receiver === '' || !is_string($receiver)) { - continue; - } - - if (str_ends_with($receiver, '/followers')) { - $followers = $this->fetchAllFollowers($receiver, $host); - if (is_array($followers)) { - $users = array_merge($users, $followers); - } - } - } - - if (empty($users)) { // todo remove, debugging for now - $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; - // Save the raw input and parsed JSON to a file for inspection - file_put_contents( - $rootDir . 'logs/outbox.log', - date('Y-m-d H:i:s') . ": ==== POST Outbox Activity ====\n" . json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", - FILE_APPEND - ); - } - if ($_user !== false && !in_array($_user, $users, true)) { - $users[] = $_user; - } - - foreach ($users as $user) { - if (!isset($user)) { - continue; - } - - $this->postForUser($user, $outboxActivity); - } - - return json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); - } - - /** - * handle post call for specific user - * - * @param string $_user user to add data to outbox - * @param \Federator\Data\ActivityPub\Common\Activity $outboxActivity the activity that we received - * @return boolean response - */ - private function postForUser($_user, $outboxActivity) - { - if (isset($_user)) { - $dbh = $this->main->getDatabase(); - $cache = $this->main->getCache(); - $connector = $this->main->getConnector(); - - // get user - $user = \Federator\DIO\User::getUserByName( - $dbh, - $_user, - $connector, - $cache - ); - if ($user->id === null) { - error_log("Outbox::postForUser couldn't find user: $_user"); - return false; - } - } - - $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; - // Save the raw input and parsed JSON to a file for inspection - file_put_contents( - $rootDir . 'logs/outbox_' . $_user . '.log', - date('Y-m-d H:i:s') . ": ==== POST " . $_user . " Outbox Activity ====\n" . json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", - FILE_APPEND - ); - - return true; - } - - /** - * fetch all followers from url and return the ones that belong to our server - * - * @param string $collectionUrl The url of f.e. the posters followers - * @param string $host our current host-url - * @return string[] the names of the followers that are hosted on our server - */ - private static function fetchAllFollowers(string $collectionUrl, string $host): array - { - $users = []; - - [$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']); - if ($collectionInfo['http_code'] != 200) { - error_log("Outbox::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl"); - return []; - } - - $collectionData = json_decode($collectionResponse, true); - $nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null; - - if (!isset($nextPage)) { - error_log("Outbox::fetchAllFollowers No 'first' or 'current' page in collection at $collectionUrl"); - return []; - } - - // Loop through all pages - while ($nextPage) { - [$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($nextPage, ['Accept: application/activity+json']); - if ($pageInfo['http_code'] != 200) { - error_log("Outbox::fetchAllFollowers Failed to fetch follower page at $nextPage"); - break; - } - - $pageData = json_decode($pageResponse, true); - $items = $pageData['orderedItems'] ?? $pageData['items'] ?? []; - - foreach ($items as $followerUrl) { - $parts = parse_url($followerUrl); - if (!isset($parts['host']) || !str_ends_with($parts['host'], $host)) { - continue; - } - - [$actorResponse, $actorInfo] = \Federator\Main::getFromRemote($followerUrl, ['Accept: application/activity+json']); - if ($actorInfo['http_code'] != 200) { - error_log("Outbox::fetchAllFollowers Failed to fetch actor data for follower: $followerUrl"); - continue; - } - - $actorData = json_decode($actorResponse, true); - if (isset($actorData['preferredUsername'])) { - $users[] = $actorData['preferredUsername']; - } - } - - $nextPage = $pageData['next'] ?? null; - } - - return $users; + return false; } } diff --git a/php/federator/api/v1/newcontent.php b/php/federator/api/v1/newcontent.php new file mode 100644 index 0000000..16a064f --- /dev/null +++ b/php/federator/api/v1/newcontent.php @@ -0,0 +1,262 @@ +main = $main; + } + + /** + * run given url path + * + * @param array $paths path array split by / + * @param \Federator\Data\User|false $user user who is calling us @unused-param + * @return bool true on success + */ + public function exec($paths, $user) + { + $method = $_SERVER["REQUEST_METHOD"]; + $_username = $paths[2]; + if ($method === 'GET') { // unsupported + throw new \Federator\Exceptions\InvalidArgument("GET not supported"); + } + switch (sizeof($paths)) { + case 3: + $ret = $this->post($_username); + break; + } + + if (isset($ret) && $ret !== false) { + $this->response = $ret; + return true; + } + + $this->main->setResponseCode(404); + return false; + } + + + /** + * handle post call + * + * @param string|null $_user user that triggered the post + * @return string|false response + */ + public function post($_user) + { + error_log("NewContent::post called with user: $_user"); + $_rawInput = file_get_contents('php://input'); + + $allHeaders = getallheaders(); + try { + $this->main->checkSignature($allHeaders); + } catch (\Federator\Exceptions\PermissionDenied $e) { + error_log("NewContent::post Signature check failed: " . $e->getMessage()); + http_response_code(401); + return false; + } + + $input = is_string($_rawInput) ? json_decode($_rawInput, true) : null; + $host = $_SERVER['SERVER_NAME']; + if (!is_array($input)) { + error_log("NewContent::post Input wasn't of type array"); + return false; + } + + if (isset($allHeaders['X-Sender'])) { + $newActivity = $this->main->getConnector()->jsonToActivity($input); + } else { + $newActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($input); + } + + if ($newActivity === false) { + error_log("NewContent::post couldn't create newActivity"); + return false; + } + + $sendTo = $newActivity->getCC(); + if ($newActivity->getType() === 'Undo') { + $object = $newActivity->getObject(); + if ($object !== null) { + $sendTo = $object->getCC(); + } + } + + $users = []; + + foreach ($sendTo as $receiver) { + if ($receiver === '' || !is_string($receiver)) { + continue; + } + + if (str_ends_with($receiver, '/followers')) { + $followers = $this->fetchAllFollowers($receiver, $host); + if (is_array($followers)) { + $users = array_merge($users, $followers); + } + } + } + + if (empty($users)) { // todo remove, debugging for now + $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; + // Save the raw input and parsed JSON to a file for inspection + file_put_contents( + $rootDir . 'logs/newContent.log', + date('Y-m-d H:i:s') . ": ==== POST NewContent Activity ====\n" . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", + FILE_APPEND + ); + } + if ($_user !== false && !in_array($_user, $users, true)) { + $users[] = $_user; + } + + foreach ($users as $user) { + if (!isset($user)) { + continue; + } + + $this->postForUser($user, $newActivity); + } + + return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + /** + * handle post call for specific user + * + * @param string $_user user that triggered the post + * @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received + * @return boolean response + */ + private function postForUser($_user, $newActivity) + { + if (isset($_user)) { + $dbh = $this->main->getDatabase(); + $cache = $this->main->getCache(); + $connector = $this->main->getConnector(); + + // get user + $user = \Federator\DIO\User::getUserByName( + $dbh, + $_user, + $connector, + $cache + ); + if ($user->id === null) { + error_log("NewContent::postForUser couldn't find user: $_user"); + return false; + } + } + + $rootDir = $_SERVER['DOCUMENT_ROOT'] . '../'; + // Save the raw input and parsed JSON to a file for inspection + file_put_contents( + $rootDir . 'logs/newcontent_' . $_user . '.log', + date('Y-m-d H:i:s') . ": ==== POST " . $_user . " NewContent Activity ====\n" . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n", + FILE_APPEND + ); + + return true; + } + + /** + * fetch all followers from url and return the ones that belong to our server + * + * @param string $collectionUrl The url of f.e. the posters followers + * @param string $host our current host-url + * @return string[] the names of the followers that are hosted on our server + */ + private static function fetchAllFollowers(string $collectionUrl, string $host): array + { + $users = []; + + [$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']); + if ($collectionInfo['http_code'] != 200) { + error_log("NewContent::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl"); + return []; + } + + $collectionData = json_decode($collectionResponse, true); + $nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null; + + if (!isset($nextPage)) { + error_log("NewContent::fetchAllFollowers No 'first' or 'current' page in collection at $collectionUrl"); + return []; + } + + // Loop through all pages + while ($nextPage) { + [$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($nextPage, ['Accept: application/activity+json']); + if ($pageInfo['http_code'] != 200) { + error_log("NewContent::fetchAllFollowers Failed to fetch follower page at $nextPage"); + break; + } + + $pageData = json_decode($pageResponse, true); + $items = $pageData['orderedItems'] ?? $pageData['items'] ?? []; + + foreach ($items as $followerUrl) { + $parts = parse_url($followerUrl); + if (!isset($parts['host']) || !str_ends_with($parts['host'], $host)) { + continue; + } + + [$actorResponse, $actorInfo] = \Federator\Main::getFromRemote($followerUrl, ['Accept: application/activity+json']); + if ($actorInfo['http_code'] != 200) { + error_log("NewContent::fetchAllFollowers Failed to fetch actor data for follower: $followerUrl"); + continue; + } + + $actorData = json_decode($actorResponse, true); + if (isset($actorData['preferredUsername'])) { + $users[] = $actorData['preferredUsername']; + } + } + + $nextPage = $pageData['next'] ?? null; + } + + return $users; + } + + /** + * get internal represenation as json string + * @return string json string or html + */ + public function toJson() + { + return $this->response; + } +} \ No newline at end of file diff --git a/php/federator/cache/cache.php b/php/federator/cache/cache.php index 2d3b638..5f3878d 100644 --- a/php/federator/cache/cache.php +++ b/php/federator/cache/cache.php @@ -57,4 +57,21 @@ interface Cache extends \Federator\Connector\Connector * @return void */ public function saveRemoteUserBySession($_session, $_user, $user); + + /** + * Save the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @param string $publicKeyPem The public key PEM to cache + * @return void + */ + public function savePublicKey(string $keyId, string $publicKeyPem); + + /** + * Retrieve the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @return string|false The cached public key PEM or false if not found + */ + public function getPublicKey(string $keyId); } diff --git a/php/federator/connector/connector.php b/php/federator/connector/connector.php index abee7eb..3a49c5a 100644 --- a/php/federator/connector/connector.php +++ b/php/federator/connector/connector.php @@ -64,4 +64,13 @@ interface Connector * @return \Federator\Data\ActivityPub\Common\Activity|false */ public function jsonToActivity(array $jsonData); + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers); } diff --git a/php/federator/data/activitypub/common/Announce.php b/php/federator/data/activitypub/common/Announce.php index 4f18004..d50a932 100644 --- a/php/federator/data/activitypub/common/Announce.php +++ b/php/federator/data/activitypub/common/Announce.php @@ -3,7 +3,7 @@ * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-License-Identifier: GPL-3.0-or-later * - * @author Sascha Nitsch (grumpydeveloper) + * @author Yannis Vogel (vogeldevelopment) **/ namespace Federator\Data\ActivityPub\Common; diff --git a/php/federator/data/activitypub/common/Undo.php b/php/federator/data/activitypub/common/Undo.php index b4732d1..28ec50b 100644 --- a/php/federator/data/activitypub/common/Undo.php +++ b/php/federator/data/activitypub/common/Undo.php @@ -3,7 +3,7 @@ * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-License-Identifier: GPL-3.0-or-later * - * @author Sascha Nitsch (grumpydeveloper) + * @author Yannis Vogel (vogeldevelopment) **/ namespace Federator\Data\ActivityPub\Common; diff --git a/php/federator/data/activitypub/common/outbox.php b/php/federator/data/activitypub/common/outbox.php index 4439ebe..4519a8a 100644 --- a/php/federator/data/activitypub/common/outbox.php +++ b/php/federator/data/activitypub/common/outbox.php @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * @author Sascha Nitsch (grumpydeveloper) + * @author Yannis Vogel (vogeldevelopment) **/ namespace Federator\Data\ActivityPub\Common; diff --git a/php/federator/dio/followers.php b/php/federator/dio/followers.php index b1dfbfc..5972b1b 100644 --- a/php/federator/dio/followers.php +++ b/php/federator/dio/followers.php @@ -3,7 +3,7 @@ * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-License-Identifier: GPL-3.0-or-later * - * @author Sascha Nitsch (grumpydeveloper) + * @author Yannis Vogel (vogeldevelopment) **/ namespace Federator\DIO; diff --git a/plugins/federator/contentnation.php b/plugins/federator/contentnation.php index 6afe12e..8775777 100644 --- a/plugins/federator/contentnation.php +++ b/plugins/federator/contentnation.php @@ -338,30 +338,128 @@ class ContentNation implements Connector */ public function jsonToActivity(array $jsonData) { + // Common fields for all activity types $ap = [ '@context' => 'https://www.w3.org/ns/activitystreams', - 'type' => 'Create', + 'type' => 'Create', // Default to 'Create' 'id' => $jsonData['id'] ?? null, - 'actor' => $jsonData['actor']['id'] ?? null, + 'actor' => $jsonData['actor'] ?? null, 'published' => $jsonData['object']['published'] ?? null, 'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'cc' => [$jsonData['related']['cc']['followers'] ?? null], - 'object' => [ - 'type' => 'Note', - 'id' => $jsonData['object']['id'] ?? null, - 'summary' => $jsonData['object']['summary'] ?? '', - 'content' => $jsonData['object']['content'] ?? '', - 'published' => $jsonData['object']['published'] ?? null, - 'attributedTo' => $jsonData['actor']['id'] ?? null, - 'to' => ['https://www.w3.org/ns/activitystreams#Public'], - 'cc' => [$jsonData['related']['cc']['followers'] ?? null], - 'url' => $jsonData['object']['url'] ?? null, - 'inReplyTo' => $jsonData['related']['article']['id'] ?? null, - ], ]; + // Handle specific fields based on the type + switch ($jsonData['type']) { + case 'comment': + $ap['object'] = [ + 'type' => 'Note', + 'id' => $jsonData['object']['id'] ?? null, + 'summary' => $jsonData['object']['summary'] ?? '', + 'content' => $jsonData['object']['content'] ?? '', + 'published' => $jsonData['object']['published'] ?? null, + 'attributedTo' => $jsonData['actor']['id'] ?? null, + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'cc' => [$jsonData['related']['cc']['followers'] ?? null], + 'url' => $jsonData['object']['url'] ?? null, + 'inReplyTo' => $jsonData['related']['article']['id'] ?? null, + ]; + break; + + // todo fix this to properly handle votes, data is mocked for now + case 'vote': + // Handle voting on a comment or an article + if ($jsonData['object']['type'] === 'Comment') { + $jsonData['object']['type'] = 'Note'; + } + $ap['object'] = [ + 'id' => $jsonData['object']['id'] ?? null, + 'type' => $jsonData['object']['type'] ?? 'Article', + ]; + + // Include additional fields if voting on an article + if ($ap['object']['type'] === 'Article') { + $ap['object']['name'] = $jsonData['object']['name'] ?? null; + $ap['object']['author'] = $jsonData['object']['author'] ?? null; + } + + // Add vote-specific fields + $ap['vote'] = [ + 'value' => $jsonData['vote']['value'] ?? null, + 'type' => $jsonData['vote']['type'] ?? null, + ]; + break; + + default: + // Handle unsupported types or fallback to default behavior + throw new \InvalidArgumentException("Unsupported activity type: {$jsonData['type']}"); + } + return \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap); } + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + $signatureHeader = $headers['Signature'] ?? null; + + if (!isset($signatureHeader)) { + throw new \Federator\Exceptions\PermissionDenied("Missing Signature header"); + } + + if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName']) + { + throw new \Federator\Exceptions\PermissionDenied("Invalid sender name"); + } + + // Parse Signature header + preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches); + $signatureParts = array_combine($matches[1], $matches[2]); + + $signature = base64_decode($signatureParts['signature']); + $signedHeaders = explode(' ', $signatureParts['headers']); + + $pKeyPath = $_SERVER['DOCUMENT_ROOT'] . $this->config['keys']['publicKeyPath']; + $publicKeyPem = file_get_contents($pKeyPath); + if ($publicKeyPem === false) { + http_response_code(500); + throw new \Federator\Exceptions\PermissionDenied("Public key couldn't be determined"); + } + + // Reconstruct the signed string + $signedString = ''; + foreach ($signedHeaders as $header) { + if ($header === '(request-target)') { + $method = strtolower($_SERVER['REQUEST_METHOD']); + $path = $_SERVER['REQUEST_URI']; + $headerValue = "$method $path"; + } else { + $headerValue = $headers[ucwords($header, '-')] ?? ''; + } + + $signedString .= strtolower($header) . ": " . $headerValue . "\n"; + } + + $signedString = rtrim($signedString); + + // Verify the signature + $pubkeyRes = openssl_pkey_get_public($publicKeyPem); + $verified = false; + if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) { + $verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256); + } + if ($verified != 1) { + http_response_code(500); + throw new \Federator\Exceptions\PermissionDenied("Signature verification failed"); + } + return "Signature verified."; + } } namespace Federator; diff --git a/plugins/federator/dummyconnector.php b/plugins/federator/dummyconnector.php index c621baf..2871f70 100644 --- a/plugins/federator/dummyconnector.php +++ b/plugins/federator/dummyconnector.php @@ -95,6 +95,18 @@ class DummyConnector implements Connector $user->session = $_session; return $user; } + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers @unused-param + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + return new \Federator\Exceptions\PermissionDenied("Dummy connector: no signature check"); + } } namespace Federator; diff --git a/plugins/federator/rediscache.php b/plugins/federator/rediscache.php index 0acccab..6768e2a 100644 --- a/plugins/federator/rediscache.php +++ b/plugins/federator/rediscache.php @@ -41,6 +41,13 @@ class RedisCache implements Cache */ private $userTTL; + /** + * public key cache time to live in secods + * + * @var int $publicKeyPemTTL + */ + private $publicKeyPemTTL; + /** * constructor */ @@ -50,6 +57,7 @@ class RedisCache implements Cache if ($config !== false) { $this->config = $config; $this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60; + $this->publicKeyPemTTL = array_key_exists('publickeypemttl', $config) ? intval($config['publickeypemttl'], 10) : 3600; } } @@ -176,6 +184,21 @@ class RedisCache implements Cache return $user; } + /** + * Retrieve the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @return string|false The cached public key PEM or false if not found + */ + public function getPublicKey(string $keyId) + { + if (!$this->connected) { + $this->connect(); + } + $key = self::createKey('publickey', $keyId); + return $this->redis->get($key); + } + /** * save remote followers by user * @@ -243,6 +266,34 @@ class RedisCache implements Cache $serialized = $user->toJson(); $this->redis->setEx($key, $this->userTTL, $serialized,); } + + /** + * Save the public key for a given keyId + * + * @param string $keyId The keyId (e.g., actor URL + #main-key) + * @param string $publicKeyPem The public key PEM to cache + * @return void + */ + public function savePublicKey(string $keyId, string $publicKeyPem) + { + if (!$this->connected) { + $this->connect(); + } + $key = self::createKey('publickey', $keyId); + $this->redis->setEx($key, $this->publicKeyPemTTL, $publicKeyPem); // TTL = 1 hour + } + + /** + * check if the headers include a valid signature + * + * @param string[] $headers the headers @unused-param + * @throws \Federator\Exceptions\PermissionDenied + * @return string|\Federator\Exceptions\PermissionDenied + */ + public function checkSignature($headers) + { + return new \Federator\Exceptions\PermissionDenied("RedisCache: no signature check"); + } } namespace Federator; diff --git a/rediscache.ini b/rediscache.ini index 5d55955..1d97d0c 100644 --- a/rediscache.ini +++ b/rediscache.ini @@ -2,6 +2,6 @@ host = localhost port = 6379 username = federator -password = redis*change*password userttl = 10 +publickeypemttl = 3600 statsttl = 60