Skip to content

Commit

Permalink
Retrieve Mastodon profiles with signed requests (#2461)
Browse files Browse the repository at this point in the history
* Fix a bug where GET /check/{username} caused DB error

* Allow PHP to read activity_pub_actor.key for development

* Use RSA key for ActivityPub

* Fix code format

* Retrieve Mastodon profiles with signed requests

* Remove Date and Host headers when testing sign
  • Loading branch information
chitoku-k authored Nov 19, 2023
1 parent 6c06045 commit 6f42e20
Show file tree
Hide file tree
Showing 10 changed files with 224 additions and 9 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ $ export HOMOCHECKER_API_HOST=api
$ export HOMOCHECKER_AP_ACTOR_ID=https://example.com/actor
$ export HOMOCHECKER_AP_ACTOR_PREFERRED_USERNAME=example.com
$ export HOMOCHECKER_AP_ACTOR_PUBLIC_KEY=/path/to/public_key
$ export HOMOCHECKER_AP_ACTOR_PRIVATE_KEY=/path/to/private_key
$ export HOMOCHECKER_DB_HOST=database
$ export HOMOCHECKER_DB_PORT=5432
$ export HOMOCHECKER_DB_USERNAME=homo
Expand Down
57 changes: 57 additions & 0 deletions api/src/Http/RequestSigner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);

namespace HomoChecker\Http;

use Psr\Http\Message\RequestInterface as Request;

class RequestSigner
{
public function __construct(
protected string $id,
protected string $privateKeyPem,
) {}

/**
* Sign the given HTTP request.
* @return Request The signed request.
*/
protected function sign(Request $request): Request
{
$requestTarget = strtolower($request->getMethod()) . ' ' . $request->getRequestTarget();
if (!$date = $request->getHeaderLine('Date')) {
$date = (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC7231);
$request = $request->withHeader('Date', $date);
}
if (!$host = $request->getHeaderLine('Host')) {
$host = $request->getUri()->getHost();
$request = $request->withHeader('Host', $host);
}

$data = implode("\n", [
"(request-target): {$requestTarget}",
"date: {$date}",
"host: {$host}",
]);

set_error_handler(fn ($severity, $message, $filename, $line) => throw new \ErrorException($message, 0, $severity, $filename, $line));

try {
openssl_sign($data, $signature, $this->privateKeyPem, OPENSSL_ALGO_SHA256);
return $request->withHeader('Signature', implode(',', [
"keyId=\"{$this->id}#main-key\"",
'headers="(request-target) date host"',
'signature="' . base64_encode($signature) . '"',
]));
} catch (\Throwable $e) {
throw new \RuntimeException('Signing request failed', 0, $e);
} finally {
restore_error_handler();
}
}

public function __invoke(callable $handler): callable
{
return fn (Request $request, array $options) => $handler($this->sign($request), $options);
}
}
24 changes: 21 additions & 3 deletions api/src/Providers/HomoProfileServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use GuzzleHttp\ClientInterface;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Subscriber\Oauth\Oauth1;
use HomoChecker\Http\RequestSigner;
use HomoChecker\Service\Profile\MastodonProfileService;
use HomoChecker\Service\Profile\TwitterProfileService;
use Illuminate\Contracts\Container\Container;
Expand All @@ -17,9 +18,15 @@ class HomoProfileServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton('mastodon.client', fn (Container $app) => new Client(
$app->make('config')->get('mastodon.client'),
));
$this->app->singleton('mastodon.client', function (Container $app) {
$handler = HandlerStack::create();
$handler->push($app->make('mastodon.signer'));

$config = $app->make('config')->get('mastodon.client');
$config['handler'] = $handler;

return new Client($config);
});

$this->app->singleton('twitter.client', function (Container $app) {
$handler = HandlerStack::create();
Expand All @@ -31,6 +38,17 @@ public function register()
return new Client($config);
});

$this->app->singleton('mastodon.signer', RequestSigner::class);
$this->app->when(RequestSigner::class)
->needs('$id')
->give(fn (Container $app) => $app->make('config')->get('activityPub.actor')['id']);
$this->app->when(RequestSigner::class)
->needs('$privateKeyPem')
->give(function (Container $app) {
$actor = $app->make('config')->get('activityPub.actor');
return \file_get_contents($actor['private_key']);
});

$this->app->singleton('twitter.oauth', Oauth1::class);
$this->app->when(Oauth1::class)
->needs('$config')
Expand Down
2 changes: 1 addition & 1 deletion api/src/Repository/HomoRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public function findAll(): array

public function findByScreenName(string $screenName): array
{
return $this->join()->where('screen_name', $screenName)->get()->all();
return $this->join()->where('users.screen_name', $screenName)->get()->all();
}

public function export(): string
Expand Down
2 changes: 1 addition & 1 deletion api/src/Service/ActivityPubService.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function actor(): array
*/
public function webFinger(string $resource): null|array
{
$domain = \parse_url($this->id, \PHP_URL_HOST);
$domain = parse_url($this->id, \PHP_URL_HOST);
$acct = "acct:{$this->preferredUsername}@{$domain}";

if ($resource === $acct || $resource === $this->id) {
Expand Down
1 change: 1 addition & 0 deletions api/src/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
'id' => env('HOMOCHECKER_AP_ACTOR_ID'),
'preferred_username' => env('HOMOCHECKER_AP_ACTOR_PREFERRED_USERNAME'),
'public_key' => env('HOMOCHECKER_AP_ACTOR_PUBLIC_KEY'),
'private_key' => env('HOMOCHECKER_AP_ACTOR_PRIVATE_KEY'),
],
'client' => [
'timeout' => 5,
Expand Down
121 changes: 121 additions & 0 deletions api/tests/Case/Http/RequestSignerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);

namespace HomoChecker\Test\Http;

use HomoChecker\Http\RequestSigner;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface as Request;
use Slim\Psr7\Factory\RequestFactory;

class RequestSignerTest extends TestCase
{
protected string $id;
protected string $publicKeyPem;
protected string $privateKeyPem;

public function setUp(): void
{
parent::setUp();

$this->id = 'https://example.com/actor';
$this->publicKeyPem = <<<'EOF'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsBn2IZ9I0tiwtlmQclZ6
J2IEaV/h6lDidqMrVFwRbS/c2wKqtT+OnqmXSYl5Mvl/9wDxwFiHOe87FOdC0gHz
Exjkq4EsWrsleMLAAagpDSxLyeFtdFJKLG08fT75hhZqyQIhTCk8cRc5lqpex6aP
nfouBWaUvPh+VyJjNTUykoSHTR11/M7mM8lwu1d2OkOQWn7C3Wy9e85acxGYLTSO
K4YSearvK97gNaOg6JU56H8QtBMzWDeuaTh11+v2s4uc1flADP5TzKNtwg51D/AK
O2lj+Eq1ksYsoqi/uqcBcVHgV3ZYrGIyRWf31+zlpuVlrnbrgCvN6cicSxlU8PQq
1QIDAQAB
-----END PUBLIC KEY-----
EOF;
$this->privateKeyPem = <<<'EOF'
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCwGfYhn0jS2LC2
WZByVnonYgRpX+HqUOJ2oytUXBFtL9zbAqq1P46eqZdJiXky+X/3APHAWIc57zsU
50LSAfMTGOSrgSxauyV4wsABqCkNLEvJ4W10UkosbTx9PvmGFmrJAiFMKTxxFzmW
ql7Hpo+d+i4FZpS8+H5XImM1NTKShIdNHXX8zuYzyXC7V3Y6Q5BafsLdbL17zlpz
EZgtNI4rhhJ5qu8r3uA1o6DolTnofxC0EzNYN65pOHXX6/azi5zV+UAM/lPMo23C
DnUP8Ao7aWP4SrWSxiyiqL+6pwFxUeBXdlisYjJFZ/fX7OWm5WWuduuAK83pyJxL
GVTw9CrVAgMBAAECggEAA8X2omdLk+r9NFcMc4q7UNM2lXxutorXo2OhJsxXOj/z
i0TOHBaZy3gGTBbUQD2c2pHMXEr5UMo5uZuv8JiGmRLoOW2J4gLPDXycyRxNjuDz
WcbJBdxKhxOrH2LlTVR3Isn3JS7gAutUuk/5umzs+F1XNZnqV3c6m8raldYHOKDx
vN+upzEp8eIIDRLSj8YjavHGdnWOjcm/0Wmmfgshs2Sveu0SoGz2c1wsy6aVV+hA
xCTs+B88SuozYnUg2TxfVK8k1BHmTqbdSVfpbF38gvJVRd2JIZR9vrEZYoBcmrPR
NuFlVlLvEHcz0KO7bESly4MV429pFWkjX2pr2t5cIQKBgQDUf/Jn+fMQWRJ0vmWu
oFgZxpgQdohuMiYFmPiX0vQtKKL7r0d3Vl0ZKAXk3+5OjAwlZCMpgRiVFCOxzrgR
iroWfmaR6QosbD7Q3WKbxmOMuPIoaS2/wrhhCPPOV3qXabLRSMjDeJeQwU5TLo5F
D/vTFut4Wrc9hIdDwWrvNTq/tQKBgQDUJo6iHDt5aveIKRXCkf+p+Ohmbf+EI3SJ
sMQcWK3mIk9DhpuEUnW49Pt0sZQY+/4fPfIdPzmjw72dliqtt+L33IXHq2jOFXsA
e/4n5odcwLSJr4HGWNm745F4jBnvyI78Vc1PP67Zl72HVuoIXaf6M9isbnWEiGZB
mnH1vvdyoQKBgGOVxotVxsQ9ifmuFMb+m+sQd8kXU56Y39q1sqKsGQRky+S5YvuZ
PK4CZKi7DNpApZyMTjIwLs4GjyfP4dFOuyC5geYVWVAyNkn5xjGMirCzJ8EqcWcx
oOjQojlsI6Z7wXJ08qkwhY8wGD3BTqks8W4eiqFvmfo5do6ZQTzzLCIVAoGBAJ6c
rSsafITMqoCMZw5vZXxI8kgSmWTLtUd0d0rSKkHTCPvtWbxWgllkH9QhKB592IK3
J5siOA/uOoflS8dRokm5//NGfjcF7E5yZZSjUDTShqgiJZ6Ls048V/iOlp2ljvGt
nLBRZoKcZkEXhCX5D6uKs8ZHV2ldKUaHGAipXAvBAoGAbr916h6eTTc/77Tr0WKj
bQF7AHHqc0X+eKpzuUeE+KApiXvcnXvO+smFayfvmh9kvsBQFcI1PNa6QF3wBvTP
BkU4+kLDPZ7q6uJXVL/3ZUJRaTTQ5gYBEc6xEXj1yIOqo3D3TdmqcXiRwculK7iw
h7oRzSyHfQtPrQ/J7HUzIuk=
-----END PRIVATE KEY-----
EOF;
}

public function testSignWithDate(): void
{
$request = (new RequestFactory())->createRequest('GET', 'https://example.com/users/example.json');
$request = $request->withHeader('Date', 'Sun, 31 Dec 2023 00:00:00 GMT');

$pattern = '|keyId="https://example\.com/actor#main-key",headers="\(request-target\) date host",signature="(?<signature>.+)"|';

$signer = new RequestSigner($this->id, $this->privateKeyPem);
$middleware = $signer(function (Request $actual, array $options) use ($pattern) {
$this->assertMatchesRegularExpression($pattern, $actual->getHeaderLine('Signature'));
$this->assertNotFalse(preg_match($pattern, $actual->getHeaderLine('Signature'), $matches));

$signature = base64_decode($matches['signature']);
$this->assertNotFalse($signature);

$data = <<<'EOF'
(request-target): get /users/example.json
date: Sun, 31 Dec 2023 00:00:00 GMT
host: example.com
EOF;
$this->assertEquals(1, openssl_verify($data, $signature, $this->publicKeyPem, OPENSSL_ALGO_SHA256));
});
$middleware($request, []);
}

public function testSignWithoutDateAndHost(): void
{
$request = (new RequestFactory())->createRequest('GET', 'https://example.com/users/example.json');
$request = $request->withoutHeader('Date')->withoutHeader('Host');

$pattern = '|keyId="https://example\.com/actor#main-key",headers="\(request-target\) date host",signature="(?<signature>.+)"|';

$signer = new RequestSigner($this->id, $this->privateKeyPem);
$middleware = $signer(function (Request $actual, array $options) use ($pattern) {
$this->assertMatchesRegularExpression($pattern, $actual->getHeaderLine('Signature'));
$this->assertNotFalse(preg_match($pattern, $actual->getHeaderLine('Signature'), $matches));

$signature = base64_decode($matches['signature']);
$this->assertNotFalse($signature);
});
$middleware($request, []);
}

public function testSignInvalid(): void
{
$request = (new RequestFactory())->createRequest('GET', 'https://example.com/users/example.json');

$signer = new RequestSigner($this->id, 'invalid privateKeyPem');
$middleware = $signer(function (Request $actual, array $options) {
$this->fail('The next handler must not be called');
});

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signing request failed');
$middleware($request, []);
}
}
14 changes: 12 additions & 2 deletions api/tests/Case/Service/ActivityPubServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,17 @@ public function setUp(): void

$this->id = 'https://example.com/actor';
$this->preferredUsername = 'example.com';
$this->publicKeyPem = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAUVd1lBkQ8I/3PJIRLgXbm2TDv16wQBXuN09wWo8lh74=\n-----END PUBLIC KEY-----\n";
$this->publicKeyPem = <<<'EOF'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsBn2IZ9I0tiwtlmQclZ6
J2IEaV/h6lDidqMrVFwRbS/c2wKqtT+OnqmXSYl5Mvl/9wDxwFiHOe87FOdC0gHz
Exjkq4EsWrsleMLAAagpDSxLyeFtdFJKLG08fT75hhZqyQIhTCk8cRc5lqpex6aP
nfouBWaUvPh+VyJjNTUykoSHTR11/M7mM8lwu1d2OkOQWn7C3Wy9e85acxGYLTSO
K4YSearvK97gNaOg6JU56H8QtBMzWDeuaTh11+v2s4uc1flADP5TzKNtwg51D/AK
O2lj+Eq1ksYsoqi/uqcBcVHgV3ZYrGIyRWf31+zlpuVlrnbrgCvN6cicSxlU8PQq
1QIDAQAB
-----END PUBLIC KEY-----
EOF;
}

public function testActor(): void
Expand All @@ -38,7 +48,7 @@ public function testActor(): void
'publicKey' => [
'id' => 'https://example.com/actor#main-key',
'owner' => 'https://example.com/actor',
'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAUVd1lBkQ8I/3PJIRLgXbm2TDv16wQBXuN09wWo8lh74=\n-----END PUBLIC KEY-----\n",
'publicKeyPem' => "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsBn2IZ9I0tiwtlmQclZ6\nJ2IEaV/h6lDidqMrVFwRbS/c2wKqtT+OnqmXSYl5Mvl/9wDxwFiHOe87FOdC0gHz\nExjkq4EsWrsleMLAAagpDSxLyeFtdFJKLG08fT75hhZqyQIhTCk8cRc5lqpex6aP\nnfouBWaUvPh+VyJjNTUykoSHTR11/M7mM8lwu1d2OkOQWn7C3Wy9e85acxGYLTSO\nK4YSearvK97gNaOg6JU56H8QtBMzWDeuaTh11+v2s4uc1flADP5TzKNtwg51D/AK\nO2lj+Eq1ksYsoqi/uqcBcVHgV3ZYrGIyRWf31+zlpuVlrnbrgCvN6cicSxlU8PQq\n1QIDAQAB\n-----END PUBLIC KEY-----",
],
];

Expand Down
1 change: 1 addition & 0 deletions bin/init
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ if [[ ! -f api/activity_pub_actor.key ]]; then

openssl genpkey -quiet -algorithm rsa -pkeyopt rsa_keygen_bits:2048 -out api/activity_pub_actor.key
openssl pkey -in api/activity_pub_actor.key -pubout -out api/activity_pub_actor.pub
chmod 644 api/activity_pub_actor.key
fi
10 changes: 8 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ services:
environment:
HOMOCHECKER_AP_ACTOR_ID: http://localhost/actor
HOMOCHECKER_AP_ACTOR_PREFERRED_USERNAME: localhost
HOMOCHECKER_AP_ACTOR_PUBLIC_KEY: /activity_pub_actor_public_key
HOMOCHECKER_AP_ACTOR_PUBLIC_KEY: /activity_pub_actor.pub
HOMOCHECKER_AP_ACTOR_PRIVATE_KEY: /activity_pub_actor.key
HOMOCHECKER_DB_HOST: database
HOMOCHECKER_DB_USERNAME: homo
HOMOCHECKER_DB_PASSWORD: homo
Expand All @@ -17,7 +18,10 @@ services:
HOMOCHECKER_TWITTER_TOKEN:
HOMOCHECKER_TWITTER_TOKEN_SECRET:
configs:
- activity_pub_actor_public_key
- source: activity_pub_actor_public_key
target: activity_pub_actor.pub
- source: activity_pub_actor_private_key
target: activity_pub_actor.key
volumes:
- type: bind
source: ./api
Expand Down Expand Up @@ -70,6 +74,8 @@ services:
configs:
activity_pub_actor_public_key:
file: ./api/activity_pub_actor.pub
activity_pub_actor_private_key:
file: ./api/activity_pub_actor.key

volumes:
database:
Expand Down

0 comments on commit 6f42e20

Please sign in to comment.