major structure rework

- checkSignature for our fqdn now happens in the connector
- cache public key for 1 hour
- change author on some files
- refactored outbox to api/v1/newcontent (CN->Federator communicates to here)
- disabled post for outbox (as that should go to v1/newcontent)
- initial support for receiving votes from CN, not fully working/clean yet
This commit is contained in:
Yannis Vogel 2025-04-30 22:44:50 +02:00
parent 10a3b1e0f9
commit 8ea9bdcf9a
No known key found for this signature in database
16 changed files with 500 additions and 205 deletions

View file

@ -21,7 +21,5 @@ username = 'federatoradmin'
password = '*change*me*as*well' password = '*change*me*as*well'
[keys] [keys]
headerSenderName = 'contentnation'
contentnationPublicKeyPath = '../contentnation.pub'
federatorPrivateKeyPath = '../federator.key' federatorPrivateKeyPath = '../federator.key'
federatorPublicKeyPath = '../federator.pub' federatorPublicKeyPath = '../federator.pub'

View file

@ -3,4 +3,8 @@ service-uri = http://local.contentnation.net
[userdata] [userdata]
path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here
url = 'https://userdata.contentnation.net' url = 'https://userdata.contentnation.net'
[keys]
headerSenderName = 'contentnation'
publicKeyPath = '../contentnation.pub'

View file

@ -107,6 +107,9 @@ class Api extends Main
case 'dummy': case 'dummy':
$handler = new Api\V1\Dummy($this); $handler = new Api\V1\Dummy($this);
break; break;
case 'newcontent':
$handler = new Api\V1\NewContent($this);
break;
} }
break; break;
} }
@ -201,6 +204,15 @@ class Api extends Main
*/ */
public function checkSignature($headers) 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; $signatureHeader = $headers['Signature'] ?? null;
if (!isset($signatureHeader)) { if (!isset($signatureHeader)) {
@ -213,12 +225,11 @@ class Api extends Main
$signature = base64_decode($signatureParts['signature']); $signature = base64_decode($signatureParts['signature']);
$signedHeaders = explode(' ', $signatureParts['headers']); $signedHeaders = explode(' ', $signatureParts['headers']);
if (isset($headers['X-Sender']) && $headers['X-Sender'] === $this->config['keys']['headerSenderName']) { $keyId = $signatureParts['keyId'];
$pKeyPath = $_SERVER['DOCUMENT_ROOT'] . $this->config['keys']['contentnationPublicKeyPath'];
$publicKeyPem = file_get_contents($pKeyPath);
} else {
$keyId = $signatureParts['keyId'];
$publicKeyPem = $this->cache->getPublicKey($keyId);
if (!isset($publicKeyPem) || $publicKeyPem === false) {
// Fetch public key from `keyId` (usually actor URL + #main-key) // Fetch public key from `keyId` (usually actor URL + #main-key)
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']); [$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
@ -233,11 +244,14 @@ class Api extends Main
} }
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null; $publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
}
if (!isset($publicKeyPem) || $publicKeyPem === false) { if (!isset($publicKeyPem) || $publicKeyPem === false) {
http_response_code(500); http_response_code(500);
throw new Exceptions\PermissionDenied("Public key couldn't be determined"); 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 // Reconstruct the signed string
@ -264,7 +278,7 @@ class Api extends Main
} }
if ($verified != 1) { if ($verified != 1) {
http_response_code(500); http_response_code(500);
throw new Exceptions\PermissionDenied("Signature verification failed for publicKey"); throw new Exceptions\PermissionDenied("Signature verification failed");
} }
// Signature is valid! // Signature is valid!

View file

@ -3,7 +3,7 @@
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* @author Sascha Nitsch (grumpydeveloper) * @author Yannis Vogel (vogeldevelopment)
**/ **/
namespace Federator\Api\FedUsers; namespace Federator\Api\FedUsers;

View file

@ -90,182 +90,11 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
/** /**
* handle post call * 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 * @return string|false response
*/ */
public function post($_user) public function post($_user)
{ {
$_rawInput = file_get_contents('php://input'); return false;
$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;
} }
} }

View file

@ -0,0 +1,262 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Api\V1;
/**
* Called from our application to inform us about new content (f.e. new posts on contentnation.net)
*/
class NewContent implements \Federator\Api\APIInterface
{
/**
* main instance
*
* @var \Federator\Api $main
*/
private $main;
/**
* response from sub-calls
*
* @var string $response
*/
private $response;
/**
* constructor
*
* @param \Federator\Main $main main instance
* @return void
*/
public function __construct($main)
{
$this->main = $main;
}
/**
* run given url path
*
* @param array<string> $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;
}
}

View file

@ -57,4 +57,21 @@ interface Cache extends \Federator\Connector\Connector
* @return void * @return void
*/ */
public function saveRemoteUserBySession($_session, $_user, $user); 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);
} }

View file

@ -64,4 +64,13 @@ interface Connector
* @return \Federator\Data\ActivityPub\Common\Activity|false * @return \Federator\Data\ActivityPub\Common\Activity|false
*/ */
public function jsonToActivity(array $jsonData); 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);
} }

View file

@ -3,7 +3,7 @@
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* @author Sascha Nitsch (grumpydeveloper) * @author Yannis Vogel (vogeldevelopment)
**/ **/
namespace Federator\Data\ActivityPub\Common; namespace Federator\Data\ActivityPub\Common;

View file

@ -3,7 +3,7 @@
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* @author Sascha Nitsch (grumpydeveloper) * @author Yannis Vogel (vogeldevelopment)
**/ **/
namespace Federator\Data\ActivityPub\Common; namespace Federator\Data\ActivityPub\Common;

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* @author Sascha Nitsch (grumpydeveloper) * @author Sascha Nitsch (grumpydeveloper)
* @author Yannis Vogel (vogeldevelopment)
**/ **/
namespace Federator\Data\ActivityPub\Common; namespace Federator\Data\ActivityPub\Common;

View file

@ -3,7 +3,7 @@
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop * SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* @author Sascha Nitsch (grumpydeveloper) * @author Yannis Vogel (vogeldevelopment)
**/ **/
namespace Federator\DIO; namespace Federator\DIO;

View file

@ -338,30 +338,128 @@ class ContentNation implements Connector
*/ */
public function jsonToActivity(array $jsonData) public function jsonToActivity(array $jsonData)
{ {
// Common fields for all activity types
$ap = [ $ap = [
'@context' => 'https://www.w3.org/ns/activitystreams', '@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create', 'type' => 'Create', // Default to 'Create'
'id' => $jsonData['id'] ?? null, 'id' => $jsonData['id'] ?? null,
'actor' => $jsonData['actor']['id'] ?? null, 'actor' => $jsonData['actor'] ?? null,
'published' => $jsonData['object']['published'] ?? null, 'published' => $jsonData['object']['published'] ?? null,
'to' => ['https://www.w3.org/ns/activitystreams#Public'], 'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [$jsonData['related']['cc']['followers'] ?? null], '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); 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; namespace Federator;

View file

@ -95,6 +95,18 @@ class DummyConnector implements Connector
$user->session = $_session; $user->session = $_session;
return $user; 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; namespace Federator;

View file

@ -41,6 +41,13 @@ class RedisCache implements Cache
*/ */
private $userTTL; private $userTTL;
/**
* public key cache time to live in secods
*
* @var int $publicKeyPemTTL
*/
private $publicKeyPemTTL;
/** /**
* constructor * constructor
*/ */
@ -50,6 +57,7 @@ class RedisCache implements Cache
if ($config !== false) { if ($config !== false) {
$this->config = $config; $this->config = $config;
$this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60; $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; 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 * save remote followers by user
* *
@ -243,6 +266,34 @@ class RedisCache implements Cache
$serialized = $user->toJson(); $serialized = $user->toJson();
$this->redis->setEx($key, $this->userTTL, $serialized,); $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; namespace Federator;

View file

@ -2,6 +2,6 @@
host = localhost host = localhost
port = 6379 port = 6379
username = federator username = federator
password = redis*change*password
userttl = 10 userttl = 10
publickeypemttl = 3600
statsttl = 60 statsttl = 60