forked from GNUsocial/gnu-social
[ActivityPub] Add HTTP Signatures
This commit is contained in:
parent
123544fa50
commit
424df54a1b
@ -112,7 +112,7 @@ class Notification extends Entity
|
||||
*/
|
||||
public function getTarget(): Actor
|
||||
{
|
||||
return Actor::getById($this->getActorId());
|
||||
return Actor::getById($this->getTargetId());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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',
|
||||
|
@ -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());
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.");
|
||||
|
180
plugins/ActivityPub/Util/HTTPSignature.php
Normal file
180
plugins/ActivityPub/Util/HTTPSignature.php
Normal file
@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* @category Network
|
||||
* @package Nautilus
|
||||
* @author Aaron Parecki <aaron@parecki.com>
|
||||
* @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];
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 = [];
|
||||
|
@ -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
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user