From 6f42e2093cf4d62c6c376a929d016785526e2943 Mon Sep 17 00:00:00 2001 From: Chitoku Date: Sun, 19 Nov 2023 12:39:06 +0900 Subject: [PATCH] Retrieve Mastodon profiles with signed requests (#2461) * 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 --- README.md | 1 + api/src/Http/RequestSigner.php | 57 +++++++++ .../Providers/HomoProfileServiceProvider.php | 24 +++- api/src/Repository/HomoRepository.php | 2 +- api/src/Service/ActivityPubService.php | 2 +- api/src/config.php | 1 + api/tests/Case/Http/RequestSignerTest.php | 121 ++++++++++++++++++ .../Case/Service/ActivityPubServiceTest.php | 14 +- bin/init | 1 + compose.yaml | 10 +- 10 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 api/src/Http/RequestSigner.php create mode 100644 api/tests/Case/Http/RequestSignerTest.php diff --git a/README.md b/README.md index 3a341a1d5..00d2a43b9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/src/Http/RequestSigner.php b/api/src/Http/RequestSigner.php new file mode 100644 index 000000000..1f21fd2f0 --- /dev/null +++ b/api/src/Http/RequestSigner.php @@ -0,0 +1,57 @@ +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); + } +} diff --git a/api/src/Providers/HomoProfileServiceProvider.php b/api/src/Providers/HomoProfileServiceProvider.php index 39e5a21ce..fc6342e06 100644 --- a/api/src/Providers/HomoProfileServiceProvider.php +++ b/api/src/Providers/HomoProfileServiceProvider.php @@ -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; @@ -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(); @@ -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') diff --git a/api/src/Repository/HomoRepository.php b/api/src/Repository/HomoRepository.php index 361ce14b9..3e25d9572 100644 --- a/api/src/Repository/HomoRepository.php +++ b/api/src/Repository/HomoRepository.php @@ -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 diff --git a/api/src/Service/ActivityPubService.php b/api/src/Service/ActivityPubService.php index e76bbbe9f..70bc0e528 100644 --- a/api/src/Service/ActivityPubService.php +++ b/api/src/Service/ActivityPubService.php @@ -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) { diff --git a/api/src/config.php b/api/src/config.php index ceb3d5a60..05e318db3 100644 --- a/api/src/config.php +++ b/api/src/config.php @@ -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, diff --git a/api/tests/Case/Http/RequestSignerTest.php b/api/tests/Case/Http/RequestSignerTest.php new file mode 100644 index 000000000..580b747b7 --- /dev/null +++ b/api/tests/Case/Http/RequestSignerTest.php @@ -0,0 +1,121 @@ +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="(?.+)"|'; + + $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="(?.+)"|'; + + $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, []); + } +} diff --git a/api/tests/Case/Service/ActivityPubServiceTest.php b/api/tests/Case/Service/ActivityPubServiceTest.php index c289cbac6..f4459f543 100644 --- a/api/tests/Case/Service/ActivityPubServiceTest.php +++ b/api/tests/Case/Service/ActivityPubServiceTest.php @@ -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 @@ -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-----", ], ]; diff --git a/bin/init b/bin/init index c93a90c00..ab2a4c81b 100755 --- a/bin/init +++ b/bin/init @@ -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 diff --git a/compose.yaml b/compose.yaml index 0d06a4522..e76196a39 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 @@ -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 @@ -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: