updated plugin ActivityPub version 8.3.0
This commit is contained in:
@ -0,0 +1,491 @@
|
||||
<?php
|
||||
/**
|
||||
* ActivityPub HTTP Message Signature Standard.
|
||||
*
|
||||
* This class implements the HTTP Message Signature standard for verifying HTTP signatures.
|
||||
*
|
||||
* @package Activitypub\Signature
|
||||
*/
|
||||
|
||||
// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.PHP.DiscouragedPHPFunctions
|
||||
|
||||
namespace Activitypub\Signature;
|
||||
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
/**
|
||||
* Class Http_Message_Signature.
|
||||
*
|
||||
* Implements the HTTP Message Signature standard for verifying HTTP signatures.
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/rfc9421.html
|
||||
*/
|
||||
class Http_Message_Signature implements Http_Signature {
|
||||
|
||||
/**
|
||||
* Signature algorithms.
|
||||
*
|
||||
* @var int[][]
|
||||
*/
|
||||
private $algorithms = array(
|
||||
// RSA PKCS#1 v1.5.
|
||||
'rsa-v1_5-sha256' => array(
|
||||
'type' => OPENSSL_KEYTYPE_RSA,
|
||||
'algo' => OPENSSL_ALGO_SHA256,
|
||||
),
|
||||
'rsa-v1_5-sha384' => array(
|
||||
'type' => OPENSSL_KEYTYPE_RSA,
|
||||
'algo' => OPENSSL_ALGO_SHA384,
|
||||
),
|
||||
'rsa-v1_5-sha512' => array(
|
||||
'type' => OPENSSL_KEYTYPE_RSA,
|
||||
'algo' => OPENSSL_ALGO_SHA512,
|
||||
),
|
||||
|
||||
// RSA PSS (note: not supported in openssl_verify() until PHP 8.1).
|
||||
'rsa-pss-sha256' => array(
|
||||
'type' => OPENSSL_KEYTYPE_RSA,
|
||||
'algo' => OPENSSL_ALGO_SHA256,
|
||||
),
|
||||
'rsa-pss-sha384' => array(
|
||||
'type' => OPENSSL_KEYTYPE_RSA,
|
||||
'algo' => OPENSSL_ALGO_SHA384,
|
||||
),
|
||||
'rsa-pss-sha512' => array(
|
||||
'type' => OPENSSL_KEYTYPE_RSA,
|
||||
'algo' => OPENSSL_ALGO_SHA512,
|
||||
),
|
||||
|
||||
// ECDSA.
|
||||
'ecdsa-p256-sha256' => array(
|
||||
'type' => OPENSSL_KEYTYPE_EC,
|
||||
'algo' => OPENSSL_ALGO_SHA256,
|
||||
),
|
||||
'ecdsa-p384-sha384' => array(
|
||||
'type' => OPENSSL_KEYTYPE_EC,
|
||||
'algo' => OPENSSL_ALGO_SHA384,
|
||||
),
|
||||
'ecdsa-p521-sha512' => array(
|
||||
'type' => OPENSSL_KEYTYPE_EC,
|
||||
'algo' => OPENSSL_ALGO_SHA512,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Digest algorithms.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private $digest_algorithms = array(
|
||||
'sha-256' => 'sha256',
|
||||
'sha-512' => 'sha512',
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate RFC-9421 compliant Signature-Input and Signature headers for an outgoing HTTP request.
|
||||
*
|
||||
* @param array $args The request arguments.
|
||||
* @param string $url The request URL.
|
||||
*
|
||||
* @return array Request arguments with signature headers.
|
||||
*/
|
||||
public function sign( $args, $url ) {
|
||||
// Standard components to sign.
|
||||
$components = array(
|
||||
'"@method"' => \strtoupper( $args['method'] ),
|
||||
'"@target-uri"' => $url,
|
||||
'"@authority"' => \wp_parse_url( $url, PHP_URL_HOST ),
|
||||
);
|
||||
|
||||
if ( isset( $args['headers']['Collection-Synchronization'] ) ) {
|
||||
$components['"collection-synchronization"'] = $args['headers']['Collection-Synchronization'];
|
||||
}
|
||||
|
||||
$identifiers = \array_keys( $components );
|
||||
|
||||
// Add digest if provided.
|
||||
if ( isset( $args['body'] ) ) {
|
||||
$components['"content-digest"'] = $this->generate_digest( $args['body'] );
|
||||
$identifiers = \array_keys( $components );
|
||||
|
||||
$args['headers']['Content-Digest'] = $components['"content-digest"'];
|
||||
}
|
||||
|
||||
$params = array(
|
||||
'created' => \strtotime( $args['headers']['Date'] ),
|
||||
'keyid' => $args['key_id'],
|
||||
'alg' => 'rsa-v1_5-sha256',
|
||||
);
|
||||
|
||||
// Build the signature base string as per RFC-9421.
|
||||
$signature_base = $this->get_signature_base_string( $components, $params );
|
||||
|
||||
$signature = null;
|
||||
\openssl_sign( $signature_base, $signature, $args['private_key'], \OPENSSL_ALGO_SHA256 );
|
||||
$signature = \base64_encode( $signature );
|
||||
|
||||
$args['headers']['Signature-Input'] = 'wp=(' . \implode( ' ', $identifiers ) . ')' . $this->get_params_string( $params );
|
||||
$args['headers']['Signature'] = 'wp=:' . $signature . ':';
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the HTTP Signature against a request.
|
||||
*
|
||||
* @param array $headers The HTTP headers.
|
||||
* @param string|null $body The request body, if applicable.
|
||||
* @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
|
||||
*/
|
||||
public function verify( array $headers, $body = null ) {
|
||||
$parsed = $this->parse_signature_labels( $headers );
|
||||
if ( \is_wp_error( $parsed ) ) {
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
$errors = new \WP_Error();
|
||||
foreach ( $parsed as $data ) {
|
||||
$result = $this->verify_signature_label( $data, $headers, $body );
|
||||
if ( true === $result ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
$errors->add( $result->get_error_code(), $result->get_error_message() );
|
||||
}
|
||||
}
|
||||
|
||||
// No valid signature found.
|
||||
$errors->add_data( array( 'status' => 401 ) );
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a digest for the request body.
|
||||
*
|
||||
* @param string $body The request body.
|
||||
*
|
||||
* @return string The digest.
|
||||
*/
|
||||
public function generate_digest( $body ) {
|
||||
return 'sha-256=:' . \base64_encode( \hash( 'sha256', $body, true ) ) . ':';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Signature-Input and Signature headers.
|
||||
*
|
||||
* @param array $headers The HTTP headers.
|
||||
* @return array|\WP_Error Parsed signature labels or WP_Error on failure.
|
||||
*/
|
||||
private function parse_signature_labels( array $headers ) {
|
||||
$parsed_inputs = array();
|
||||
\preg_match_all( '/(?P<label>\w+)=\((?P<components>[^)]*)\)(?P<params>[^,]*)/', $headers['signature_input'][0], $matches, PREG_SET_ORDER );
|
||||
|
||||
foreach ( $matches as $match ) {
|
||||
$label = $match['label'];
|
||||
$components = \preg_split( '/\s+/', \trim( $match['components'] ) );
|
||||
$param_str = \trim( $match['params'], '; ' );
|
||||
$params = array();
|
||||
|
||||
foreach ( \explode( ';', $param_str ) as $param ) {
|
||||
if ( \preg_match( '/(\w+)=("?)([^";]+)\2/', \trim( $param ), $m ) ) {
|
||||
$params[ \strtolower( $m[1] ) ] = $m[3];
|
||||
}
|
||||
}
|
||||
|
||||
if ( \preg_match( '/' . \preg_quote( $label, '/' ) . '=:([^:]+):/', $headers['signature'][0], $sig_match ) ) {
|
||||
$parsed_inputs[ $label ] = array(
|
||||
'components' => $components,
|
||||
'params' => $params,
|
||||
'signature' => \base64_decode( $sig_match[1] ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $parsed_inputs ) ) {
|
||||
return new \WP_Error( 'no_valid_labels', 'No valid signature labels found.' );
|
||||
}
|
||||
|
||||
return $parsed_inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a single signature label.
|
||||
*
|
||||
* @param array $data Parsed signature data.
|
||||
* @param array $headers HTTP headers.
|
||||
* @param string|null $body Request body, if applicable.
|
||||
* @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
|
||||
*/
|
||||
private function verify_signature_label( $data, $headers, $body ) {
|
||||
$params = $data['params'];
|
||||
|
||||
/*
|
||||
* Timestamp verification.
|
||||
*
|
||||
* Keep the pre-existing one-minute forward bound (tighter than the
|
||||
* Cavage path's five minutes, appropriate for RFC 9421 where fresh
|
||||
* peers tend to ship with synced clocks) and add one hour of
|
||||
* backward drift. Without the past-side bound, peers that omit
|
||||
* `expires` could present arbitrarily old signatures for replay.
|
||||
*/
|
||||
$now = \time();
|
||||
if ( isset( $params['created'] ) ) {
|
||||
$created = (int) $params['created'];
|
||||
if ( $created > $now + MINUTE_IN_SECONDS ) {
|
||||
return new \WP_Error( 'invalid_created', 'The signature creation time is in the future.' );
|
||||
}
|
||||
if ( $created < $now - HOUR_IN_SECONDS ) {
|
||||
return new \WP_Error( 'expired_created', 'The signature creation time is too far in the past.' );
|
||||
}
|
||||
}
|
||||
if ( isset( $params['expires'] ) ) {
|
||||
$expires = (int) $params['expires'];
|
||||
if ( $expires < $now ) {
|
||||
return new \WP_Error( 'expired_signature', 'The signature has expired.' );
|
||||
}
|
||||
if ( $expires > $now + DAY_IN_SECONDS ) {
|
||||
return new \WP_Error( 'invalid_expires', 'The signature expiry time is too far in the future.' );
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Require a time anchor. Both `created` and `expires` are optional
|
||||
* in RFC-9421; a signature without either has no freshness bound
|
||||
* and could be replayed indefinitely.
|
||||
*/
|
||||
if ( ! isset( $params['created'] ) && ! isset( $params['expires'] ) ) {
|
||||
return new \WP_Error( 'missing_time_anchor', 'The signature is missing a time anchor (created or expires).' );
|
||||
}
|
||||
|
||||
// KeyId verification.
|
||||
if ( empty( $params['keyid'] ) ) {
|
||||
return new \WP_Error( 'missing_keyid', 'Missing keyId in signature parameters.' );
|
||||
}
|
||||
|
||||
$public_key = Remote_Actors::get_public_key( $params['keyid'] );
|
||||
if ( \is_wp_error( $public_key ) ) {
|
||||
return $public_key;
|
||||
}
|
||||
|
||||
// Algorithm verification.
|
||||
$algorithm = $this->verify_algorithm( $params['alg'] ?? '', $public_key );
|
||||
if ( \is_wp_error( $algorithm ) ) {
|
||||
return $algorithm;
|
||||
}
|
||||
|
||||
// Digest verification.
|
||||
$result = $this->verify_content_digest( $headers, $body );
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$components = $this->get_component_values( $data['components'], $headers );
|
||||
$signature_base = $this->get_signature_base_string( $components, $params );
|
||||
|
||||
$verified = \openssl_verify( $signature_base, $data['signature'], $public_key, $algorithm ) > 0;
|
||||
if ( ! $verified ) {
|
||||
return new \WP_Error( 'activitypub_signature', 'Invalid signature' );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the Content-Digest header against the request body.
|
||||
*
|
||||
* @param array $headers The HTTP headers.
|
||||
* @param string|null $body The request body, if applicable.
|
||||
* @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
|
||||
*/
|
||||
private function verify_content_digest( $headers, $body ) {
|
||||
if ( ! isset( $headers['content_digest'][0] ) || null === $body ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$digests = \array_map( 'trim', \explode( ',', $headers['content_digest'][0] ) );
|
||||
|
||||
foreach ( $digests as $digest ) {
|
||||
if ( \preg_match( '/^([a-z0-9-]+)=:(.+):$/i', $digest, $matches ) ) {
|
||||
list( , $alg, $encoded ) = $matches;
|
||||
|
||||
if ( ! isset( $this->digest_algorithms[ $alg ] ) ) {
|
||||
return new \WP_Error( 'unsupported_digest', 'WordPress supports sha-256 and sha-512 in Digest header. Offered algorithm: ' . $alg );
|
||||
}
|
||||
|
||||
if ( \hash_equals( $encoded, \base64_encode( \hash( $this->digest_algorithms[ $alg ], $body, true ) ) ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error( 'digest_mismatch', 'Content-Digest header value does not match body.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate the HTTP Signature algorithm from `alg=` parameter and key.
|
||||
*
|
||||
* @param string $alg_string The alg= parameter value (e.g., 'rsa-pss-sha512').
|
||||
* @param resource $public_key An OpenSSL public key resource.
|
||||
*
|
||||
* @return int|\WP_Error OpenSSL algorithm constant or WP_Error.
|
||||
*/
|
||||
private function verify_algorithm( $alg_string, $public_key ) {
|
||||
$details = \openssl_pkey_get_details( $public_key );
|
||||
if ( ! isset( $details['type'] ) ) {
|
||||
return new \WP_Error( 'invalid_key_details', 'Unable to read public key details.' );
|
||||
}
|
||||
|
||||
// If alg_string is empty, determine algorithm based on public key.
|
||||
if ( empty( $alg_string ) ) {
|
||||
switch ( $details['type'] ) {
|
||||
case \OPENSSL_KEYTYPE_RSA:
|
||||
$bits = $details['bits'] ?? 2048;
|
||||
|
||||
if ( $bits >= 4 * KB_IN_BYTES ) {
|
||||
return \OPENSSL_ALGO_SHA512;
|
||||
} elseif ( $bits >= 3 * KB_IN_BYTES ) {
|
||||
return \OPENSSL_ALGO_SHA384;
|
||||
} else {
|
||||
return \OPENSSL_ALGO_SHA256;
|
||||
}
|
||||
|
||||
case \OPENSSL_KEYTYPE_EC:
|
||||
switch ( $details['ec']['curve_name'] ?? '' ) {
|
||||
case 'prime256v1':
|
||||
case 'secp256r1':
|
||||
return \OPENSSL_ALGO_SHA256;
|
||||
case 'secp384r1':
|
||||
return \OPENSSL_ALGO_SHA384;
|
||||
case 'secp521r1':
|
||||
return \OPENSSL_ALGO_SHA512;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$alg_string = \strtolower( $alg_string );
|
||||
if ( \strpos( $alg_string, 'rsa-pss-' ) === 0 && \version_compare( PHP_VERSION, '8.1.0', '<' ) ) {
|
||||
return new \WP_Error( 'unsupported_pss', 'RSA-PSS algorithms are not supported.' );
|
||||
}
|
||||
|
||||
if ( ! isset( $this->algorithms[ $alg_string ] ) ) {
|
||||
return new \WP_Error( 'unsupported_alg', 'Unsupported or unknown alg parameter: ' . $alg_string );
|
||||
}
|
||||
|
||||
if ( $this->algorithms[ $alg_string ]['type'] !== $details['type'] ) {
|
||||
return new \WP_Error( 'alg_key_mismatch', 'Algorithm does not match public key type.' );
|
||||
}
|
||||
|
||||
return $this->algorithms[ $alg_string ]['algo'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base strings to compare the incoming signature with.
|
||||
*
|
||||
* @param array $components Signature components.
|
||||
* @param array $params Signature params.
|
||||
*
|
||||
* @return string Base string to compare signature with.
|
||||
*/
|
||||
private function get_signature_base_string( $components, $params ) {
|
||||
$signature_base = '';
|
||||
|
||||
foreach ( $components as $component => $value ) {
|
||||
$signature_base .= $component . ': ' . $value . "\n";
|
||||
}
|
||||
|
||||
$signature_base .= '"@signature-params": (' . \implode( ' ', \array_keys( $components ) ) . ')';
|
||||
$signature_base .= $this->get_params_string( $params );
|
||||
|
||||
return $signature_base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the signature params in a string format.
|
||||
*
|
||||
* @param array $params Signature params.
|
||||
*
|
||||
* @return string Signature params.
|
||||
*/
|
||||
private function get_params_string( $params ) {
|
||||
$signature_params = '';
|
||||
|
||||
foreach ( $params as $key => $value ) {
|
||||
if ( \is_numeric( $value ) ) {
|
||||
$signature_params .= ';' . $key . '=' . $value; // No quotes.
|
||||
} else {
|
||||
// Escape backslashes and double quotes per RFC-9421.
|
||||
$value = \str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $value );
|
||||
$signature_params .= ';' . $key . '="' . $value . '"'; // Double quotes.
|
||||
}
|
||||
}
|
||||
|
||||
return $signature_params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate signature components.
|
||||
*
|
||||
* @param array $components Signature component names.
|
||||
* @param array $headers HTTP headers.
|
||||
*
|
||||
* @return array Signature components.
|
||||
*/
|
||||
private function get_component_values( $components, $headers ) {
|
||||
$signature_components = array();
|
||||
|
||||
foreach ( $components as $component ) {
|
||||
$key = \strtok( $component, ';' ); // See https://www.rfc-editor.org/rfc/rfc9421.html#name-query-parameters.
|
||||
$key = \strtolower( \trim( $key, '"' ) );
|
||||
|
||||
switch ( $key ) {
|
||||
case '@method':
|
||||
$value = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
break;
|
||||
|
||||
case '@target-uri':
|
||||
$value = \set_url_scheme( '//' . ( $_SERVER['HTTP_HOST'] ?? '' ) . ( $_SERVER['REQUEST_URI'] ?? '/' ) );
|
||||
break;
|
||||
|
||||
case '@authority':
|
||||
$value = $_SERVER['HTTP_HOST'] ?? '';
|
||||
break;
|
||||
|
||||
case '@scheme':
|
||||
$value = \is_ssl() ? 'https' : 'http';
|
||||
break;
|
||||
|
||||
case '@request-target':
|
||||
$value = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
break;
|
||||
|
||||
case '@path':
|
||||
$value = \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH );
|
||||
break;
|
||||
|
||||
case '@query':
|
||||
$value = \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY );
|
||||
$value = $value ? '?' . $value : '';
|
||||
break;
|
||||
|
||||
case '@query-param':
|
||||
$value = '';
|
||||
if ( \preg_match( '/"@query-param";name="(?P<name>[^"]+)"/', $component, $matches ) ) {
|
||||
$query = \wp_parse_args( \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY ) );
|
||||
$value = $query[ $matches['name'] ] ?? '';
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
/** Canonicalize header names. {@see WP_REST_Request::canonicalize_header_name()} */
|
||||
$key = \str_replace( '-', '_', $key );
|
||||
$value = \preg_replace( '/\s+/', ' ', \trim( $headers[ $key ][0] ?? '' ) );
|
||||
}
|
||||
|
||||
$signature_components[ $component ] = $value;
|
||||
}
|
||||
|
||||
return $signature_components;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,394 @@
|
||||
<?php
|
||||
/**
|
||||
* ActivityPub Draft Cavage Signature Standard.
|
||||
*
|
||||
* This class implements the Draft Cavage signature standard for verifying HTTP signatures.
|
||||
*
|
||||
* @package Activitypub\Signature
|
||||
*/
|
||||
|
||||
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions
|
||||
|
||||
namespace Activitypub\Signature;
|
||||
|
||||
use Activitypub\Collection\Remote_Actors;
|
||||
|
||||
/**
|
||||
* Class Http_Signature_Draft.
|
||||
*
|
||||
* Implements the Draft Cavage signature standard for verifying HTTP signatures.
|
||||
*
|
||||
* @see https://tools.ietf.org/html/draft-cavage-http-signatures-12
|
||||
*/
|
||||
class Http_Signature_Draft implements Http_Signature {
|
||||
|
||||
/**
|
||||
* Generate Signature headers for an outgoing HTTP request.
|
||||
*
|
||||
* @param array $args The request arguments.
|
||||
* @param string $url The request URL.
|
||||
*
|
||||
* @return array Request arguments with signature headers.
|
||||
*/
|
||||
public function sign( $args, $url ) {
|
||||
$url_parts = \wp_parse_url( $url );
|
||||
|
||||
$host = $url_parts['host'];
|
||||
$path = '/';
|
||||
|
||||
// Add path.
|
||||
if ( ! empty( $url_parts['path'] ) ) {
|
||||
$path = $url_parts['path'];
|
||||
}
|
||||
|
||||
// Add query.
|
||||
if ( ! empty( $url_parts['query'] ) ) {
|
||||
$path .= '?' . $url_parts['query'];
|
||||
}
|
||||
|
||||
$http_method = \strtolower( $args['method'] );
|
||||
$date = $args['headers']['Date'];
|
||||
|
||||
$signed_parts = array(
|
||||
sprintf( '(request-target): %s %s', $http_method, $path ),
|
||||
sprintf( 'host: %s', $host ),
|
||||
sprintf( 'date: %s', $date ),
|
||||
);
|
||||
$headers_list = array( '(request-target)', 'host', 'date' );
|
||||
|
||||
if ( isset( $args['body'] ) ) {
|
||||
$args['headers']['Digest'] = $this->generate_digest( $args['body'] );
|
||||
$signed_parts[] = sprintf( 'digest: %s', $args['headers']['Digest'] );
|
||||
$headers_list[] = 'digest';
|
||||
}
|
||||
|
||||
if ( isset( $args['headers']['Collection-Synchronization'] ) ) {
|
||||
$signed_parts[] = sprintf( 'collection-synchronization: %s', $args['headers']['Collection-Synchronization'] );
|
||||
$headers_list[] = 'collection-synchronization';
|
||||
}
|
||||
|
||||
$signed_string = implode( "\n", $signed_parts );
|
||||
$headers_list = implode( ' ', $headers_list );
|
||||
|
||||
$signature = null;
|
||||
\openssl_sign( $signed_string, $signature, $args['private_key'], \OPENSSL_ALGO_SHA256 );
|
||||
$signature = \base64_encode( $signature );
|
||||
|
||||
$args['headers']['Signature'] = \sprintf(
|
||||
'keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"',
|
||||
$args['key_id'],
|
||||
$headers_list,
|
||||
$signature
|
||||
);
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the HTTP Signature against a request.
|
||||
*
|
||||
* @param array $headers The HTTP headers.
|
||||
* @param string|null $body The request body, if applicable.
|
||||
* @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
|
||||
*/
|
||||
public function verify( array $headers, $body = null ) {
|
||||
if ( ! isset( $headers['signature'] ) && ! isset( $headers['authorization'] ) ) {
|
||||
return new \WP_Error( 'missing_signature', 'No Signature or Authorization header present.' );
|
||||
}
|
||||
|
||||
$header = $headers['signature'] ?? $headers['authorization'];
|
||||
$parsed = $this->parse_signature_header( $header[0] );
|
||||
|
||||
if ( empty( $parsed['keyId'] ) ) {
|
||||
return new \WP_Error( 'activitypub_signature', 'No Key ID present.' );
|
||||
}
|
||||
|
||||
$public_key = Remote_Actors::get_public_key( $parsed['keyId'] );
|
||||
if ( \is_wp_error( $public_key ) ) {
|
||||
return $public_key;
|
||||
}
|
||||
|
||||
$signed_data = $this->get_signed_data( $parsed['headers'], $parsed, $headers );
|
||||
if ( ! $signed_data ) {
|
||||
return new \WP_Error( 'invalid_signed_data', 'Signed data is invalid or expired.' );
|
||||
}
|
||||
|
||||
$algorithm = $this->get_signature_algorithm( $parsed, $public_key );
|
||||
if ( \is_wp_error( $algorithm ) ) {
|
||||
return $algorithm;
|
||||
}
|
||||
|
||||
// Digest verification.
|
||||
$result = $this->verify_content_digest( $headers, $body );
|
||||
if ( \is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$verified = \openssl_verify( $signed_data, $parsed['signature'], $public_key, $algorithm ) > 0;
|
||||
if ( ! $verified ) {
|
||||
return new \WP_Error( 'activitypub_signature', 'Invalid signature', array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the digest for an HTTP Request.
|
||||
*
|
||||
* @param string $body The body of the request.
|
||||
*
|
||||
* @return string The digest.
|
||||
*/
|
||||
public function generate_digest( $body ) {
|
||||
return 'SHA-256=' . \base64_encode( \hash( 'sha256', $body, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the signature algorithm from the signature header.
|
||||
*
|
||||
* @param array $signature_block The signature block.
|
||||
* @param resource $public_key The public key resource.
|
||||
*
|
||||
* @return int|\WP_Error The signature algorithm or WP_Error if not found.
|
||||
*/
|
||||
private function get_signature_algorithm( $signature_block, $public_key ) {
|
||||
if ( ! empty( $signature_block['algorithm'] ) ) {
|
||||
switch ( $signature_block['algorithm'] ) {
|
||||
case 'hs2019':
|
||||
$details = \openssl_pkey_get_details( $public_key );
|
||||
|
||||
switch ( $details['type'] ?? 0 ) {
|
||||
case \OPENSSL_KEYTYPE_RSA:
|
||||
$bits = $details['bits'] ?? 2048;
|
||||
|
||||
if ( $bits >= 4 * KB_IN_BYTES ) {
|
||||
return \OPENSSL_ALGO_SHA512;
|
||||
} elseif ( $bits >= 3 * KB_IN_BYTES ) {
|
||||
return \OPENSSL_ALGO_SHA384;
|
||||
} else {
|
||||
return \OPENSSL_ALGO_SHA256;
|
||||
}
|
||||
|
||||
case \OPENSSL_KEYTYPE_EC:
|
||||
$curve_name = $details['ec']['curve_name'] ?? '';
|
||||
|
||||
// 3 levels switch statements are fine, right?
|
||||
switch ( $curve_name ) {
|
||||
case 'prime256v1':
|
||||
case 'secp256r1':
|
||||
return \OPENSSL_ALGO_SHA256;
|
||||
case 'secp384r1':
|
||||
return \OPENSSL_ALGO_SHA384;
|
||||
case 'secp521r1':
|
||||
return \OPENSSL_ALGO_SHA512;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error( 'unsupported_key_type', 'Unsupported key type (only RSA and EC keys are supported).', array( 'status' => 401 ) );
|
||||
|
||||
case 'rsa-sha512':
|
||||
return \OPENSSL_ALGO_SHA512;
|
||||
default:
|
||||
return \OPENSSL_ALGO_SHA256;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error( 'unsupported_key_type', 'Unsupported signature algorithm (only rsa-sha256, rsa-sha512, and hs2019 are supported).', array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the Content-Digest header against the request body.
|
||||
*
|
||||
* @param array $headers The HTTP headers.
|
||||
* @param string|null $body The request body, if applicable.
|
||||
* @return bool|\WP_Error True, if the signature is valid, WP_Error on failure.
|
||||
*/
|
||||
private function verify_content_digest( $headers, $body ) {
|
||||
if ( ! isset( $headers['digest'][0] ) || null === $body ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
list( $alg, $digest ) = \explode( '=', $headers['digest'][0], 2 );
|
||||
$alg = \strtolower( $alg );
|
||||
$map = array(
|
||||
'sha-256' => 'sha256',
|
||||
'sha-512' => 'sha512',
|
||||
);
|
||||
|
||||
if ( ! isset( $map[ $alg ] ) ) {
|
||||
return new \WP_Error( 'unsupported_digest', 'WordPress supports SHA-256 and SHA-512 in Digest header. Offered algorithm: ' . $alg, array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
if ( \hash_equals( $digest, \base64_encode( \hash( $map[ $alg ], $body, true ) ) ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new \WP_Error( 'digest_mismatch', 'Digest header value does not match body.', array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Signature header.
|
||||
*
|
||||
* @param string $signature The signature header.
|
||||
*
|
||||
* @return array Signature parts.
|
||||
*/
|
||||
private function parse_signature_header( $signature ) {
|
||||
$parsed_header = array();
|
||||
$matches = array();
|
||||
|
||||
if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['keyId'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/im', $signature, $matches ) ) {
|
||||
$parsed_header['(created)'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/im', $signature, $matches ) ) {
|
||||
$parsed_header['(expires)'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['algorithm'] = trim( $matches[1] );
|
||||
}
|
||||
if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
|
||||
}
|
||||
if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
|
||||
$parsed_header['signature'] = \base64_decode( \preg_replace( '/\s+/', '', \trim( $matches[1] ) ) );
|
||||
}
|
||||
|
||||
if ( empty( $parsed_header['headers'] ) ) {
|
||||
$parsed_header['headers'] = array( 'date' );
|
||||
}
|
||||
|
||||
return $parsed_header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the header data from the included pseudo headers.
|
||||
*
|
||||
* @param array $signed_headers The signed headers.
|
||||
* @param array $signature_block The signature block.
|
||||
* @param array $headers The HTTP headers.
|
||||
*
|
||||
* @return string signed headers for comparison
|
||||
*/
|
||||
private function get_signed_data( $signed_headers, $signature_block, $headers ) {
|
||||
$signed_data = '';
|
||||
$has_time_anchor = false;
|
||||
$now = \time();
|
||||
$max_future_skew = $now + ( 5 * MINUTE_IN_SECONDS );
|
||||
$min_past_skew = $now - HOUR_IN_SECONDS;
|
||||
$max_expires_drift = $now + DAY_IN_SECONDS;
|
||||
|
||||
// This also verifies time-based values by returning false if any of these are out of range.
|
||||
foreach ( $signed_headers as $header ) {
|
||||
if ( 'host' === $header ) {
|
||||
if ( isset( $headers['x_original_host'] ) ) {
|
||||
$signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ( '(request-target)' === $header ) {
|
||||
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
|
||||
continue;
|
||||
}
|
||||
if ( \str_contains( $header, '-' ) ) {
|
||||
$signed_data .= $header . ': ' . $headers[ \str_replace( '-', '_', $header ) ][0] . "\n";
|
||||
continue;
|
||||
}
|
||||
if ( '(created)' === $header ) {
|
||||
if ( empty( $signature_block['(created)'] ) ) {
|
||||
// (created) listed in signed headers but the signature omitted the value.
|
||||
return false;
|
||||
}
|
||||
|
||||
$created = \intval( $signature_block['(created)'] );
|
||||
if ( $created <= 0 || $created > $max_future_skew || $created < $min_past_skew ) {
|
||||
// Created is zero or out of the asymmetric window.
|
||||
return false;
|
||||
}
|
||||
$has_time_anchor = true;
|
||||
|
||||
if ( ! \array_key_exists( '(created)', $headers ) ) {
|
||||
$signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ( '(expires)' === $header ) {
|
||||
if ( empty( $signature_block['(expires)'] ) ) {
|
||||
// (expires) listed in signed headers but the signature omitted the value.
|
||||
return false;
|
||||
}
|
||||
|
||||
$expires = \intval( $signature_block['(expires)'] );
|
||||
|
||||
/*
|
||||
* Reject signatures that have already expired, and also
|
||||
* reject absurdly-far-future expiries that a malicious
|
||||
* sender could use to neuter replay protection.
|
||||
*/
|
||||
if ( $expires < $now || $expires > $max_expires_drift ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* A validated (expires) bounds the signature's lifetime
|
||||
* to at most one day in the future, so it's a legitimate
|
||||
* freshness signal on its own.
|
||||
*/
|
||||
$has_time_anchor = true;
|
||||
|
||||
if ( ! \array_key_exists( '(expires)', $headers ) ) {
|
||||
$signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ( 'date' === $header ) {
|
||||
$has_time_anchor = true;
|
||||
if ( empty( $headers['date'][0] ) ) {
|
||||
// Date is in the signed headers list but missing from the request.
|
||||
return false;
|
||||
}
|
||||
|
||||
$date = \date_create( $headers['date'][0] );
|
||||
if ( ! $date ) {
|
||||
// Malformed Date header — refuse rather than fatal on setTimeZone().
|
||||
return false;
|
||||
}
|
||||
$date->setTimeZone( \timezone_open( 'UTC' ) );
|
||||
$date = $date->format( 'U' );
|
||||
|
||||
/*
|
||||
* Asymmetric skew tolerance.
|
||||
*
|
||||
* Future-dated signatures are tolerated by up to five minutes
|
||||
* of clock drift; anything further is either a misconfigured
|
||||
* peer or a forged replay envelope.
|
||||
*
|
||||
* Past-dated signatures are tolerated for up to an hour so
|
||||
* that retried / queued federation traffic from peers with
|
||||
* backed-up outboxes still verifies.
|
||||
*/
|
||||
if ( $date > $max_future_skew || $date < $min_past_skew ) {
|
||||
// Time out of range.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! empty( $headers[ $header ][0] ) ) {
|
||||
$signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Require a signed time anchor (Date or (created)). Without one,
|
||||
* a captured signed request could be replayed indefinitely because
|
||||
* no field inside the signed base string bounds its freshness.
|
||||
*/
|
||||
if ( ! $has_time_anchor ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \rtrim( $signed_data, "\n" );
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* Interface for HTTP Signature.
|
||||
*
|
||||
* This interface defines the methods required for verifying HTTP signatures
|
||||
* according to various standards, such as Draft Cavage and HTTP Message Signature.
|
||||
*
|
||||
* @package Activitypub\Signature
|
||||
*/
|
||||
|
||||
namespace Activitypub\Signature;
|
||||
|
||||
/**
|
||||
* Interface Http_Signature.
|
||||
*/
|
||||
interface Http_Signature {
|
||||
|
||||
/**
|
||||
* Generate Signature headers for an outgoing HTTP request.
|
||||
*
|
||||
* @param array $args The request arguments.
|
||||
* @param string $url The request URL.
|
||||
*
|
||||
* @return array Request arguments with signature headers.
|
||||
*/
|
||||
public function sign( $args, $url );
|
||||
|
||||
/**
|
||||
* Verify the HTTP Signature against a request.
|
||||
*
|
||||
* @param array $headers The HTTP headers.
|
||||
* @param string|null $body The request body, if applicable.
|
||||
* @return bool|\WP_Error
|
||||
*/
|
||||
public function verify( array $headers, $body = null );
|
||||
|
||||
/**
|
||||
* Generate a digest for the request body.
|
||||
*
|
||||
* @param string $body The request body.
|
||||
*
|
||||
* @return string The digest.
|
||||
*/
|
||||
public function generate_digest( $body );
|
||||
}
|
||||
Reference in New Issue
Block a user