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]
rediscache = 'rediscache.php'
dummy = 'dummyconnector.php'
# dummy = 'dummyconnector.php'
contentnation = 'contentnation.php'
mastodon = 'mastodon.php'
[maintenance]
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
*
* @param string[] $headers
* permission(s) to check for
* @param string[] $headers the headers
* @throws Exceptions\PermissionDenied
* @return string|Exceptions\PermissionDenied
*/
public static function checkSignature($headers)
public function checkSignature($headers)
{
$signatureHeader = $headers['Signature'] ?? null;
@ -209,33 +208,40 @@ class Api extends Main
throw new Exceptions\PermissionDenied("Missing Signature header");
}
$config = $this->getConfig();
// Parse Signature header
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
$signatureParts = array_combine($matches[1], $matches[2]);
$signature = base64_decode($signatureParts['signature']);
$keyId = $signatureParts['keyId'];
$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)
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
// Fetch public key from `keyId` (usually actor URL + #main-key)
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
if ($info['http_code'] != 200) {
http_response_code(500);
throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId");
if ($info['http_code'] != 200) {
http_response_code(500);
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 (!is_array($actor) || !isset($actor['id'])) {
throw new Exceptions\PermissionDenied("Invalid actor data");
}
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
if (!isset($publicKeyPem)) {
if (!isset($publicKeyPem) || $publicKeyPem === false) {
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
@ -251,6 +257,7 @@ class Api extends Main
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
}
$signedString = rtrim($signedString);
// Verify the signature
@ -261,12 +268,11 @@ class Api extends Main
}
if ($verified != 1) {
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!
return "Signature verified from actor: " . $actorId;
return "Signature verified.";
}
/**

View file

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

View file

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

View file

@ -56,4 +56,12 @@ interface Connector
* @return \Federator\Data\Stats|false
*/
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;
}
/**
* 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;

View file

@ -57,6 +57,16 @@ class DummyConnector implements Connector
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
* @param string $_name user or profile name

View file

@ -90,6 +90,17 @@ class RedisCache implements Cache
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
*