From 86c796090d3319ed5dc3a0bf7f76712719896302 Mon Sep 17 00:00:00 2001 From: Django Doucet Date: Mon, 28 Feb 2022 15:52:30 -0700 Subject: [PATCH] Signature Verification with phpseclib3 --- activitypub.php | 2 + composer.json | 3 +- includes/class-signature.php | 133 +++++++++++++++++++++++++++++++++ includes/functions.php | 5 ++ includes/rest/class-inbox.php | 1 + includes/rest/class-server.php | 2 +- 6 files changed, 144 insertions(+), 2 deletions(-) diff --git a/activitypub.php b/activitypub.php index 55d569a..1e69abc 100644 --- a/activitypub.php +++ b/activitypub.php @@ -15,6 +15,8 @@ namespace Activitypub; +require __DIR__ . '/vendor/autoload.php'; + /** * Initialize plugin */ diff --git a/composer.json b/composer.json index 76d9b4c..0e70dc7 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "wordpress-plugin", "require": { "php": ">=5.6.0", - "composer/installers": "~1.0" + "composer/installers": "~1.0", + "phpseclib/phpseclib": "~3.0" }, "require-dev": { "phpunit/phpunit": "^5.7.21 || ^6.5 || ^7.5 || ^8", diff --git a/includes/class-signature.php b/includes/class-signature.php index 5caf884..b89b87f 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -1,6 +1,8 @@ + (https?:\/\/[\w\-\.]+[\w]+) + (:[\d]+)? + ([\w\-\.#\/@]+) + )", + (algorithm="(?P[\w\s-]+)",)? + (headers="(?P[\(\)\w\s-]+)",)? + signature="(?P[\w+\/]+={0,2})" + /x'; + + /** + * Allowed keys when splitting signature + * + * @var array + */ + private $allowedKeys = [ + 'keyId', + 'algorithm', // optional + 'headers', // optional + 'signature', + ]; + /** * @param int $user_id * @@ -109,6 +134,114 @@ class Signature { public static function verify_signature( $headers, $signature ) { + // https://github.com/landrok/activitypub/blob/master/src/ActivityPhp/Server/Http/HttpSignature.php + $header_data = $request->get_headers(); + $body = $request->get_body(); + if ( !$header_data['signature'][0] ) { + return false; + } + // Split it into its parts ( keyId, headers and signature ) + $signature_parts = self::splitSignature( $header_data['signature'][0] ); + if ( !count($signature_parts ) ) { + return false; + } + extract( $signature_parts ); + + // Fetch the public key linked from keyId + $actor = \strip_fragment_from_url( $keyId ); + $publicKeyPem = \Activitypub\get_publickey_by_actor( $actor, $keyId ); + + if (! is_wp_error( $publicKeyPem ) ) { + $pkey = \openssl_pkey_get_details( \openssl_pkey_get_public( $publicKeyPem ) ); + $digest_gen = 'SHA-256=' . \base64_encode( \hash( 'sha256', $body, true ) ); + if ( $digest_gen !== $header_data['digest'][0] ) { + return false; + } + // Create a comparison string from the plaintext headers we got + // in the same order as was given in the signature header, + $data_plain = self::getPlainText( + explode(' ', trim( $headers ) ), + $request + ); + + // Verify that string using the public key and the original + // signature. + $rsa = RSA::createKey() + ->loadPublicKey( $pkey['key']) + ->withHash('sha256'); + + $verified = $rsa->verify( $data_plain, \base64_decode( $signature ) ); + + if ( '1' === $verified ) { + return true; + } else { + return false; + } + } else { + $activity = json_decode($body); + if ( $activity->type === 'Delete' ) { + // TODO eventually process ld signatures + } + } + } + + /** + * Split HTTP signature into its parts (keyId, headers and signature) + */ + public static function splitSignature( $signature ) { + + $allowedKeys = [ + 'keyId', + 'algorithm', // optional + 'headers', // optional + 'signature', + ]; + + if (!preg_match(self::SIGNATURE_PATTERN, $signature, $matches)) { + \error_log('Signature pattern failed' . print_r( $signature, true ) ); + return []; + } + + // Headers are optional + if (!isset($matches['headers']) || $matches['headers'] == '') { + $matches['headers'] = 'date'; + } + + return array_filter($matches, function($key) use ($allowedKeys) { + return !is_int($key) && in_array($key, $allowedKeys); + }, ARRAY_FILTER_USE_KEY ); + } + + /** + * Get plain text that has been originally signed + * + * @param array $headers HTTP header keys + * @param \Symfony\Component\HttpFoundation\Request $request + */ + public static function getPlainText( $headers, $request ) { + + $url_params = $request->get_url_params(); + if ( isset( $url_params ) && isset( $url_params['user_id'] ) ) { + $url_params = ''; + } + + $strings = []; + $request_target = sprintf( + '%s %s%s', + strtolower($request->get_method()), + $request->get_route(), + $url_params + ); + + foreach ($headers as $value) { + if ( $value == '(request-target)' ) { + $strings[] = "$value: " . $request_target; + } else { + $strings[] = "$value: " . $request->get_header($value); + } + } + + return implode("\n", $strings); } public static function generate_digest( $body ) { diff --git a/includes/functions.php b/includes/functions.php index 3c33664..94bfaba 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -182,6 +182,11 @@ function get_inbox_by_actor( $actor ) { */ function get_publickey_by_actor( $actor, $key_id ) { $metadata = \Activitypub\get_remote_metadata_by_actor( $actor ); + + //Other Implementations may include an 'operations' query (Zap) + if ( isset( $metadata['publicKey']['id'] ) ) { + $metadata['publicKey']['id'] = strtok($metadata['publicKey']['id'], "?"); + } if ( \is_wp_error( $metadata ) ) { return $metadata; diff --git a/includes/rest/class-inbox.php b/includes/rest/class-inbox.php index 1ffe451..8c246d7 100644 --- a/includes/rest/class-inbox.php +++ b/includes/rest/class-inbox.php @@ -82,6 +82,7 @@ class Inbox { $headers = $request->get_headers(); // verify signature + \Activitypub\Signature::verify_signature( $request ); //\Activitypub\Signature::verify_signature( $headers, $key ); return $served; diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php index ac89dca..685b315 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -24,7 +24,7 @@ class Server extends \WP_REST_Server { // check for content-sub-types like 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' if ( \preg_match( '/application\/([a-zA-Z+_-]+\+)json/', $content_type['value'] ) ) { - $request->set_header( 'Content-Type', 'application/json' ); + // Signature Verification requires headers to be intact } // make request filterable