diff --git a/.github/changelog/add-fasp-support b/.github/changelog/add-fasp-support new file mode 100644 index 0000000000..04456ce44c --- /dev/null +++ b/.github/changelog/add-fasp-support @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add support for auxiliary fediverse services like moderation tools and search providers. diff --git a/FEDERATION.md b/FEDERATION.md index d0c2b4ff78..03be5f82a5 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -11,6 +11,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio - [HTTP Signatures](https://swicg.github.io/activitypub-http-signature/) - [NodeInfo](https://nodeinfo.diaspora.software/) - [Interaction Policy](https://docs.gotosocial.org/en/latest/federation/interaction_policy/) +- [FASP](https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/) (Fediverse Auxiliary Service Provider) ## Supported FEPs diff --git a/activitypub.php b/activitypub.php index d0cefa4915..9fcd9eeefe 100644 --- a/activitypub.php +++ b/activitypub.php @@ -79,6 +79,11 @@ function rest_init() { ( new Rest\Nodeinfo_Controller() )->register_routes(); } ( new Rest\Proxy_Controller() )->register_routes(); + + // Load FASP endpoints only if enabled. + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + ( new Rest\Fasp_Controller() )->register_routes(); + } } \add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' ); @@ -154,6 +159,11 @@ function plugin_admin_init() { \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\User_Settings_Fields', 'init' ) ); \add_action( 'admin_init', array( __NAMESPACE__ . '\WP_Admin\Welcome_Fields', 'init' ) ); + // Only load FASP admin actions if enabled. + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + \add_action( 'admin_init', array( WP_Admin\Fasp_Settings::class, 'init' ) ); + } + if ( defined( 'WP_LOAD_IMPORTERS' ) && WP_LOAD_IMPORTERS ) { require_once __DIR__ . '/includes/wp-admin/import/load.php'; \add_action( 'admin_init', __NAMESPACE__ . '\WP_Admin\Import\load' ); diff --git a/assets/css/activitypub-admin.css b/assets/css/activitypub-admin.css index 225b95bb38..36cc804872 100644 --- a/assets/css/activitypub-admin.css +++ b/assets/css/activitypub-admin.css @@ -401,6 +401,112 @@ input.blog-user-identifier { } } +/* FASP Registrations */ +.fasp-registrations-list { + margin-bottom: 2em; +} + +.fasp-registration-card { + margin: 10px 0; + padding: 15px; + background: #fff; +} + +.fasp-registration-card.highlighted { + border-color: #3582c4; + box-shadow: 0 0 0 1px #3582c4; +} + +.fasp-registration-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.fasp-registration-name { + font-size: 14px; + font-weight: 600; + margin: 0; +} + +.fasp-registration-actions { + display: flex; + gap: 10px; +} + +.fasp-registration-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 10px; +} + +.fasp-registration-detail { + background: #f6f7f7; + padding: 10px; + border-radius: 3px; +} + +.fasp-registration-detail strong { + display: block; + margin-bottom: 5px; + font-size: 11px; + text-transform: uppercase; + color: #50575e; +} + +.fasp-registration-detail p.description { /* stylelint-disable-line no-descending-specificity */ + font-size: 12px; + margin: 5px 0; +} + +.fasp-registration-fingerprint { + margin-top: 10px; +} + +.fasp-fingerprint { + display: block; + font-size: 12px; + word-break: break-all; + background: #f0f0f1; + padding: 8px; + border-radius: 3px; + margin-top: 5px; +} + +.fasp-technical-details { + margin-top: 10px; + border-top: 1px solid #f0f0f1; + padding-top: 10px; +} + +.fasp-technical-details summary { + font-size: 13px; + padding: 5px 0; +} + +.fasp-technical-details .fasp-registration-details { + margin-top: 10px; +} + +/* stylelint-disable no-descending-specificity */ +.fasp-empty-state { + text-align: center; + padding: 40px 20px; + background: #f6f7f7; + border-radius: 4px; +} + +.fasp-empty-state p { + margin: 0 0 10px; +} + +.fasp-empty-state p.description { + margin: 0; +} +/* stylelint-enable no-descending-specificity */ + @media screen and (max-width: 782px) { .activitypub-settings { @@ -411,4 +517,14 @@ input.blog-user-identifier { max-width: calc(100% - 36px); width: 100%; } + + .fasp-registration-details { + grid-template-columns: 1fr; + } + + .fasp-registration-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } } diff --git a/includes/class-options.php b/includes/class-options.php index a5dd23bc44..45174ca1a5 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -393,6 +393,16 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub_advanced', + 'activitypub_enable_fasp', + array( + 'type' => 'boolean', + 'description' => 'Enable Fediverse Auxiliary Service Providers (FASP) integration.', + 'default' => false, + ) + ); + $default_distribution = self::get_distribution_preset_values()['default']; \register_setting( diff --git a/includes/fasp/class-client.php b/includes/fasp/class-client.php new file mode 100644 index 0000000000..601531dcdb --- /dev/null +++ b/includes/fasp/class-client.php @@ -0,0 +1,223 @@ + $method, + 'timeout' => 10, + 'headers' => array( + 'Accept' => 'application/json', + ), + ); + + if ( null !== $body ) { + $args['body'] = $body; + $args['headers']['Content-Type'] = 'application/json'; + } + + $signature = new Http_Message_Signature(); + $args = $signature->sign_request_ed25519( $args, $url, $private_key, $registration['server_id'] ); + + $response = \wp_safe_remote_request( $url, $args ); + if ( \is_wp_error( $response ) ) { + return $response; + } + + $verification = self::verify_response( $registration, $response ); + if ( \is_wp_error( $verification ) ) { + return $verification; + } + + return $response; + } + + /** + * Verify the signature of a FASP response. + * + * The FASP spec requires all responses to be signed with the provider's + * Ed25519 key over `@status` and `content-digest`. + * + * @param array $registration The registration record. + * @param array $response The HTTP response array. + * @return true|\WP_Error True if the response signature is valid, WP_Error otherwise. + */ + private static function verify_response( $registration, $response ) { + $public_key = \base64_decode( $registration['fasp_public_key'] ?? '' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + if ( ! $public_key ) { + return new \WP_Error( + 'fasp_missing_public_key', + \__( 'No public key is stored for this auxiliary service.', 'activitypub' ) + ); + } + + $headers = \wp_remote_retrieve_headers( $response ); + if ( \is_object( $headers ) ) { + $headers = $headers->getAll(); + } + + $signature = new Http_Message_Signature(); + $verified = $signature->verify_response( + \wp_remote_retrieve_response_code( $response ), + $headers, + \wp_remote_retrieve_body( $response ), + $public_key + ); + + if ( \is_wp_error( $verified ) ) { + return $verified; + } + + return true; + } +} diff --git a/includes/fasp/class-registrations.php b/includes/fasp/class-registrations.php new file mode 100644 index 0000000000..951c674704 --- /dev/null +++ b/includes/fasp/class-registrations.php @@ -0,0 +1,336 @@ + \wp_generate_uuid4(), + 'name' => $data['name'], + 'base_url' => \untrailingslashit( $data['base_url'] ), + 'server_id' => $data['server_id'], + 'fasp_public_key' => $data['fasp_public_key'], + 'fasp_public_key_fingerprint' => self::get_public_key_fingerprint( $data['fasp_public_key'] ), + 'server_public_key' => \base64_encode( $public_key ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'server_private_key' => \base64_encode( $private_key ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'status' => 'pending', + 'requested_at' => \current_time( 'mysql', true ), + ); + + $registrations = self::get_registrations_store(); + + $registrations[ $registration['fasp_id'] ] = $registration; + + if ( ! \update_option( self::OPTION_REGISTRATIONS, $registrations, false ) ) { + return false; + } + + return $registration; + } + + /** + * Get registration by FASP ID. + * + * @param string $fasp_id FASP ID. + * @return array|null Registration data or null if not found. + */ + public static function get( $fasp_id ) { + $registrations = self::get_registrations_store(); + + return isset( $registrations[ $fasp_id ] ) ? $registrations[ $fasp_id ] : null; + } + + /** + * Get registration by server ID. + * + * @param string $server_id The server ID the FASP generated for this site. + * @return array|null Registration data or null if not found. + */ + public static function get_by_server_id( $server_id ) { + foreach ( self::get_registrations_store() as $registration ) { + if ( isset( $registration['server_id'] ) && $registration['server_id'] === $server_id ) { + return $registration; + } + } + + return null; + } + + /** + * Get registrations filtered by status. + * + * @param string $status The status to filter by ('pending', 'approved', 'rejected'). + * @return array Array of matching registrations, sorted newest first. + */ + public static function get_by_status( $status ) { + $filtered = array(); + + foreach ( self::get_registrations_store() as $registration ) { + if ( $status === $registration['status'] ) { + $filtered[] = $registration; + } + } + + \usort( + $filtered, + function ( $a, $b ) use ( $status ) { + $key = 'approved' === $status ? 'approved_at' : 'requested_at'; + return ( $b[ $key ] ?? '' ) <=> ( $a[ $key ] ?? '' ); + } + ); + + return $filtered; + } + + /** + * Approve a registration request. + * + * @param string $fasp_id FASP ID. + * @param int $user_id User ID who approved. + * @return bool True on success, false on failure. + */ + public static function approve( $fasp_id, $user_id ) { + $registrations = self::get_registrations_store(); + + if ( ! isset( $registrations[ $fasp_id ] ) ) { + return false; + } + + $registrations[ $fasp_id ]['status'] = 'approved'; + $registrations[ $fasp_id ]['approved_at'] = \current_time( 'mysql', true ); + $registrations[ $fasp_id ]['approved_by'] = $user_id; + + return \update_option( self::OPTION_REGISTRATIONS, $registrations, false ); + } + + /** + * Reject a registration request. + * + * @param string $fasp_id FASP ID. + * @param int $user_id User ID who rejected. + * @return bool True on success, false on failure. + */ + public static function reject( $fasp_id, $user_id ) { + $registrations = self::get_registrations_store(); + + if ( ! isset( $registrations[ $fasp_id ] ) ) { + return false; + } + + $registrations[ $fasp_id ]['status'] = 'rejected'; + $registrations[ $fasp_id ]['rejected_at'] = \current_time( 'mysql', true ); + $registrations[ $fasp_id ]['rejected_by'] = $user_id; + + return \update_option( self::OPTION_REGISTRATIONS, $registrations, false ); + } + + /** + * Delete a registration and its capability state. + * + * @param string $fasp_id FASP ID. + * @return bool True on success, false on failure. + */ + public static function delete( $fasp_id ) { + $registrations = self::get_registrations_store(); + + if ( ! isset( $registrations[ $fasp_id ] ) ) { + return false; + } + + unset( $registrations[ $fasp_id ] ); + + $capabilities = self::get_capabilities_store(); + foreach ( \array_keys( $capabilities ) as $key ) { + if ( ( $capabilities[ $key ]['fasp_id'] ?? null ) === $fasp_id ) { + unset( $capabilities[ $key ] ); + } + } + \update_option( self::OPTION_CAPABILITIES, $capabilities, false ); + + \delete_transient( 'activitypub_fasp_provider_info_' . $fasp_id ); + + return \update_option( self::OPTION_REGISTRATIONS, $registrations, false ); + } + + /** + * Generate public key fingerprint. + * + * The fingerprint is the base64 encoded SHA-256 hash of the (raw) public + * key, as defined by the FASP registration spec. + * + * @param string $public_key Base64 encoded public key. + * @return string SHA-256 fingerprint, base64 encoded. + */ + public static function get_public_key_fingerprint( $public_key ) { + $decoded_key = \base64_decode( $public_key ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $hash = \hash( 'sha256', $decoded_key, true ); + + return \base64_encode( $hash ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + + /** + * Get enabled capabilities for a FASP. + * + * @param string $fasp_id FASP ID. + * @return array Array of enabled capabilities. + */ + public static function get_enabled_capabilities( $fasp_id ) { + $enabled = array(); + + foreach ( self::get_capabilities_store() as $capability ) { + if ( $capability['fasp_id'] === $fasp_id && $capability['enabled'] ) { + $enabled[] = $capability; + } + } + + return $enabled; + } + + /** + * Check if a FASP has a specific capability enabled. + * + * @param string $fasp_id FASP ID. + * @param string $identifier Capability identifier. + * @param string $version Capability version. + * @return bool True if capability is enabled, false otherwise. + */ + public static function is_capability_enabled( $fasp_id, $identifier, $version ) { + $capabilities = self::get_capabilities_store(); + $capability_key = $fasp_id . '_' . $identifier . '_v' . $version; + + return isset( $capabilities[ $capability_key ] ) && $capabilities[ $capability_key ]['enabled']; + } + + /** + * Mark a capability as enabled for a FASP. + * + * This only records local state. Callers are responsible for notifying + * the FASP first, see {@see Client::activate_capability()}. + * + * @param string $fasp_id FASP ID. + * @param string $identifier Capability identifier. + * @param string $version Capability version. + * @return bool True on success, false on failure. + */ + public static function enable_capability( $fasp_id, $identifier, $version ) { + $capabilities = self::get_capabilities_store(); + $capability_key = $fasp_id . '_' . $identifier . '_v' . $version; + + $capabilities[ $capability_key ] = array( + 'fasp_id' => $fasp_id, + 'identifier' => $identifier, + 'version' => $version, + 'enabled' => true, + 'updated_at' => \current_time( 'mysql', true ), + ); + + return \update_option( self::OPTION_CAPABILITIES, $capabilities, false ); + } + + /** + * Mark a capability as disabled for a FASP. + * + * This only records local state. Callers are responsible for notifying + * the FASP first, see {@see Client::deactivate_capability()}. + * + * @param string $fasp_id FASP ID. + * @param string $identifier Capability identifier. + * @param string $version Capability version. + * @return bool True on success, false on failure. + */ + public static function disable_capability( $fasp_id, $identifier, $version ) { + $capabilities = self::get_capabilities_store(); + $capability_key = $fasp_id . '_' . $identifier . '_v' . $version; + + if ( isset( $capabilities[ $capability_key ] ) ) { + $capabilities[ $capability_key ]['enabled'] = false; + $capabilities[ $capability_key ]['updated_at'] = \current_time( 'mysql', true ); + } + + return \update_option( self::OPTION_CAPABILITIES, $capabilities, false ); + } + + /** + * Retrieve registrations, ensuring the option exists and is non-autoloaded. + * + * @return array + */ + private static function get_registrations_store() { + $registrations = \get_option( self::OPTION_REGISTRATIONS, null ); + + if ( null === $registrations ) { + \add_option( self::OPTION_REGISTRATIONS, array(), '', false ); + return array(); + } + + return \is_array( $registrations ) ? $registrations : array(); + } + + /** + * Retrieve capabilities store ensuring the option exists and is non-autoloaded. + * + * @return array + */ + private static function get_capabilities_store() { + $capabilities = \get_option( self::OPTION_CAPABILITIES, null ); + + if ( null === $capabilities ) { + \add_option( self::OPTION_CAPABILITIES, array(), '', false ); + return array(); + } + + return \is_array( $capabilities ) ? $capabilities : array(); + } +} diff --git a/includes/rest/class-fasp-controller.php b/includes/rest/class-fasp-controller.php new file mode 100644 index 0000000000..b437d45b7d --- /dev/null +++ b/includes/rest/class-fasp-controller.php @@ -0,0 +1,280 @@ +namespace, + '/' . $this->rest_base . '/registration', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'handle_registration' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'name' => array( + 'required' => true, + 'type' => 'string', + 'description' => 'The name of the FASP.', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'baseUrl' => array( + 'required' => true, + 'type' => 'string', + 'format' => 'uri', + 'description' => 'The base URL of the FASP (must be HTTPS).', + 'sanitize_callback' => 'esc_url_raw', + 'validate_callback' => array( $this, 'validate_https_url' ), + ), + 'serverId' => array( + 'required' => true, + 'type' => 'string', + 'description' => 'The identifier the FASP generated for this server.', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'publicKey' => array( + 'required' => true, + 'type' => 'string', + 'description' => 'The FASP public key, base64 encoded.', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ), + 'schema' => array( $this, 'get_registration_schema' ), + ) + ); + } + + /** + * Handle FASP registration requests. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response|\WP_Error The response or error. + */ + public function handle_registration( $request ) { + // Rate-limit registrations to prevent DB spam (max 10 per minute per IP). + $rate_limit = $this->check_rate_limit(); + if ( \is_wp_error( $rate_limit ) ) { + return $rate_limit; + } + + $fasp_public_key = $request->get_param( 'publicKey' ); + $server_id = $request->get_param( 'serverId' ); + + // Validate Ed25519 public key format (must be valid base64, 32 bytes when decoded). + $validation = $this->validate_ed25519_public_key( $fasp_public_key ); + if ( \is_wp_error( $validation ) ) { + return $validation; + } + + // Enforce serverId uniqueness. + if ( Registrations::get_by_server_id( $server_id ) ) { + return new \WP_Error( + 'server_id_exists', + 'A FASP with this serverId is already registered', + array( 'status' => 409 ) + ); + } + + $registration = Registrations::create( + array( + 'name' => $request->get_param( 'name' ), + 'base_url' => $request->get_param( 'baseUrl' ), + 'server_id' => $server_id, + 'fasp_public_key' => $fasp_public_key, + ) + ); + + if ( ! $registration ) { + return new \WP_Error( + 'storage_failed', + 'Failed to store registration request', + array( 'status' => 500 ) + ); + } + + $response_data = array( + 'faspId' => $registration['fasp_id'], + 'publicKey' => $registration['server_public_key'], + 'registrationCompletionUri' => \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&highlight=' . \rawurlencode( $registration['fasp_id'] ) ), + ); + + $response = new \WP_REST_Response( $response_data, 201 ); + + /* + * The FASP spec requires all responses to be signed over `@status` and + * `content-digest`, using the keypair generated for this registration + * under the serverId the provider allocated for this site. + */ + $signature = new Http_Message_Signature(); + $response->header( 'Content-Digest', $signature->generate_digest( \wp_json_encode( $response_data ) ) ); + $signature->sign_response_ed25519( + $response, + \base64_decode( $registration['server_private_key'] ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $registration['server_id'] + ); + + return $response; + } + + /** + * Rate-limit registration requests per IP. + * + * @return true|\WP_Error True if the request may proceed, WP_Error (429) otherwise. + */ + private function check_rate_limit() { + $ip = get_client_ip(); + if ( '' === $ip ) { + return $this->rate_limit_error(); + } + + $transient_key = 'ap_fasp_reg_' . \md5( $ip ); + $count = (int) \get_transient( $transient_key ); + + if ( $count >= 10 ) { + return $this->rate_limit_error(); + } + + \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); + + return true; + } + + /** + * Build the rate-limit error. + * + * @return \WP_Error The 429 error. + */ + private function rate_limit_error() { + return new \WP_Error( + 'activitypub_rate_limited', + \__( 'Too many registration requests. Please try again later.', 'activitypub' ), + array( 'status' => 429 ) + ); + } + + /** + * Get the schema for registration endpoint. + * + * @return array The schema. + */ + public function get_registration_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'FASP Registration Request', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'The name of the FASP provider.', + ), + 'baseUrl' => array( + 'type' => 'string', + 'format' => 'uri', + 'description' => 'The base URL of the FASP provider.', + ), + 'serverId' => array( + 'type' => 'string', + 'description' => 'The server ID generated by the FASP.', + ), + 'publicKey' => array( + 'type' => 'string', + 'description' => 'The FASP public key, base64 encoded.', + ), + ), + 'required' => array( 'name', 'baseUrl', 'serverId', 'publicKey' ), + ); + } + + /** + * Validate an Ed25519 public key format. + * + * @param string $public_key The base64-encoded public key. + * @return true|\WP_Error True if valid, WP_Error otherwise. + */ + private function validate_ed25519_public_key( $public_key ) { + // Check if valid base64. + $decoded = \base64_decode( $public_key, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + if ( false === $decoded ) { + return new \WP_Error( + 'invalid_public_key', + 'Public key is not valid base64', + array( 'status' => 400 ) + ); + } + + // Ed25519 public keys must be exactly 32 bytes. + if ( \strlen( $decoded ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) { + return new \WP_Error( + 'invalid_public_key_length', + \sprintf( + 'Invalid Ed25519 public key length: expected %d bytes, got %d', + SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, + \strlen( $decoded ) + ), + array( 'status' => 400 ) + ); + } + + return true; + } + + /** + * Validate that a URL uses HTTPS scheme. + * + * @param string $url The URL to validate. + * @return true|\WP_Error True if valid, WP_Error otherwise. + */ + public function validate_https_url( $url ) { + $scheme = \wp_parse_url( $url, \PHP_URL_SCHEME ); + + if ( 'https' !== $scheme ) { + return new \WP_Error( + 'invalid_url_scheme', + \__( 'The base URL must use HTTPS.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + return true; + } +} diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php index 6d0dd2551d..8f462147c2 100644 --- a/includes/signature/class-http-message-signature.php +++ b/includes/signature/class-http-message-signature.php @@ -130,6 +130,274 @@ public function sign( $args, $url ) { return $args; } + /** + * Sign an outgoing HTTP request with Ed25519 (RFC-9421 HTTP Message Signatures). + * + * Covers the derived components `@method` and `@target-uri` and the + * `content-digest` header with the parameters `created` and `keyid`, + * as required by the FASP specification. + * + * @see https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/protocol_basics.md + * + * @since unreleased + * + * @param array $args The request arguments, as passed to `wp_remote_request()`. + * @param string $url The request URL. + * @param string $private_key The Ed25519 private key (raw binary, 64 bytes). + * @param string $key_id The key ID to use in the signature. + * + * @return array Request arguments with `Content-Digest`, `Signature-Input` and `Signature` headers added. + */ + public function sign_request_ed25519( $args, $url, $private_key, $key_id ) { + // The FASP spec requires a Content-Digest on all requests, even body-less ones. + $digest = $this->generate_digest( $args['body'] ?? '' ); + + $components = array( + '"@method"' => \strtoupper( $args['method'] ?? 'GET' ), + '"@target-uri"' => $url, + '"content-digest"' => $digest, + ); + + $params = array( + 'created' => \time(), + 'keyid' => $key_id, + ); + + $signature_base = $this->get_signature_base_string( $components, $params ); + $signature = \sodium_crypto_sign_detached( $signature_base, $private_key ); + $signature = \base64_encode( $signature ); + + $args['headers']['Content-Digest'] = $digest; + $args['headers']['Signature-Input'] = 'sig=(' . \implode( ' ', \array_keys( $components ) ) . ')' . $this->get_params_string( $params ); + $args['headers']['Signature'] = 'sig=:' . $signature . ':'; + + return $args; + } + + /** + * Sign a WP_REST_Response with Ed25519 (RFC-9421 HTTP Message Signatures). + * + * Response signatures cover the derived component `@status` and the + * `content-digest` header, as required by the FASP specification. The + * response must already carry a `Content-Digest` header. + * + * @since unreleased + * + * @param \WP_REST_Response $response The response to sign. + * @param string $private_key The Ed25519 private key (raw binary, 64 bytes). + * @param string $key_id The key ID to use in the signature. + * + * @return \WP_REST_Response The response with signature headers added. + */ + public function sign_response_ed25519( $response, $private_key, $key_id ) { + $components = array( + '"@status"' => (string) $response->get_status(), + '"content-digest"' => $response->get_headers()['Content-Digest'] ?? '', + ); + + $params = array( + 'created' => \time(), + 'keyid' => $key_id, + ); + + $signature_base = $this->get_signature_base_string( $components, $params ); + $signature = \sodium_crypto_sign_detached( $signature_base, $private_key ); + $signature = \base64_encode( $signature ); + + $response->header( 'Signature-Input', 'sig=(' . \implode( ' ', \array_keys( $components ) ) . ')' . $this->get_params_string( $params ) ); + $response->header( 'Signature', 'sig=:' . $signature . ':' ); + + return $response; + } + + /** + * Verify an Ed25519-signed HTTP response (RFC-9421 HTTP Message Signatures). + * + * Verifies a response signature covering the derived component `@status` + * and the `content-digest` header against a known public key, as used by + * the FASP protocol where the signer's key is exchanged at registration. + * + * @since unreleased + * + * @param int $status The HTTP response status code. + * @param array $headers The response headers as a flat name => value array. + * @param string $body The response body. + * @param string $public_key The signer's Ed25519 public key (raw binary, 32 bytes). + * + * @return string|\WP_Error The verified keyId on success, WP_Error on failure. + */ + public function verify_response( $status, array $headers, $body, $public_key ) { + // Normalize headers to the internal `name_with_underscores => array( value )` shape. + $normalized = array(); + foreach ( $headers as $name => $value ) { + $normalized[ \str_replace( '-', '_', \strtolower( $name ) ) ] = (array) $value; + } + + if ( empty( $normalized['signature_input'][0] ) || empty( $normalized['signature'][0] ) ) { + return new \WP_Error( 'missing_signature', 'The response is not signed.', array( 'status' => 401 ) ); + } + + $parsed = $this->parse_signature_labels( $normalized ); + if ( \is_wp_error( $parsed ) ) { + return $parsed; + } + + $digest_check = $this->verify_content_digest( $normalized, $body ); + if ( \is_wp_error( $digest_check ) ) { + return $digest_check; + } + + $errors = new \WP_Error(); + foreach ( $parsed as $data ) { + $result = $this->verify_response_label( $data, $status, $normalized, $public_key ); + if ( true === $result ) { + return $data['params']['keyid']; + } + + $errors->add( $result->get_error_code(), $result->get_error_message() ); + } + + $errors->add_data( array( 'status' => 401 ) ); + + return $errors; + } + + /** + * Verify a single Ed25519 response signature label. + * + * @param array $data Parsed signature data. + * @param int $status The HTTP response status code. + * @param array $headers Normalized response headers. + * @param string $public_key The signer's Ed25519 public key (raw binary, 32 bytes). + * + * @return bool|\WP_Error True if the signature is valid, WP_Error on failure. + */ + private function verify_response_label( $data, $status, $headers, $public_key ) { + $params = $data['params']; + + $result = $this->verify_timestamps( $params ); + if ( \is_wp_error( $result ) ) { + return $result; + } + + if ( empty( $params['keyid'] ) ) { + return new \WP_Error( 'missing_keyid', 'Missing keyId in signature parameters.' ); + } + + // Response signatures must cover @status and content-digest. + $covered = \array_map( + function ( $component ) { + return \strtolower( \trim( $component, '"' ) ); + }, + $data['components'] + ); + if ( ! \in_array( '@status', $covered, true ) || ! \in_array( 'content-digest', $covered, true ) ) { + return new \WP_Error( 'invalid_components', 'The response signature does not cover @status and content-digest.' ); + } + + $components = array(); + foreach ( $data['components'] as $component ) { + $key = \strtolower( \trim( $component, '"' ) ); + + if ( '@status' === $key ) { + $components[ $component ] = (string) $status; + } else { + $components[ $component ] = \preg_replace( '/\s+/', ' ', \trim( $headers[ \str_replace( '-', '_', $key ) ][0] ?? '' ) ); + } + } + + $signature_base = $this->get_signature_base_string( $components, $params ); + + return $this->verify_ed25519_signature( $signature_base, $data['signature'], $public_key ); + } + + /** + * Verify an Ed25519 signature. + * + * @since unreleased + * + * @param string $message The message that was signed. + * @param string $signature The signature to verify. + * @param string $public_key The Ed25519 public key (raw binary, 32 bytes). + * + * @return bool|\WP_Error True if valid, WP_Error on failure. + */ + private function verify_ed25519_signature( $message, $signature, $public_key ) { + if ( \strlen( $signature ) !== SODIUM_CRYPTO_SIGN_BYTES ) { + return new \WP_Error( + 'invalid_signature_length', + \sprintf( 'Invalid Ed25519 signature length: expected %d bytes, got %d', SODIUM_CRYPTO_SIGN_BYTES, \strlen( $signature ) ) + ); + } + + if ( \strlen( $public_key ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) { + return new \WP_Error( + 'invalid_key_length', + \sprintf( 'Invalid Ed25519 public key length: expected %d bytes, got %d', SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, \strlen( $public_key ) ) + ); + } + + try { + $verified = \sodium_crypto_sign_verify_detached( $signature, $message, $public_key ); + } catch ( \Exception $e ) { + return new \WP_Error( 'ed25519_verification_failed', 'Ed25519 signature verification failed: ' . $e->getMessage() ); + } + + if ( ! $verified ) { + return new \WP_Error( 'activitypub_signature', 'Invalid Ed25519 signature' ); + } + + return true; + } + + /** + * Verify the `created` and `expires` signature parameters. + * + * 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. + * + * @since unreleased + * + * @param array $params Signature parameters. + * + * @return bool|\WP_Error True if the timestamps are acceptable, WP_Error otherwise. + */ + private function verify_timestamps( $params ) { + $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).' ); + } + + return true; + } + /** * Verify the HTTP Signature against a request. * @@ -227,42 +495,9 @@ private function parse_signature_labels( array $headers ) { 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).' ); + $result = $this->verify_timestamps( $params ); + if ( \is_wp_error( $result ) ) { + return $result; } // KeyId verification. diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php index ebcaf42356..5f3b646fe3 100644 --- a/includes/wp-admin/class-advanced-settings-fields.php +++ b/includes/wp-admin/class-advanced-settings-fields.php @@ -131,6 +131,15 @@ public static function register_advanced_fields() { 'activitypub_advanced_settings', array( 'label_for' => 'activitypub_object_type' ) ); + + \add_settings_field( + 'activitypub_enable_fasp', + \__( 'Auxiliary Services', 'activitypub' ), + array( self::class, 'render_fasp_field' ), + 'activitypub_advanced_settings', + 'activitypub_advanced_settings', + array( 'label_for' => 'activitypub_enable_fasp' ) + ); } /** @@ -313,6 +322,27 @@ public static function render_object_type_field() { +
+ +
++ +
++ +
+ '1' ) : array( 'error' => '1' ) ); + } + + /** + * Handle reject FASP registration action. + */ + public static function reject_registration() { + $fasp_id = self::verify_action_request( 'fasp_registration_' ); + + $result = Registrations::reject( $fasp_id, \get_current_user_id() ); + + self::redirect( $result ? array( 'rejected' => '1' ) : array( 'error' => '1' ) ); + } + + /** + * Handle delete FASP registration action. + */ + public static function delete_registration() { + $fasp_id = self::verify_action_request( 'fasp_registration_' ); + + $result = Registrations::delete( $fasp_id ); + + self::redirect( $result ? array( 'deleted' => '1' ) : array( 'error' => '1' ) ); + } + + /** + * Handle enabling or disabling a provider capability. + * + * Notifies the provider first, per the FASP spec, and only records the + * local state change when the provider acknowledged the call. + */ + public static function toggle_capability() { + $fasp_id = self::verify_action_request( 'fasp_capability_' ); + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- Verified in verify_action_request(). + $identifier = isset( $_POST['identifier'] ) ? \sanitize_text_field( \wp_unslash( $_POST['identifier'] ) ) : ''; + $version = isset( $_POST['version'] ) ? \sanitize_text_field( \wp_unslash( $_POST['version'] ) ) : ''; + $enable = isset( $_POST['enable'] ) && '1' === $_POST['enable']; + // phpcs:enable WordPress.Security.NonceVerification.Missing + + $registration = Registrations::get( $fasp_id ); + + if ( ! $registration || 'approved' !== $registration['status'] || ! $identifier || ! $version ) { + self::redirect( array( 'error' => '1' ) ); + } + + if ( $enable ) { + $result = Client::activate_capability( $registration, $identifier, $version ); + } else { + $result = Client::deactivate_capability( $registration, $identifier, $version ); + } + + if ( \is_wp_error( $result ) ) { + self::redirect( array( 'error' => '1' ) ); + } + + if ( $enable ) { + Registrations::enable_capability( $fasp_id, $identifier, $version ); + } else { + Registrations::disable_capability( $fasp_id, $identifier, $version ); + } + + self::redirect( array( 'capability_updated' => '1' ) ); + } + + /** + * Handle refreshing the cached provider info of a FASP. + */ + public static function refresh_provider_info() { + $fasp_id = self::verify_action_request( 'fasp_registration_' ); + + \delete_transient( Client::PROVIDER_INFO_TRANSIENT . $fasp_id ); + + self::redirect( array( 'highlight' => $fasp_id ) ); + } + + /** + * Register settings errors based on query parameters from FASP admin actions. + */ + public static function process_admin_notices() { + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $page = isset( $_GET['page'] ) ? \sanitize_text_field( \wp_unslash( $_GET['page'] ) ) : ''; + $tab = isset( $_GET['tab'] ) ? \sanitize_text_field( \wp_unslash( $_GET['tab'] ) ) : ''; + + if ( 'activitypub' !== $page || 'fasp-registrations' !== $tab ) { + return; + } + + if ( isset( $_GET['approved'] ) && '1' === $_GET['approved'] ) { + \add_settings_error( + 'activitypub_fasp', + 'fasp_approved', + \__( 'Service approved successfully. You can now choose which features it may use.', 'activitypub' ), + 'success' + ); + } + + if ( isset( $_GET['rejected'] ) && '1' === $_GET['rejected'] ) { + \add_settings_error( + 'activitypub_fasp', + 'fasp_rejected', + \__( 'Service request rejected.', 'activitypub' ), + 'success' + ); + } + + if ( isset( $_GET['deleted'] ) && '1' === $_GET['deleted'] ) { + \add_settings_error( + 'activitypub_fasp', + 'fasp_deleted', + \__( 'Service disconnected successfully.', 'activitypub' ), + 'success' + ); + } + + if ( isset( $_GET['capability_updated'] ) && '1' === $_GET['capability_updated'] ) { + \add_settings_error( + 'activitypub_fasp', + 'fasp_capability_updated', + \__( 'Service capabilities updated.', 'activitypub' ), + 'success' + ); + } + + if ( isset( $_GET['error'] ) && '1' === $_GET['error'] ) { + \add_settings_error( + 'activitypub_fasp', + 'fasp_error', + \__( 'An error occurred while processing your request. Please try again.', 'activitypub' ), + 'error' + ); + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + } + + /** + * Verify capability and nonce of an admin-post action request. + * + * Dies when the current user may not manage options or the nonce does + * not match; returns the targeted FASP ID otherwise. + * + * @param string $nonce_prefix The nonce action prefix, followed by the FASP ID. + * @return string The FASP ID. + */ + private static function verify_action_request( $nonce_prefix ) { + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_die( \esc_html__( 'You do not have permission to perform this action.', 'activitypub' ) ); + } + + $fasp_id = isset( $_POST['fasp_id'] ) ? \sanitize_text_field( \wp_unslash( $_POST['fasp_id'] ) ) : ''; + $nonce = isset( $_POST['_wpnonce'] ) ? \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ) ) : ''; + + if ( ! \wp_verify_nonce( $nonce, $nonce_prefix . $fasp_id ) ) { + \wp_die( \esc_html__( 'Invalid nonce.', 'activitypub' ) ); + } + + return $fasp_id; + } + + /** + * Redirect back to the settings tab with the given query arguments. + * + * @param array $args Query arguments to add. + */ + private static function redirect( $args ) { + \wp_safe_redirect( \add_query_arg( $args, \admin_url( self::SETTINGS_URL ) ) ); + exit; + } +} diff --git a/includes/wp-admin/class-settings.php b/includes/wp-admin/class-settings.php index 873f15a466..877c092b83 100644 --- a/includes/wp-admin/class-settings.php +++ b/includes/wp-admin/class-settings.php @@ -60,6 +60,14 @@ public static function settings_page() { 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/blocked-actors-list.php', ); + // Add FASP registrations tab for managing auxiliary service providers (only if enabled). + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + $settings_tabs['fasp-registrations'] = array( + 'label' => \__( 'Auxiliary Services', 'activitypub' ), + 'template' => ACTIVITYPUB_PLUGIN_DIR . 'templates/fasp-registrations.php', + ); + } + if ( user_can_activitypub( Actors::BLOG_USER_ID ) ) { $settings_tabs['blog-profile'] = array( 'label' => __( 'Blog Profile', 'activitypub' ), diff --git a/integration/class-nodeinfo.php b/integration/class-nodeinfo.php index 3466b29a9d..db32612164 100644 --- a/integration/class-nodeinfo.php +++ b/integration/class-nodeinfo.php @@ -84,6 +84,11 @@ public static function add_nodeinfo_data( $nodeinfo, $version ) { $nodeinfo['metadata']['federation'] = array( 'enabled' => true ); $nodeinfo['metadata']['staffAccounts'] = self::get_staff(); + // Only expose FASP base URL when the feature is enabled. + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + $nodeinfo['metadata']['faspBaseUrl'] = get_rest_url_by_path( 'fasp' ); + } + return $nodeinfo; } @@ -103,6 +108,11 @@ public static function add_nodeinfo2_data( $nodeinfo ) { 'activeHalfyear' => get_active_users( 6 ), ); + // Only expose FASP base URL when the feature is enabled. + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + $nodeinfo['metadata']['faspBaseUrl'] = get_rest_url_by_path( 'fasp' ); + } + return $nodeinfo; } diff --git a/templates/fasp-registrations.php b/templates/fasp-registrations.php new file mode 100644 index 0000000000..fe4b2b39e0 --- /dev/null +++ b/templates/fasp-registrations.php @@ -0,0 +1,230 @@ + + +