[ActivityPub] Add HTTP Signatures

This commit is contained in:
Diogo Peralta Cordeiro 2021-11-30 16:47:31 +00:00
parent 123544fa50
commit 424df54a1b
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
11 changed files with 283 additions and 29 deletions

View File

@ -112,7 +112,7 @@ class Notification extends Entity
*/
public function getTarget(): Actor
{
return Actor::getById($this->getActorId());
return Actor::getById($this->getTargetId());
}
/**

View File

@ -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',

View File

@ -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());

View File

@ -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

View File

@ -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.");

View 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];
}
}

View File

@ -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(),

View File

@ -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
{

View File

@ -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);
}

View File

@ -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 = [];

View File

@ -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
{