added proper application-signing

- we now check whether the request was sent from contentnation and approve the request with contentnations pub key
- fix issue where inbox wasn't gotten as it also got a username
- rough initial draft of parsing of connector-specific data (plugins/federator/contentnation::jsonToActivity)
- added key paths and headername to config
This commit is contained in:
Yannis Vogel 2025-04-23 21:07:26 +02:00
parent 0edc4c0db6
commit 352377887c
No known key found for this signature in database
10 changed files with 147 additions and 29 deletions

View file

@ -13,10 +13,15 @@ compiledir = '../cache'
[plugins] [plugins]
rediscache = 'rediscache.php' rediscache = 'rediscache.php'
dummy = 'dummyconnector.php' # dummy = 'dummyconnector.php'
contentnation = 'contentnation.php' contentnation = 'contentnation.php'
mastodon = 'mastodon.php'
[maintenance] [maintenance]
username = 'federatoradmin' username = 'federatoradmin'
password = '*change*me*as*well' password = '*change*me*as*well'
[keys]
headerSenderName = 'contentnation'
contentnationPublicKeyPath = '../contentnation.pub'
federatorPrivateKeyPath = '../federator.key'
federatorPublicKeyPath = '../federator.pub'

14
contentnation.pub Normal file
View file

@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArTNHQL76ZuM7meWvtfHC
DOAivi9D4m2u3JdgN2R/cMa4+U4jURVJ1BZBesVkW8bS7AhBpFjAOUSNDsvsB7Yf
mxUa8vKD7GgTLQPUhP10EeEZz+R/onDlTU7TCDVd1PDdQwlx/2aT+m7K2KwmnOC5
ZUO0jO7EtXn4qhA1qt8oRFRogQlzvMbLr6lYhkomBxn4XqezbtDw+HQjJ2Af5ECR
+tElbkEZWpEG/fwJvv1hhqqDToloOkK2YhMTiOZFesafH+AFQq1pYx6hFoa6TIkG
8aLoLuPY+IBZqXtPLyh1cIiBYqAiyo0lIHjtKjWnPbXhu83EZ3VOvpbopYonSCOX
0uHsbf1fn9NGhe5TSSxbz0SAGJgZTr2VvHinqZ0k3me4CS/HUzkvOtMdVtwJdqLp
N/pUfGRjeiDbO5JvOMrimUP3klVB54Nf0IIw7aMhD3yO7KGoxRIV89H6i5TJF3zY
WeirON6ejHapNw6WCWL7YY5WDsGuiMIuAcfwYAcsaqaKYktjqJZT1hejJNVqLhSU
ZaVBcl56/VO9lPoC8u7NXFfnT4h3bIfL8Ft3riabQzFSMXjLI2Q27BG7R5xNEo+u
aG2STkjKam/+q5VeUnpg1F0HBz/QuZ+GcsItD8uN+IjN9yZhJjryRwhn2KcaPIYz
upOsIXACYAm0kfqNRK5qdwECAwEAAQ==
-----END PUBLIC KEY-----

14
federator.pub Normal file
View file

@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA6kZOjPKQjQIo5dugu80e
gsQXPkWhGjSUgbJ5UNwcyFto4p2euhVqVnTVOCWeS9+dPQP14fuVowODceaZLmGg
sBqraBZ4FNb76ByBdfiqDmPvUP61hrdDCZ52IPMYq7e3knWVakGouSqyoa/TVf3l
5oa7qgYnRDvHQXkA51Dj/1BqW57WeBQzEd5nwFhhAKZuVLxC/+xEu6Ohf+6WC2qR
Dz/toI26A3QrMCgmt21ELxjTyNmUdTL6U8PjutiMZJ2sy5uhR7stRNzoWt0AnRJE
1NlwPU8tKpfXAv00zxTS4xuLt0zv2lNSSRfECeM2g86fXuhMB0NYd30Mgda+Svbu
MEFvOkB5xEAi1NRamETV9Ci/LBqShC1ZBcY5QdikH4S0awIsQA3YMsK0y4+gCY1S
oHwFjR+KhiGKBa4NaKsfFy3JL5OB6+8PF6z2ICbD26X1jJy9ScLHrljd/AKVNtXE
Jaz2NDrqmqdjCILxROTle5aNnOfpaAMmiszIWmZNuCWRBbrpVXPeOR3D+qLEld3u
z2l/i2ywfNtt0VrMhKMWjT99aPOHyMvInuZGYx2RVhzYyf5h3V6FCoD67ihInbCa
SfDGHKhEa6gQaIIZi2EfY2QbYbZG/4gX9BHfUlTYMoFgW5P2qS1c27tTi/1LkJKx
CZWiL/7VWZ/nx94SQPL76k0CAwEAAQ==
-----END PUBLIC KEY-----

View file

@ -195,12 +195,11 @@ class Api extends Main
/** /**
* check if the headers include a valid signature * check if the headers include a valid signature
* *
* @param string[] $headers * @param string[] $headers the headers
* permission(s) to check for
* @throws Exceptions\PermissionDenied * @throws Exceptions\PermissionDenied
* @return string|Exceptions\PermissionDenied * @return string|Exceptions\PermissionDenied
*/ */
public static function checkSignature($headers) public function checkSignature($headers)
{ {
$signatureHeader = $headers['Signature'] ?? null; $signatureHeader = $headers['Signature'] ?? null;
@ -209,33 +208,40 @@ class Api extends Main
throw new Exceptions\PermissionDenied("Missing Signature header"); throw new Exceptions\PermissionDenied("Missing Signature header");
} }
$config = $this->getConfig();
// Parse Signature header // Parse Signature header
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches); preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
$signatureParts = array_combine($matches[1], $matches[2]); $signatureParts = array_combine($matches[1], $matches[2]);
$signature = base64_decode($signatureParts['signature']); $signature = base64_decode($signatureParts['signature']);
$keyId = $signatureParts['keyId'];
$signedHeaders = explode(' ', $signatureParts['headers']); $signedHeaders = explode(' ', $signatureParts['headers']);
if (isset($headers['X-Sender']) && $headers['X-Sender'] === $config['keys']['headerSenderName']) {
$pKeyPath = $_SERVER['DOCUMENT_ROOT'] . $config['keys']['contentnationPublicKeyPath'];
$publicKeyPem = file_get_contents($pKeyPath);
} else {
$keyId = $signatureParts['keyId'];
// 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']);
if ($info['http_code'] != 200) { if ($info['http_code'] != 200) {
http_response_code(500); http_response_code(500);
throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId"); throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId");
}
$actor = json_decode($publicKeyData, true);
if (!is_array($actor) || !isset($actor['id'])) {
throw new Exceptions\PermissionDenied("Invalid actor data");
}
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
} }
$actor = json_decode($publicKeyData, true); if (!isset($publicKeyPem) || $publicKeyPem === false) {
if (!is_array($actor) || !isset($actor['id'])) {
throw new Exceptions\PermissionDenied("Invalid actor data");
}
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
if (!isset($publicKeyPem)) {
http_response_code(500); http_response_code(500);
throw new Exceptions\PermissionDenied("Invalid public key format from actor with keyId: $keyId"); throw new Exceptions\PermissionDenied("Public key couldn't be determined");
} }
// Reconstruct the signed string // Reconstruct the signed string
@ -251,6 +257,7 @@ class Api extends Main
$signedString .= strtolower($header) . ": " . $headerValue . "\n"; $signedString .= strtolower($header) . ": " . $headerValue . "\n";
} }
$signedString = rtrim($signedString); $signedString = rtrim($signedString);
// Verify the signature // Verify the signature
@ -261,12 +268,11 @@ 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 with keyId: $keyId"); throw new Exceptions\PermissionDenied("Signature verification failed for publicKey");
} }
$actorId = $actor['id'];
// Signature is valid! // Signature is valid!
return "Signature verified from actor: " . $actorId; return "Signature verified.";
} }
/** /**

View file

@ -69,6 +69,7 @@ class FedUsers implements APIInterface
} else { } else {
switch ($paths[1]) { switch ($paths[1]) {
case 'inbox': case 'inbox':
$_username = NULL;
$handler = new FedUsers\Inbox($this->main); $handler = new FedUsers\Inbox($this->main);
break; break;
default: default:

View file

@ -106,14 +106,18 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
return false; return false;
} }
$activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null; $input = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
$host = $_SERVER['SERVER_NAME']; $host = $_SERVER['SERVER_NAME'];
if (!is_array($input)) {
if (!is_array($activity)) {
error_log("Outbox::post Input wasn't of type array"); error_log("Outbox::post Input wasn't of type array");
return false; return false;
} }
$outboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
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) { if ($outboxActivity === false) {
error_log("Outbox::post couldn't create outboxActivity"); error_log("Outbox::post couldn't create outboxActivity");
@ -142,9 +146,20 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
} }
} }
} }
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)) { if ($_user !== false && !in_array($_user, $users, true)) {
$users[] = $_user; $users[] = $_user;
} }
foreach ($users as $user) { foreach ($users as $user) {
if (!isset($user)) { if (!isset($user)) {
continue; continue;
@ -152,6 +167,7 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
$this->postForUser($user, $outboxActivity); $this->postForUser($user, $outboxActivity);
} }
return json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); return json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
} }

View file

@ -56,4 +56,12 @@ interface Connector
* @return \Federator\Data\Stats|false * @return \Federator\Data\Stats|false
*/ */
public function getRemoteStats(); public function getRemoteStats();
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData);
} }

View file

@ -329,6 +329,39 @@ class ContentNation implements Connector
} }
return $user; return $user;
} }
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData)
{
$ap = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create',
'id' => $jsonData['id'] ?? null,
'actor' => $jsonData['actor']['id'] ?? 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,
],
];
return \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
}
} }
namespace Federator; namespace Federator;

View file

@ -57,6 +57,16 @@ class DummyConnector implements Connector
return $stats; return $stats;
} }
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData) {
return false;
}
/** /**
* get remote user by name * get remote user by name
* @param string $_name user or profile name * @param string $_name user or profile name

View file

@ -90,6 +90,17 @@ class RedisCache implements Cache
return false; return false;
} }
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData) {
error_log("rediscache::jsonToActivity not implemented");
return false;
}
/** /**
* get posts by given user * get posts by given user
* *