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 763dd3e9db..e0d123ab1c 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -9,6 +9,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 8b448f25e7..ba90c8d379 100644 --- a/activitypub.php +++ b/activitypub.php @@ -69,6 +69,11 @@ function rest_init() { if ( is_blog_public() ) { ( new Rest\Nodeinfo_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' ); @@ -106,6 +111,11 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Relay', 'init' ) ); } + // Only load FASP if enabled. + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + \add_action( 'init', array( __NAMESPACE__ . '\Fasp', 'init' ) ); + } + // Load development tools. if ( 'local' === wp_get_environment_type() ) { $loader_file = __DIR__ . '/local/load.php'; 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-fasp.php b/includes/class-fasp.php new file mode 100644 index 0000000000..51fb55cab3 --- /dev/null +++ b/includes/class-fasp.php @@ -0,0 +1,406 @@ + 403 ) + ); + } + + // Return the stored public key. + if ( empty( $registration['fasp_public_key'] ) ) { + return new \WP_Error( + 'fasp_no_public_key', + 'FASP registration does not have a public key', + array( 'status' => 401 ) + ); + } + + /* + * FASP uses Ed25519 keys stored as base64. + * Decode and return as Ed25519 key array for signature verification. + */ + $raw_key = base64_decode( $registration['fasp_public_key'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + + if ( false === $raw_key ) { + return new \WP_Error( + 'fasp_invalid_key', + 'FASP public key is not valid base64', + array( 'status' => 401 ) + ); + } + + return array( + 'type' => 'ed25519', + 'key' => $raw_key, + ); + } + + /** + * Get registration by server ID. + * + * @param string $server_id The server ID from the FASP. + * @return array|null Registration data or null if not found. + */ + public static function get_registration_by_server_id( $server_id ) { + $registrations = self::get_registrations_store(); + + foreach ( $registrations as $registration ) { + if ( isset( $registration['server_id'] ) && $registration['server_id'] === $server_id ) { + return $registration; + } + } + + return null; + } + + /** + * Get all pending registration requests. + * + * @return array Array of registration requests. + */ + public static function get_pending_registrations() { + $registrations = self::get_registrations_store(); + $pending = array(); + + foreach ( $registrations as $registration ) { + if ( 'pending' === $registration['status'] ) { + $pending[] = $registration; + } + } + + // Sort by requested_at DESC. + usort( + $pending, + function ( $a, $b ) { + return strcmp( $b['requested_at'], $a['requested_at'] ); + } + ); + + return $pending; + } + + /** + * Get all approved registrations. + * + * @return array Array of approved registrations. + */ + public static function get_approved_registrations() { + $registrations = self::get_registrations_store(); + $approved = array(); + + foreach ( $registrations as $registration ) { + if ( 'approved' === $registration['status'] ) { + $approved[] = $registration; + } + } + + // Sort by approved_at DESC. + usort( + $approved, + function ( $a, $b ) { + return ( $b['approved_at'] ?? '' ) <=> ( $a['approved_at'] ?? '' ); + } + ); + + return $approved; + } + + /** + * 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_registration( $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( 'activitypub_fasp_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_registration( $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( 'activitypub_fasp_registrations', $registrations, false ); + } + + /** + * 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_registration( $fasp_id ) { + $registrations = self::get_registrations_store(); + + return isset( $registrations[ $fasp_id ] ) ? $registrations[ $fasp_id ] : null; + } + + /** + * Delete a registration request. + * + * @param string $fasp_id FASP ID. + * @return bool True on success, false on failure. + */ + public static function delete_registration( $fasp_id ) { + $registrations = self::get_registrations_store(); + + if ( ! isset( $registrations[ $fasp_id ] ) ) { + return false; + } + + unset( $registrations[ $fasp_id ] ); + + return update_option( 'activitypub_fasp_registrations', $registrations, false ); + } + + /** + * Store a new registration request. + * + * @param array $data Registration data including fasp_id. + * @return bool True on success, false on failure. + */ + public static function store_registration( $data ) { + $registrations = self::get_registrations_store(); + + // Add new registration. + $registrations[ $data['fasp_id'] ] = $data; + + // Store updated registrations without autoloading. + return update_option( 'activitypub_fasp_registrations', $registrations, false ); + } + + /** + * Generate public key fingerprint. + * + * @param string $public_key Base64 encoded public key. + * @return string SHA-256 fingerprint. + */ + 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 ) { + $capabilities = self::get_capabilities_store(); + $enabled = array(); + + foreach ( $capabilities 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']; + } + + /** + * Enable a capability for a FASP. + * + * @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( 'activitypub_fasp_capabilities', $capabilities, false ); + } + + /** + * Disable a capability for a FASP. + * + * @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( 'activitypub_fasp_capabilities', $capabilities, false ); + } + + /** + * Retrieve registrations, ensuring the option exists, is non-autoloaded, and sanitized. + * + * @return array + */ + private static function get_registrations_store() { + $registrations = get_option( 'activitypub_fasp_registrations', null ); + + if ( null === $registrations ) { + add_option( 'activitypub_fasp_registrations', array(), '', 'no' ); + return array(); + } + + if ( ! is_array( $registrations ) ) { + $registrations = array(); + } + + return self::sanitize_registration_records( $registrations ); + } + + /** + * Remove sensitive data from stored registrations. + * + * @param array $registrations Registration data. + * @return array Sanitized registrations. + */ + private static function sanitize_registration_records( array $registrations ) { + $modified = false; + + foreach ( $registrations as $fasp_id => $registration ) { + if ( isset( $registration['server_private_key'] ) ) { + unset( $registration['server_private_key'] ); + $registrations[ $fasp_id ] = $registration; + $modified = true; + } + + if ( isset( $registration['fasp_public_key'] ) && empty( $registration['fasp_public_key_fingerprint'] ) ) { + $registration['fasp_public_key_fingerprint'] = self::get_public_key_fingerprint( $registration['fasp_public_key'] ); + $registrations[ $fasp_id ] = $registration; + $modified = true; + } + } + + if ( $modified ) { + update_option( 'activitypub_fasp_registrations', $registrations, false ); + } + + return $registrations; + } + + /** + * Retrieve capabilities store ensuring the option exists and is non-autoloaded. + * + * @return array + */ + private static function get_capabilities_store() { + $capabilities = get_option( 'activitypub_fasp_capabilities', null ); + + if ( null === $capabilities ) { + add_option( 'activitypub_fasp_capabilities', array(), '', 'no' ); + return array(); + } + + if ( ! is_array( $capabilities ) ) { + return array(); + } + + return $capabilities; + } +} diff --git a/includes/class-options.php b/includes/class-options.php index 54b04da7c1..a5aab9c826 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -330,6 +330,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, + ) + ); + /* * Options Group: activitypub_blog */ diff --git a/includes/class-signature.php b/includes/class-signature.php index c3759a6539..c1104c3ddd 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -52,11 +52,11 @@ public static function sign_request( $args, $url ) { } /** - * Verifies the http signatures + * Verifies the http signatures. * * @param \WP_REST_Request|array $request The request object or $_SERVER array. * - * @return bool|\WP_Error A boolean or WP_Error. + * @return string|\WP_Error The verified keyId on success, WP_Error on failure. */ public static function verify_http_signature( $request ) { if ( is_object( $request ) ) { // REST Request object. @@ -187,6 +187,61 @@ private static function rfc9421_add_unsupported_host( $url ) { \update_option( 'activitypub_rfc9421_unsupported', $list, false ); } + /** + * Get the server's Ed25519 keypair, generating if needed. + * + * This keypair is used for server-level signatures (e.g., FASP). + * + * @return array Array with 'public' and 'private' keys (raw binary). + */ + public static function get_server_ed25519_keypair() { + $keypair = \get_option( 'activitypub_server_ed25519_keypair', null ); + + if ( null === $keypair || empty( $keypair['public'] ) || empty( $keypair['private'] ) ) { + $keypair = self::generate_server_ed25519_keypair(); + } + + return array( + 'public' => \base64_decode( $keypair['public'] ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + 'private' => \base64_decode( $keypair['private'] ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + ); + } + + /** + * Get the server's Ed25519 public key (base64 encoded). + * + * @return string Base64-encoded public key. + */ + public static function get_server_ed25519_public_key() { + $keypair = \get_option( 'activitypub_server_ed25519_keypair', null ); + + if ( null === $keypair || empty( $keypair['public'] ) ) { + $keypair = self::generate_server_ed25519_keypair(); + } + + return $keypair['public']; + } + + /** + * Generate and store a new Ed25519 keypair for the server. + * + * @return array Array with 'public' and 'private' keys (base64 encoded). + */ + private static function generate_server_ed25519_keypair() { + $keypair = \sodium_crypto_sign_keypair(); + $public_key = \sodium_crypto_sign_publickey( $keypair ); + $private_key = \sodium_crypto_sign_secretkey( $keypair ); + + $stored = array( + 'public' => \base64_encode( $public_key ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + 'private' => \base64_encode( $private_key ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + ); + + \update_option( 'activitypub_server_ed25519_keypair', $stored, false ); + + return $stored; + } + /** * Return the public key for a given user. * diff --git a/includes/collection/class-remote-actors.php b/includes/collection/class-remote-actors.php index 501a4c308d..574770a48a 100644 --- a/includes/collection/class-remote-actors.php +++ b/includes/collection/class-remote-actors.php @@ -639,11 +639,47 @@ public static function normalize_identifier( $actor ) { /** * Get public key from key_id. * - * @param string $key_id The URL to the public key. + * @param string $key_id The key ID (typically a URL to the public key, but can be any identifier). * - * @return resource|\WP_Error The public key resource or WP_Error. + * @return resource|array|\WP_Error The public key resource, Ed25519 key array, or WP_Error. */ public static function get_public_key( $key_id ) { + /** + * Filter to allow custom public key resolution for non-URL key IDs. + * + * This filter allows other protocols (like FASP) to provide public keys + * for key IDs that are not ActivityPub actor URLs. + * + * Return formats: + * - OpenSSL resource: Standard RSA/EC key + * - PEM string: Will be converted to OpenSSL resource + * - Array with 'type' => 'ed25519' and 'key' => raw bytes: Ed25519 key + * - WP_Error: Return error to caller + * - null: Continue with default ActivityPub lookup + * + * @param resource|string|array|\WP_Error|null $public_key The public key. + * @param string $key_id The key ID from the signature. + */ + $public_key = \apply_filters( 'activitypub_pre_get_public_key', null, $key_id ); + + if ( null !== $public_key ) { + // If filter returned an Ed25519 key array, pass it through. + if ( \is_array( $public_key ) && isset( $public_key['type'] ) && 'ed25519' === $public_key['type'] ) { + return $public_key; + } + + // If filter returned a PEM string, convert to resource. + if ( \is_string( $public_key ) && ! \is_wp_error( $public_key ) ) { + $key_resource = \openssl_pkey_get_public( \rtrim( $public_key ) ); + if ( $key_resource ) { + return $key_resource; + } + return new \WP_Error( 'activitypub_invalid_key', 'Invalid public key format', array( 'status' => 401 ) ); + } + + return $public_key; + } + $actor = self::get_by_uri( \strip_fragment_from_url( $key_id ) ); if ( \is_wp_error( $actor ) ) { diff --git a/includes/rest/class-fasp-controller.php b/includes/rest/class-fasp-controller.php new file mode 100644 index 0000000000..f3571cacdc --- /dev/null +++ b/includes/rest/class-fasp-controller.php @@ -0,0 +1,631 @@ +namespace, + '/' . $this->rest_base . '/provider_info', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_provider_info' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + ), + 'schema' => array( $this, 'get_provider_info_schema' ), + ) + ); + + // Registration endpoint for FASP providers to register with this server. + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/registration', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'handle_registration' ), + 'permission_callback' => array( $this, 'registration_permission_check' ), + '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 server ID generated by the FASP.', + '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' ), + ) + ); + + // Capability activation endpoint (enable). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/capabilities/(?P[a-zA-Z0-9_-]+)/(?P[0-9]+(?:\.[0-9]+)*)/activation', + array( + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'enable_capability' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'identifier' => array( + 'required' => true, + 'type' => 'string', + 'description' => 'The capability identifier.', + ), + 'version' => array( + 'required' => true, + 'type' => 'string', + 'pattern' => '^\d+(?:\.\d+)*$', + 'description' => 'The capability version.', + ), + ), + ), + ) + ); + + // Capability deactivation endpoint (disable). + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/capabilities/(?P[a-zA-Z0-9_-]+)/(?P[0-9]+(?:\.[0-9]+)*)/activation', + array( + array( + 'methods' => \WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'disable_capability' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'identifier' => array( + 'required' => true, + 'type' => 'string', + 'description' => 'The capability identifier.', + ), + 'version' => array( + 'required' => true, + 'type' => 'string', + 'pattern' => '^\d+(?:\.\d+)*$', + 'description' => 'The capability version.', + ), + ), + ), + ) + ); + } + + /** + * Get provider info. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response|\WP_Error The response or error. + */ + public function get_provider_info( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + // Build provider name. + $site_name = \get_bloginfo( 'name' ); + $name = $site_name ? $site_name . ' ActivityPub FASP' : 'WordPress ActivityPub FASP'; + + // Build privacy policy. + $privacy_policy = array(); + $privacy_policy_url = \get_privacy_policy_url(); + if ( $privacy_policy_url ) { + $privacy_policy = array( + array( + 'url' => $privacy_policy_url, + 'language' => \get_locale(), + ), + ); + } + + // Get capabilities - can be extended by filters. + $capabilities = \apply_filters( 'activitypub_fasp_capabilities', array() ); + + // Build provider info. + $provider_info = array( + 'name' => $name, + 'privacyPolicy' => $privacy_policy, + 'capabilities' => $capabilities, + 'signInUrl' => \admin_url(), + 'contactEmail' => \get_option( 'admin_email' ), + ); + + $response = new \WP_REST_Response( $provider_info ); + + // Add content-digest header as required by specification. + $content = \wp_json_encode( $provider_info ); + $digest = ( new Http_Message_Signature() )->generate_digest( $content ); + $response->header( 'Content-Digest', $digest ); + + // Sign the response. + $this->sign_response( $response ); + + return $response; + } + + /** + * Sign the response using HTTP Message Signatures (RFC-9421) with Ed25519. + * + * Uses the server's Ed25519 keypair as required by the FASP specification. + * + * @param \WP_REST_Response $response The response to sign. + */ + private function sign_response( $response ) { + $keypair = Signature::get_server_ed25519_keypair(); + $private_key = $keypair['private']; + + /* + * Use the site URL as the key ID for FASP signatures. + * This matches the serverId concept in the FASP spec. + */ + $key_id = \trailingslashit( \get_home_url() ) . '#fasp-key'; + + $signature_helper = new Http_Message_Signature(); + $signature_helper->sign_response_ed25519( + $response, + $private_key, + $key_id, + 'sig' + ); + } + + /** + * 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 ) { + // Get the server's Ed25519 public key as required by the FASP spec. + $public_key = Signature::get_server_ed25519_public_key(); + + // Parameters are already sanitized via sanitize_callback in register_routes(). + $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. + $existing = Fasp::get_registration_by_server_id( $server_id ); + if ( $existing ) { + return new \WP_Error( + 'server_id_exists', + 'A FASP with this serverId is already registered', + array( 'status' => 409 ) + ); + } + + // Generate unique FASP ID. + $fasp_id = $this->generate_unique_id(); + + // Store registration request (pending approval). + $registration_data = array( + 'fasp_id' => $fasp_id, + 'name' => $request->get_param( 'name' ), + 'base_url' => $request->get_param( 'baseUrl' ), + 'server_id' => $server_id, + 'fasp_public_key' => $fasp_public_key, + 'fasp_public_key_fingerprint' => Fasp::get_public_key_fingerprint( $fasp_public_key ), + 'server_public_key' => $public_key, + 'status' => 'pending', + 'requested_at' => \current_time( 'mysql', true ), + ); + + $result = Fasp::store_registration( $registration_data ); + if ( ! $result ) { + return new \WP_Error( + 'storage_failed', + 'Failed to store registration request', + array( 'status' => 500 ) + ); + } + + // Generate registration completion URI. + $completion_uri = \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&highlight=' . \rawurlencode( $fasp_id ) ); + + // Return successful response with the server's Ed25519 public key. + $response_data = array( + 'faspId' => $fasp_id, + 'publicKey' => $public_key, + 'registrationCompletionUri' => $completion_uri, + ); + + return new \WP_REST_Response( $response_data, 201 ); + } + + /** + * Enable a capability for a FASP. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response|\WP_Error The response or error. + */ + public function enable_capability( $request ) { + $validation = $this->validate_capability_request( $request ); + if ( \is_wp_error( $validation ) ) { + return $validation; + } + + $result = Fasp::enable_capability( + $validation['fasp_id'], + $validation['identifier'], + $validation['version'] + ); + + if ( ! $result ) { + return new \WP_Error( + 'capability_update_failed', + 'Failed to enable capability', + array( 'status' => 500 ) + ); + } + + return new \WP_REST_Response( null, 204 ); + } + + /** + * Disable a capability for a FASP. + * + * @param \WP_REST_Request $request The REST request. + * @return \WP_REST_Response|\WP_Error The response or error. + */ + public function disable_capability( $request ) { + $validation = $this->validate_capability_request( $request ); + if ( \is_wp_error( $validation ) ) { + return $validation; + } + + $result = Fasp::disable_capability( + $validation['fasp_id'], + $validation['identifier'], + $validation['version'] + ); + + if ( ! $result ) { + return new \WP_Error( + 'capability_update_failed', + 'Failed to disable capability', + array( 'status' => 500 ) + ); + } + + return new \WP_REST_Response( null, 204 ); + } + + /** + * Validate a capability request and return the validated data. + * + * Per FASP spec, the keyId in the signature MUST be the serverId exchanged during registration. + * Signature verification is handled by the permission callback (Server::verify_signature). + * + * @param \WP_REST_Request $request The REST request. + * @return array|\WP_Error Validated data or error. + */ + private function validate_capability_request( $request ) { + $identifier = $request->get_param( 'identifier' ); + $version = $request->get_param( 'version' ); + + /* + * Get the verified keyId from the signature verification. + * This is set by Server::verify_signature() and ensures we use the keyId + * from the signature that was actually verified, not just any keyId in headers. + */ + $keyid = $request->get_param( 'activitypub_verified_keyid' ); + if ( empty( $keyid ) ) { + return new \WP_Error( + 'missing_verified_keyid', + 'No verified signature keyId found', + array( 'status' => 401 ) + ); + } + + // Look up FASP registration by serverId. + $fasp_data = $this->get_fasp_by_keyid( $keyid ); + if ( \is_wp_error( $fasp_data ) ) { + return $fasp_data; + } + + // Verify FASP is approved. + if ( 'approved' !== $fasp_data['status'] ) { + return new \WP_Error( + 'fasp_not_approved', + 'FASP registration is not approved', + array( 'status' => 403 ) + ); + } + + // Check if capability is supported. + $supported_capabilities = $this->get_supported_capabilities_list(); + $capability_key = $identifier . '_v' . $version; + + if ( ! isset( $supported_capabilities[ $capability_key ] ) ) { + return new \WP_Error( + 'capability_not_found', + 'Capability not found or not supported', + array( 'status' => 404 ) + ); + } + + return array( + 'fasp_id' => $fasp_data['fasp_id'], + 'identifier' => $identifier, + 'version' => $version, + ); + } + + /** + * Permission check for registration endpoint. + * + * @param \WP_REST_Request $request The REST request. + * @return bool True if allowed. + */ + public function registration_permission_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + // Registration endpoint is publicly accessible but should verify. + // the request comes from a legitimate FASP. + return true; + } + + /** + * Generate unique ID for FASP. + * + * @return string Unique ID. + */ + private function generate_unique_id() { + return \wp_generate_password( 12, false ); + } + + /** + * Look up FASP registration by keyId (serverId). + * + * Per FASP spec, the keyId MUST be the identifier exchanged during registration (serverId). + * + * @see https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/blob/main/general/v0.1/protocol_basics.md + * + * @param string $keyid The keyId from the signature (should be the serverId). + * @return array|\WP_Error FASP data or error. + */ + private function get_fasp_by_keyid( $keyid ) { + $registration = Fasp::get_registration_by_server_id( $keyid ); + + if ( ! $registration ) { + return new \WP_Error( + 'fasp_not_found', + 'FASP not found for provided keyId', + array( 'status' => 404 ) + ); + } + + return $registration; + } + + /** + * Get supported capabilities list. + * + * @return array Supported capabilities. + */ + private function get_supported_capabilities_list() { + $capabilities = (array) \apply_filters( 'activitypub_fasp_capabilities', array() ); + $indexed = array(); + + foreach ( $capabilities as $capability ) { + if ( empty( $capability['id'] ) || ! isset( $capability['version'] ) ) { + continue; + } + + $key = $capability['id'] . '_v' . $capability['version']; + $indexed[ $key ] = $capability; + } + + return $indexed; + } + + /** + * Get the schema for provider info endpoint. + * + * @return array The schema. + */ + public function get_provider_info_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'FASP Provider Info', + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'The name of the FASP provider.', + ), + 'privacyPolicy' => array( + 'type' => 'array', + 'description' => 'Privacy policy information.', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'url' => array( + 'type' => 'string', + 'format' => 'uri', + ), + 'language' => array( + 'type' => 'string', + ), + ), + ), + ), + 'capabilities' => array( + 'type' => 'array', + 'description' => 'Supported capabilities.', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + ), + 'version' => array( + 'type' => 'string', + ), + ), + ), + ), + 'signInUrl' => array( + 'type' => 'string', + 'format' => 'uri', + 'description' => 'URL where administrators can sign in.', + ), + 'contactEmail' => array( + 'type' => 'string', + 'format' => 'email', + 'description' => 'Contact email address.', + ), + 'fediverseAccount' => array( + 'type' => 'string', + 'description' => 'Fediverse account for updates.', + ), + ), + 'required' => array( 'name', 'privacyPolicy', 'capabilities' ), + ); + } + + /** + * 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. + * + * FASP providers should use HTTPS for security. + * + * @param string $url The URL to validate. + * @param \WP_REST_Request $request The request object. + * @param string $param The parameter name. + * @return true|\WP_Error True if valid, WP_Error otherwise. + */ + public function validate_https_url( $url, $request, $param ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $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/rest/class-server.php b/includes/rest/class-server.php index 3309eb5953..075e5e3a2b 100644 --- a/includes/rest/class-server.php +++ b/includes/rest/class-server.php @@ -38,6 +38,9 @@ public static function init() { * You can use the filter 'activitypub_defer_signature_verification' to defer the signature verification. * HEAD requests are always bypassed. * + * On successful signature verification, the verified keyId is stored in the request + * as the 'activitypub_verified_keyid' attribute for use by endpoint callbacks. + * * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch * @@ -69,14 +72,17 @@ public static function verify_signature( $request ) { // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode. if ( 'GET' !== $request->get_method() || use_authorized_fetch() ) { - $verified_request = Signature::verify_http_signature( $request ); - if ( \is_wp_error( $verified_request ) ) { + $verified_keyid = Signature::verify_http_signature( $request ); + if ( \is_wp_error( $verified_keyid ) ) { return new \WP_Error( 'activitypub_signature_verification', - $verified_request->get_error_message(), + $verified_keyid->get_error_message(), array( 'status' => 401 ) ); } + + // Store the verified keyId in the request for use by endpoint callbacks. + $request->set_param( 'activitypub_verified_keyid', $verified_keyid ); } return true; diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php index 34142e3ed8..2af6c927b9 100644 --- a/includes/signature/class-http-message-signature.php +++ b/includes/signature/class-http-message-signature.php @@ -130,12 +130,83 @@ public function sign( $args, $url ) { return $args; } + /** + * Sign a WP_REST_Response with RFC-9421 HTTP Message Signatures. + * + * @param \WP_REST_Response $response The response to sign. + * @param string $private_key The private key to sign with. + * @param string $key_id The key ID to use in the signature. + * @param string $label Optional signature label (default: 'sig'). + * + * @return \WP_REST_Response The response with signature headers added. + */ + public function sign_response( $response, $private_key, $key_id, $label = 'wp' ) { + // Build signature components for response. + $components = array( + '"@status"' => (string) $response->get_status(), + '"content-digest"' => $response->get_headers()['Content-Digest'] ?? '', + ); + $identifiers = \array_keys( $components ); + + $params = array( + 'created' => \time(), + 'keyid' => $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, $private_key, \OPENSSL_ALGO_SHA256 ); + $signature = \base64_encode( $signature ); + + // Add signature headers. + $response->header( 'Signature-Input', $label . '=(' . \implode( ' ', $identifiers ) . ')' . $this->get_params_string( $params ) ); + $response->header( 'Signature', $label . '=:' . $signature . ':' ); + + return $response; + } + + /** + * Sign a WP_REST_Response with Ed25519 (RFC-9421 HTTP Message Signatures). + * + * @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. + * @param string $label Optional signature label (default: 'sig'). + * + * @return \WP_REST_Response The response with signature headers added. + */ + public function sign_response_ed25519( $response, $private_key, $key_id, $label = 'sig' ) { + $components = array( + '"@status"' => (string) $response->get_status(), + '"content-digest"' => $response->get_headers()['Content-Digest'] ?? '', + ); + $identifiers = \array_keys( $components ); + + $params = array( + 'created' => \time(), + 'keyid' => $key_id, + 'alg' => 'ed25519', + ); + + $signature_base = $this->get_signature_base_string( $components, $params ); + $signature = \sodium_crypto_sign_detached( $signature_base, $private_key ); + $signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + $response->header( 'Signature-Input', $label . '=(' . \implode( ' ', $identifiers ) . ')' . $this->get_params_string( $params ) ); + $response->header( 'Signature', $label . '=:' . $signature . ':' ); + + return $response; + } + /** * 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. + * @return string|\WP_Error The verified keyId on success, WP_Error on failure. */ public function verify( array $headers, $body = null ) { $parsed = $this->parse_signature_labels( $headers ); @@ -147,7 +218,8 @@ public function verify( array $headers, $body = null ) { foreach ( $parsed as $data ) { $result = $this->verify_signature_label( $data, $headers, $body ); if ( true === $result ) { - return true; + // Return the keyId that was verified, not just true. + return $data['params']['keyid'] ?? ''; } if ( \is_wp_error( $result ) ) { @@ -239,12 +311,6 @@ private function verify_signature_label( $data, $headers, $body ) { 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 ) ) { @@ -254,6 +320,22 @@ private function verify_signature_label( $data, $headers, $body ) { $components = $this->get_component_values( $data['components'], $headers ); $signature_base = $this->get_signature_base_string( $components, $params ); + // Handle Ed25519 keys (e.g., from FASP). + if ( \is_array( $public_key ) && isset( $public_key['type'] ) && 'ed25519' === $public_key['type'] ) { + // Verify alg parameter matches if specified (FASP/RFC-9421 expects alg="ed25519"). + $alg = \strtolower( $params['alg'] ?? '' ); + if ( '' !== $alg && 'ed25519' !== $alg ) { + return new \WP_Error( 'alg_key_mismatch', 'Algorithm parameter does not match Ed25519 key type.' ); + } + return $this->verify_ed25519_signature( $signature_base, $data['signature'], $public_key['key'] ); + } + + // Standard OpenSSL verification for RSA/EC keys. + $algorithm = $this->verify_algorithm( $params['alg'] ?? '', $public_key ); + if ( \is_wp_error( $algorithm ) ) { + return $algorithm; + } + $verified = \openssl_verify( $signature_base, $data['signature'], $public_key, $algorithm ) > 0; if ( ! $verified ) { return new \WP_Error( 'activitypub_signature', 'Invalid signature' ); @@ -262,6 +344,45 @@ private function verify_signature_label( $data, $headers, $body ) { return true; } + /** + * Verify an Ed25519 signature using WordPress's sodium_compat. + * + * @param string $message The message that was signed. + * @param string $signature The signature to verify. + * @param string $public_key The Ed25519 public key (32 bytes). + * @return bool|\WP_Error True if valid, WP_Error on failure. + */ + private function verify_ed25519_signature( $message, $signature, $public_key ) { + // Ed25519 signatures are 64 bytes. + 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 ) ) + ); + } + + // Ed25519 public keys are 32 bytes. + 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 { + // Use WordPress's sodium_compat for Ed25519 verification. + $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 Content-Digest header against the request body. * diff --git a/includes/signature/class-http-signature-draft.php b/includes/signature/class-http-signature-draft.php index 54f5118e3b..b6cb5e1b04 100644 --- a/includes/signature/class-http-signature-draft.php +++ b/includes/signature/class-http-signature-draft.php @@ -89,7 +89,7 @@ public function sign( $args, $url ) { * * @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. + * @return string|\WP_Error The verified keyId on success, WP_Error on failure. */ public function verify( array $headers, $body = null ) { if ( ! isset( $headers['signature'] ) && ! isset( $headers['authorization'] ) ) { @@ -129,7 +129,8 @@ public function verify( array $headers, $body = null ) { return new \WP_Error( 'activitypub_signature', 'Invalid signature', array( 'status' => 401 ) ); } - return true; + // Return the keyId that was verified, not just true. + return $parsed['keyId']; } /** diff --git a/includes/signature/interface-http-signature.php b/includes/signature/interface-http-signature.php index 3a0a614f79..88249ff9a9 100644 --- a/includes/signature/interface-http-signature.php +++ b/includes/signature/interface-http-signature.php @@ -30,7 +30,7 @@ public function sign( $args, $url ); * * @param array $headers The HTTP headers. * @param string|null $body The request body, if applicable. - * @return bool|\WP_Error + * @return string|\WP_Error The verified keyId on success, WP_Error on failure. */ public function verify( array $headers, $body = null ); diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index a04b0dd201..cc78e54df2 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -57,6 +57,13 @@ public static function init() { \add_action( 'admin_post_delete_actor_confirmed', array( self::class, 'handle_bulk_actor_delete_confirmation' ) ); \add_action( 'admin_action_activitypub_confirm_removal', array( self::class, 'handle_bulk_actor_delete_page' ) ); + // Only register FASP admin actions if FASP is enabled. + if ( '1' === \get_option( 'activitypub_enable_fasp', '0' ) ) { + \add_action( 'admin_post_approve_fasp_registration', array( self::class, 'approve_fasp_registration' ) ); + \add_action( 'admin_post_reject_fasp_registration', array( self::class, 'reject_fasp_registration' ) ); + \add_action( 'admin_post_delete_fasp_registration', array( self::class, 'delete_fasp_registration' ) ); + } + if ( user_can_activitypub( \get_current_user_id() ) ) { \add_action( 'show_user_profile', array( self::class, 'add_profile' ) ); } @@ -115,6 +122,62 @@ public static function admin_notices() { \__( 'Failed to remove subscription.', 'activitypub' ) ) ); } } + + /** + * Handle approve FASP registration action. + */ + public static function approve_fasp_registration() { + 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, 'fasp_registration_' . $fasp_id ) ) { + \wp_die( \esc_html__( 'Invalid nonce.', 'activitypub' ) ); + } + + $result = \Activitypub\Fasp::approve_registration( $fasp_id, \get_current_user_id() ); + + if ( $result ) { + \wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&approved=1' ) ); + } else { + \wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&error=1' ) ); + } + exit; + } + + /** + * Handle reject FASP registration action. + */ + public static function reject_fasp_registration() { + 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, 'fasp_registration_' . $fasp_id ) ) { + \wp_die( \esc_html__( 'Invalid nonce.', 'activitypub' ) ); + } + + $result = \Activitypub\Fasp::reject_registration( $fasp_id, \get_current_user_id() ); + + if ( $result ) { + \wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&rejected=1' ) ); + } else { + \wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&error=1' ) ); + } + exit; + } + + /** + * Handle delete FASP registration action. + */ + public static function delete_fasp_registration() { + 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, 'fasp_registration_' . $fasp_id ) ) { + \wp_die( \esc_html__( 'Invalid nonce.', 'activitypub' ) ); + } + + $result = \Activitypub\Fasp::delete_registration( $fasp_id ); + + if ( $result ) { + \wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&deleted=1' ) ); + } else { + \wp_safe_redirect( \admin_url( 'options-general.php?page=activitypub&tab=fasp-registrations&error=1' ) ); + } + exit; + } } diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php index 5efa7aff77..bde2a92bf0 100644 --- a/includes/wp-admin/class-advanced-settings-fields.php +++ b/includes/wp-admin/class-advanced-settings-fields.php @@ -98,6 +98,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' ) + ); } /** @@ -253,4 +262,25 @@ public static function render_object_type_field() {

+

+ +

+

+ +

+

+ +

+ 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 1354ce528e..d0fe8808b4 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; } diff --git a/templates/fasp-registrations.php b/templates/fasp-registrations.php new file mode 100644 index 0000000000..d6d01aa1d5 --- /dev/null +++ b/templates/fasp-registrations.php @@ -0,0 +1,155 @@ + + +
+ + + +
+

+ +

+ + +

+

+ +

+
+ + +
+
+

+
+
+ + + + +
+
+ + + + +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ +

+ +
+
+
+ +
+ + + +

+

+ +

+
+ + +
+
+

+
+
+ + + + +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+ +

+ +
+
+
+ +
+ + + +
+

+

+
+ +
diff --git a/tests/e2e/specs/includes/rest/fasp-controller.test.js b/tests/e2e/specs/includes/rest/fasp-controller.test.js new file mode 100644 index 0000000000..8619d63d16 --- /dev/null +++ b/tests/e2e/specs/includes/rest/fasp-controller.test.js @@ -0,0 +1,491 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; +import crypto from 'crypto'; +import { execSync } from 'child_process'; + +/** + * Generate a valid Ed25519 public key for testing. + * Ed25519 public keys must be exactly 32 bytes. + * + * @return {string} Base64-encoded 32-byte public key. + */ +const generateValidEd25519PublicKey = () => { + // Generate a real Ed25519 keypair and extract the raw public key. + const { publicKey } = crypto.generateKeyPairSync( 'ed25519' ); + const rawPublicKey = publicKey.export( { type: 'spki', format: 'der' } ); + // SPKI format for Ed25519: 12 bytes header + 32 bytes key. + const ed25519PublicKeyBytes = rawPublicKey.subarray( 12 ); + return ed25519PublicKeyBytes.toString( 'base64' ); +}; + +// Enable FASP feature before running tests. +test.beforeAll( async () => { + execSync( "npx wp-env run tests-cli wp option update activitypub_enable_fasp '1'" ); +} ); + +// Disable FASP feature after tests complete. +test.afterAll( async () => { + execSync( 'npx wp-env run tests-cli wp option delete activitypub_enable_fasp' ); +} ); + +/** + * FASP v0.1 Specification Compliance Tests + * + * Tests implementation against: + * https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications/tree/main/general/v0.1 + * + * This test validates SPEC COMPLIANCE, not just API responses. + * + * Authentication Pattern: + * - All FASP endpoints use the standard ActivityPub signature verification pattern (Server::verify_signature) + * - Provider info endpoint: Verifies HTTP signatures (GET requests with authorized fetch enabled) + * - Capability endpoints: Require HTTP signatures (POST/DELETE requests always require signatures) + * - Registration endpoint: Publicly accessible (no signature required) + * + * Note: Uses /?rest_route= URL format for mod_rewrite compatibility + */ +test.describe( 'FASP v0.1 Specification Compliance', () => { + const faspBasePath = '/activitypub/1.0/fasp'; + + // Helper to construct REST API URL that works with and without mod_rewrite + const restUrl = ( baseURL, path ) => `${ baseURL }/?rest_route=${ path }`; + + test.describe( 'Protocol Basics - Request Integrity (RFC-9530)', () => { + test( 'provider_info response MUST include Content-Digest header with SHA-256', async ( { + request, + baseURL, + } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + expect( headers[ 'content-digest' ] ).toBeDefined(); + expect( headers[ 'content-digest' ] ).toMatch( /^sha-256=:/ ); + + const digestMatch = headers[ 'content-digest' ].match( /^sha-256=:([A-Za-z0-9+/=]+):$/ ); + expect( digestMatch ).toBeTruthy(); + expect( digestMatch[ 1 ] ).toBeTruthy(); + } ); + + test( 'Content-Digest MUST match actual response body', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const body = await response.text(); + const headers = response.headers(); + + const digestMatch = headers[ 'content-digest' ].match( /^sha-256=:([A-Za-z0-9+/=]+):$/ ); + expect( digestMatch ).toBeTruthy(); + + const receivedDigest = digestMatch[ 1 ]; + const expectedDigest = crypto.createHash( 'sha256' ).update( body ).digest( 'base64' ); + + expect( receivedDigest ).toBe( expectedDigest ); + } ); + } ); + + test.describe( 'Protocol Basics - Authentication (RFC-9421)', () => { + test( 'provider_info response MUST include Signature-Input header', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + expect( headers[ 'signature-input' ] ).toBeDefined(); + + const signatureInput = headers[ 'signature-input' ]; + expect( signatureInput ).toMatch( /^[a-z0-9_-]+=\([^)]+\);/ ); + } ); + + test( 'provider_info response MUST include Signature header', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + expect( headers.signature ).toBeDefined(); + + const signature = headers.signature; + expect( signature ).toMatch( /^[a-z0-9_-]+=:[A-Za-z0-9+/=]+:$/ ); + } ); + + test( 'Signature-Input MUST include @status derived component', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + const signatureInput = headers[ 'signature-input' ]; + expect( signatureInput ).toContain( '"@status"' ); + } ); + + test( 'Signature-Input MUST include content-digest component', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + const signatureInput = headers[ 'signature-input' ]; + expect( signatureInput ).toContain( '"content-digest"' ); + } ); + + test( 'Signature-Input MUST include created parameter', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + const signatureInput = headers[ 'signature-input' ]; + expect( signatureInput ).toMatch( /;created=\d+/ ); + } ); + + test( 'Signature-Input MUST include keyid parameter', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + const signatureInput = headers[ 'signature-input' ]; + expect( signatureInput ).toMatch( /;keyid=/ ); + } ); + + test( 'Signature labels MUST match', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + const signatureInput = headers[ 'signature-input' ]; + const signature = headers.signature; + + const inputLabelMatch = signatureInput.match( /^([a-z0-9_-]+)=/ ); + expect( inputLabelMatch ).toBeTruthy(); + const inputLabel = inputLabelMatch[ 1 ]; + + const sigLabelMatch = signature.match( /^([a-z0-9_-]+)=/ ); + expect( sigLabelMatch ).toBeTruthy(); + const sigLabel = sigLabelMatch[ 1 ]; + + expect( inputLabel ).toBe( sigLabel ); + } ); + + test( 'created timestamp within acceptable range', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + const headers = response.headers(); + const signatureInput = headers[ 'signature-input' ]; + + const createdMatch = signatureInput.match( /;created=(\d+)/ ); + expect( createdMatch ).toBeTruthy(); + + const created = parseInt( createdMatch[ 1 ], 10 ); + const now = Math.floor( Date.now() / 1000 ); + + expect( created ).toBeLessThanOrEqual( now + 60 ); + expect( created ).toBeGreaterThan( now - 3600 ); + } ); + } ); + + test.describe( 'Provider Info Endpoint', () => { + test( 'endpoint accessible', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + expect( response.status() ).toBe( 200 ); + } ); + + test( 'returns valid JSON', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + + expect( response.headers()[ 'content-type' ] ).toContain( 'application/json' ); + + const data = await response.json(); + expect( data ).toBeDefined(); + expect( typeof data ).toBe( 'object' ); + } ); + + test( 'contains required field: name', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + expect( data ).toHaveProperty( 'name' ); + expect( typeof data.name ).toBe( 'string' ); + expect( data.name.length ).toBeGreaterThan( 0 ); + } ); + + test( 'contains required field: privacyPolicy', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + expect( data ).toHaveProperty( 'privacyPolicy' ); + expect( Array.isArray( data.privacyPolicy ) ).toBe( true ); + } ); + + test( 'privacyPolicy items have url and language', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + if ( data.privacyPolicy.length > 0 ) { + for ( const policy of data.privacyPolicy ) { + expect( policy ).toHaveProperty( 'url' ); + expect( policy ).toHaveProperty( 'language' ); + expect( typeof policy.url ).toBe( 'string' ); + expect( typeof policy.language ).toBe( 'string' ); + + expect( () => new URL( policy.url ) ).not.toThrow(); + // WordPress locales use underscores (e.g., en_US), but BCP 47 uses hyphens (e.g., en-US). + expect( policy.language ).toMatch( /^[a-z]{2}([_-][A-Za-z]{2,})?$/ ); + } + } + } ); + + test( 'contains required field: capabilities', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + expect( data ).toHaveProperty( 'capabilities' ); + expect( Array.isArray( data.capabilities ) ).toBe( true ); + } ); + + test( 'capabilities items have id and version', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + if ( data.capabilities.length > 0 ) { + for ( const capability of data.capabilities ) { + expect( capability ).toHaveProperty( 'id' ); + expect( capability ).toHaveProperty( 'version' ); + expect( typeof capability.id ).toBe( 'string' ); + expect( typeof capability.version ).toBe( 'string' ); + } + } + } ); + + test( 'signInUrl valid if present', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + if ( data.signInUrl ) { + expect( typeof data.signInUrl ).toBe( 'string' ); + expect( () => new URL( data.signInUrl ) ).not.toThrow(); + } + } ); + + test( 'contactEmail valid if present', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + if ( data.contactEmail ) { + expect( typeof data.contactEmail ).toBe( 'string' ); + expect( data.contactEmail ).toMatch( /^[^\s@]+@[^\s@]+\.[^\s@]+$/ ); + } + } ); + + test( 'fediverseAccount valid if present', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + const data = await response.json(); + + if ( data.fediverseAccount ) { + expect( typeof data.fediverseAccount ).toBe( 'string' ); + expect( data.fediverseAccount ).toMatch( /^@[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ ); + } + } ); + } ); + + test.describe( 'Registration Endpoint', () => { + test( 'endpoint accessible', async ( { request, baseURL } ) => { + const testPayload = { + name: 'Test FASP', + baseUrl: 'https://fasp.example.com', + serverId: 'test123456', + publicKey: 'dGVzdHB1YmxpY2tleQ==', + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + expect( response.status() ).not.toBe( 404 ); + expect( [ 201, 400, 401 ] ).toContain( response.status() ); + } ); + + test( 'validates required fields', async ( { request, baseURL } ) => { + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: {}, + } ); + + expect( response.status() ).toBe( 400 ); + } ); + + test( 'validates name field', async ( { request, baseURL } ) => { + const testPayload = { + baseUrl: 'https://fasp.example.com', + serverId: 'test123456', + publicKey: 'dGVzdHB1YmxpY2tleQ==', + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + expect( response.status() ).toBe( 400 ); + } ); + + test( 'validates baseUrl field', async ( { request, baseURL } ) => { + const testPayload = { + name: 'Test FASP', + serverId: 'test123456', + publicKey: 'dGVzdHB1YmxpY2tleQ==', + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + expect( response.status() ).toBe( 400 ); + } ); + + test( 'validates serverId field', async ( { request, baseURL } ) => { + const testPayload = { + name: 'Test FASP', + baseUrl: 'https://fasp.example.com', + publicKey: 'dGVzdHB1YmxpY2tleQ==', + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + expect( response.status() ).toBe( 400 ); + } ); + + test( 'validates publicKey field', async ( { request, baseURL } ) => { + const testPayload = { + name: 'Test FASP', + baseUrl: 'https://fasp.example.com', + serverId: 'test123456', + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + expect( response.status() ).toBe( 400 ); + } ); + + test( 'successful registration returns 201', async ( { request, baseURL } ) => { + const testPayload = { + name: 'E2E Test FASP', + baseUrl: 'https://fasp.example.com', + serverId: `test${ Date.now() }`, + publicKey: generateValidEd25519PublicKey(), + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + expect( response.status() ).toBe( 201 ); + } ); + + test( 'response includes faspId', async ( { request, baseURL } ) => { + const testPayload = { + name: 'E2E Test FASP', + baseUrl: 'https://fasp.example.com', + serverId: `test${ Date.now() }`, + publicKey: generateValidEd25519PublicKey(), + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + if ( response.status() === 201 ) { + const data = await response.json(); + expect( data ).toHaveProperty( 'faspId' ); + expect( typeof data.faspId ).toBe( 'string' ); + } + } ); + + test( 'response includes publicKey', async ( { request, baseURL } ) => { + const testPayload = { + name: 'E2E Test FASP', + baseUrl: 'https://fasp.example.com', + serverId: `test${ Date.now() }`, + publicKey: generateValidEd25519PublicKey(), + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + if ( response.status() === 201 ) { + const data = await response.json(); + expect( data ).toHaveProperty( 'publicKey' ); + expect( typeof data.publicKey ).toBe( 'string' ); + expect( () => Buffer.from( data.publicKey, 'base64' ) ).not.toThrow(); + } + } ); + + test( 'response includes registrationCompletionUri', async ( { request, baseURL } ) => { + const testPayload = { + name: 'E2E Test FASP', + baseUrl: 'https://fasp.example.com', + serverId: `test${ Date.now() }`, + publicKey: generateValidEd25519PublicKey(), + }; + + const response = await request.post( restUrl( baseURL, `${ faspBasePath }/registration` ), { + data: testPayload, + } ); + + if ( response.status() === 201 ) { + const data = await response.json(); + expect( data ).toHaveProperty( 'registrationCompletionUri' ); + expect( typeof data.registrationCompletionUri ).toBe( 'string' ); + expect( () => new URL( data.registrationCompletionUri ) ).not.toThrow(); + } + } ); + } ); + + test.describe( 'Capability Activation Endpoints', () => { + /** + * Note: Capability endpoints require HTTP Message Signatures (RFC-9421) for authentication. + * These tests verify endpoint routing and error handling for unauthenticated requests. + * TODO: Add tests with properly signed requests to verify full capability activation flow. + */ + + test( 'endpoint accessible (rejects unauthenticated requests)', async ( { request, baseURL } ) => { + const response = await request.post( + restUrl( baseURL, `${ faspBasePath }/capabilities/test/1/activation` ) + ); + // Endpoint exists (not 404) but requires signature authentication + expect( response.status() ).not.toBe( 404 ); + expect( response.status() ).toBe( 401 ); // Unauthenticated + } ); + + test( 'POST requires HTTP signature authentication', async ( { request, baseURL } ) => { + const response = await request.post( + restUrl( baseURL, `${ faspBasePath }/capabilities/test_capability/1/activation` ) + ); + // Without valid HTTP signature, request is rejected + expect( response.status() ).toBe( 401 ); + } ); + + test( 'DELETE requires HTTP signature authentication', async ( { request, baseURL } ) => { + const response = await request.delete( + restUrl( baseURL, `${ faspBasePath }/capabilities/test_capability/1/activation` ) + ); + // Without valid HTTP signature, request is rejected + expect( response.status() ).toBe( 401 ); + } ); + + test( 'rejects requests with missing signature headers', async ( { request, baseURL } ) => { + const response = await request.post( + restUrl( baseURL, `${ faspBasePath }/capabilities/test/1/activation` ), + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + expect( response.status() ).toBe( 401 ); + } ); + } ); + + test.describe( 'HTTP Headers Compliance', () => { + test( 'endpoint responds successfully', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + expect( response.status() ).toBeLessThan( 500 ); + } ); + + test( 'has correct Content-Type', async ( { request, baseURL } ) => { + const response = await request.get( restUrl( baseURL, `${ faspBasePath }/provider_info` ) ); + expect( response.headers()[ 'content-type' ] ).toContain( 'application/json' ); + } ); + } ); +} ); diff --git a/tests/phpunit/tests/includes/class-test-fasp.php b/tests/phpunit/tests/includes/class-test-fasp.php new file mode 100644 index 0000000000..5673320b70 --- /dev/null +++ b/tests/phpunit/tests/includes/class-test-fasp.php @@ -0,0 +1,615 @@ +controller = new Fasp_Controller(); + + // Clean up options. + delete_option( 'activitypub_fasp_registrations' ); + delete_option( 'activitypub_fasp_capabilities' ); + } + + /** + * Clean up after tests. + */ + public function tear_down() { + parent::tear_down(); + + // Clean up options. + delete_option( 'activitypub_enable_fasp' ); + delete_option( 'activitypub_fasp_registrations' ); + delete_option( 'activitypub_fasp_capabilities' ); + } + + /** + * Test provider info endpoint registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + global $wp_rest_server; + + $this->controller->register_routes(); + + $routes = $wp_rest_server->get_routes(); + $this->assertArrayHasKey( '/activitypub/1.0/fasp/provider_info', $routes ); + + $route = $routes['/activitypub/1.0/fasp/provider_info']; + $this->assertIsArray( $route ); + $this->assertEquals( 'GET', $route[0]['methods']['GET'] ); + } + + /** + * Test provider info endpoint response. + * + * @covers ::get_provider_info + */ + public function test_provider_info() { + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/fasp/provider_info' ); + $response = $this->controller->get_provider_info( $request ); + + $this->assertInstanceOf( 'WP_REST_Response', $response ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'name', $data ); + $this->assertArrayHasKey( 'privacyPolicy', $data ); + $this->assertArrayHasKey( 'capabilities', $data ); + + // Test required fields are present and properly typed. + $this->assertIsString( $data['name'] ); + $this->assertIsArray( $data['privacyPolicy'] ); + $this->assertIsArray( $data['capabilities'] ); + + // Test Content-Digest header is present. + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'Content-Digest', $headers ); + $this->assertStringStartsWith( 'sha-256=:', $headers['Content-Digest'] ); + } + + /** + * Test provider info with privacy policy. + * + * @covers ::get_provider_info + */ + public function test_provider_info_with_privacy_policy() { + // Create a privacy policy page. + $privacy_page_id = self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'Privacy Policy', + 'post_status' => 'publish', + ) + ); + update_option( 'wp_page_for_privacy_policy', $privacy_page_id ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/fasp/provider_info' ); + $response = $this->controller->get_provider_info( $request ); + + $data = $response->get_data(); + + $this->assertNotEmpty( $data['privacyPolicy'] ); + $this->assertArrayHasKey( 'url', $data['privacyPolicy'][0] ); + $this->assertArrayHasKey( 'language', $data['privacyPolicy'][0] ); + + // Clean up. + wp_delete_post( $privacy_page_id, true ); + delete_option( 'wp_page_for_privacy_policy' ); + } + + /** + * Test provider info optional fields. + * + * @covers ::get_provider_info + */ + public function test_provider_info_optional_fields() { + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/fasp/provider_info' ); + $response = $this->controller->get_provider_info( $request ); + + $data = $response->get_data(); + + // signInUrl should be present (WordPress admin). + $this->assertArrayHasKey( 'signInUrl', $data ); + $this->assertStringContainsString( 'wp-admin', $data['signInUrl'] ); + + // contactEmail should be present (admin email). + $this->assertArrayHasKey( 'contactEmail', $data ); + $this->assertIsString( $data['contactEmail'] ); + + // fediverseAccount should not be present by default. + $this->assertArrayNotHasKey( 'fediverseAccount', $data ); + } + + /** + * Test capabilities filter. + * + * @covers ::get_provider_info + */ + public function test_capabilities_filter() { + // Add a test capability via filter. + add_filter( + 'activitypub_fasp_capabilities', + function ( $capabilities ) { + $capabilities[] = array( + 'id' => 'test_capability', + 'version' => '1.0', + ); + return $capabilities; + } + ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/fasp/provider_info' ); + $response = $this->controller->get_provider_info( $request ); + + $data = $response->get_data(); + + $this->assertCount( 1, $data['capabilities'] ); + $this->assertEquals( 'test_capability', $data['capabilities'][0]['id'] ); + $this->assertEquals( '1.0', $data['capabilities'][0]['version'] ); + + // Clean up. + remove_all_filters( 'activitypub_fasp_capabilities' ); + } + + /** + * Test provider name generation. + * + * @covers ::get_provider_info + */ + public function test_provider_name() { + // Test with custom site name. + update_option( 'blogname', 'Test Site' ); + + $request = new \WP_REST_Request( 'GET', '/activitypub/1.0/fasp/provider_info' ); + $response = $this->controller->get_provider_info( $request ); + + $data = $response->get_data(); + $this->assertEquals( 'Test Site ActivityPub FASP', $data['name'] ); + + // Test with empty site name. + update_option( 'blogname', '' ); + + $response = $this->controller->get_provider_info( $request ); + $data = $response->get_data(); + $this->assertEquals( 'WordPress ActivityPub FASP', $data['name'] ); + } + + /** + * Test registration endpoint registration. + * + * @covers ::register_routes + */ + public function test_registration_route_registered() { + global $wp_rest_server; + + $this->controller->register_routes(); + + $routes = $wp_rest_server->get_routes(); + + $this->assertArrayHasKey( '/activitypub/1.0/fasp/registration', $routes ); + + $route = $routes['/activitypub/1.0/fasp/registration']; + $this->assertArrayHasKey( 0, $route ); + $this->assertEquals( 'POST', $route[0]['methods']['POST'] ); + } + + /** + * Test registration endpoint response. + * + * @covers ::handle_registration + */ + public function test_registration() { + // Ed25519 public keys must be exactly 32 bytes. + $valid_ed25519_key = \base64_encode( \str_repeat( 'x', SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + $request_data = array( + 'name' => 'Test FASP Provider', + 'baseUrl' => 'https://fasp.example.com', + 'serverId' => 'test-server-123', + 'publicKey' => $valid_ed25519_key, + ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/fasp/registration' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $request_data ) ); + + $response = $this->controller->handle_registration( $request ); + + $this->assertInstanceOf( 'WP_REST_Response', $response ); + $this->assertEquals( 201, $response->get_status() ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'faspId', $data ); + $this->assertArrayHasKey( 'publicKey', $data ); + $this->assertArrayHasKey( 'registrationCompletionUri', $data ); + + // Verify data was stored. + $registrations = get_option( 'activitypub_fasp_registrations', array() ); + $this->assertNotEmpty( $registrations ); + $this->assertArrayHasKey( $data['faspId'], $registrations ); + + $stored_registration = $registrations[ $data['faspId'] ]; + $this->assertEquals( 'Test FASP Provider', $stored_registration['name'] ); + $this->assertEquals( 'https://fasp.example.com', $stored_registration['base_url'] ); + $this->assertEquals( 'test-server-123', $stored_registration['server_id'] ); + $this->assertEquals( 'pending', $stored_registration['status'] ); + $this->assertArrayHasKey( 'fasp_public_key_fingerprint', $stored_registration ); + } + + /** + * Test registration with missing fields returns error via REST API. + * + * Validation is handled by REST API args with required => true. + * + * @covers ::handle_registration + */ + public function test_registration_missing_fields() { + $request_data = array( + 'name' => 'Test FASP Provider', + 'baseUrl' => 'https://fasp.example.com', + // Missing serverId and publicKey. + ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/fasp/registration' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $request_data ) ); + + // Dispatch through REST API to trigger validation. + $response = rest_do_request( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_missing_callback_param', $data['code'] ); + } + + /** + * Test FASP registration management methods. + * + * @covers Activitypub\Fasp::get_pending_registrations + * @covers Activitypub\Fasp::approve_registration + * @covers Activitypub\Fasp::get_approved_registrations + */ + public function test_registration_management() { + // Create a test registration. + $registration_data = array( + 'fasp_id' => 'test-fasp-123', + 'name' => 'Test FASP', + 'base_url' => 'https://fasp.example.com', + 'server_id' => 'test-server-123', + 'fasp_public_key' => 'dGVzdC1wdWJsaWMta2V5', + 'fasp_public_key_fingerprint' => Fasp::get_public_key_fingerprint( 'dGVzdC1wdWJsaWMta2V5' ), + 'server_public_key' => 'c2VydmVyLXB1YmxpYy1rZXk=', + 'status' => 'pending', + 'requested_at' => current_time( 'mysql', true ), + ); + + $registrations = array( 'test-fasp-123' => $registration_data ); + update_option( 'activitypub_fasp_registrations', $registrations ); + + // Test getting pending registrations. + $pending = Fasp::get_pending_registrations(); + $this->assertCount( 1, $pending ); + $this->assertEquals( 'Test FASP', $pending[0]['name'] ); + $this->assertEquals( 'pending', $pending[0]['status'] ); + + // Test approving registration. + $result = Fasp::approve_registration( 'test-fasp-123', 1 ); + $this->assertTrue( $result ); + + // Test getting approved registrations. + $approved = Fasp::get_approved_registrations(); + $this->assertCount( 1, $approved ); + $this->assertEquals( 'Test FASP', $approved[0]['name'] ); + $this->assertEquals( 'approved', $approved[0]['status'] ); + + // Test pending registrations is now empty. + $pending = Fasp::get_pending_registrations(); + $this->assertCount( 0, $pending ); + } + + /** + * Test public key fingerprint generation. + * + * @covers Activitypub\Fasp::get_public_key_fingerprint + */ + public function test_public_key_fingerprint() { + $public_key = 'dGVzdC1wdWJsaWMta2V5'; // base64 encoded "test-public-key". + $fingerprint = Fasp::get_public_key_fingerprint( $public_key ); + + $this->assertNotEmpty( $fingerprint ); + $this->assertIsString( $fingerprint ); + + // Fingerprint should be deterministic. + $fingerprint2 = Fasp::get_public_key_fingerprint( $public_key ); + $this->assertEquals( $fingerprint, $fingerprint2 ); + } + + /** + * Test capability management. + * + * @covers Activitypub\Fasp::is_capability_enabled + */ + public function test_capability_management() { + // Initially no capabilities should be enabled. + $enabled = Fasp::is_capability_enabled( 'test-fasp-123', 'trends', 1 ); + $this->assertFalse( $enabled ); + + // Enable a capability manually. + $capabilities = array( + 'test-fasp-123_trends_v1' => array( + 'fasp_id' => 'test-fasp-123', + 'identifier' => 'trends', + 'version' => 1, + 'enabled' => true, + 'updated_at' => current_time( 'mysql', true ), + ), + ); + update_option( 'activitypub_fasp_capabilities', $capabilities ); + + // Now it should be enabled. + $enabled = Fasp::is_capability_enabled( 'test-fasp-123', 'trends', 1 ); + $this->assertTrue( $enabled ); + + // Different capability should not be enabled. + $enabled = Fasp::is_capability_enabled( 'test-fasp-123', 'search', 1 ); + $this->assertFalse( $enabled ); + } + + /** + * Test capability activation with valid serverId. + * + * Per FASP spec, keyId MUST be the serverId exchanged during registration. + * + * @covers ::enable_capability + */ + public function test_capability_activation_with_valid_server_id() { + $key_base64 = 'dGVzdC1wdWJsaWMta2V5'; + $registration_data = array( + 'fasp_id' => 'test-fasp-123', + 'name' => 'Test FASP', + 'base_url' => 'https://fasp.example.com', + 'server_id' => 'test-server-123', + 'fasp_public_key' => $key_base64, + 'fasp_public_key_fingerprint' => Fasp::get_public_key_fingerprint( $key_base64 ), + 'server_public_key' => 'c2VydmVyLXB1YmxpYy1rZXk=', + 'status' => 'approved', + 'requested_at' => current_time( 'mysql', true ), + ); + + update_option( 'activitypub_fasp_registrations', array( 'test-fasp-123' => $registration_data ) ); + + add_filter( + 'activitypub_fasp_capabilities', + function ( $capabilities ) { + $capabilities[] = array( + 'id' => 'trends', + 'version' => '1.0', + ); + return $capabilities; + } + ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/fasp/capabilities/trends/1.0/activation' ); + $request->set_param( 'identifier', 'trends' ); + $request->set_param( 'version', '1.0' ); + + /* + * Set the verified keyId parameter that would be set by Server::verify_signature(). + * Per FASP spec, keyId must be the serverId exchanged during registration. + */ + $request->set_param( 'activitypub_verified_keyid', 'test-server-123' ); + + $response = $this->controller->enable_capability( $request ); + + $this->assertInstanceOf( 'WP_REST_Response', $response ); + $this->assertEquals( 204, $response->get_status() ); + + $stored_capabilities = get_option( 'activitypub_fasp_capabilities', array() ); + $this->assertArrayHasKey( 'test-fasp-123_trends_v1.0', $stored_capabilities ); + + remove_all_filters( 'activitypub_fasp_capabilities' ); + } + + /** + * Test capability activation rejects requests from unknown FASPs. + * + * When a request comes with a serverId that doesn't match any registered FASP, + * it should be rejected. + * + * @covers ::enable_capability + */ + public function test_capability_activation_rejects_unknown_fasp() { + $key_base64 = 'dGVzdC1wdWJsaWMta2V5'; + $registration_data = array( + 'fasp_id' => 'test-fasp-123', + 'name' => 'Test FASP', + 'base_url' => 'https://fasp.example.com', + 'server_id' => 'test-server-123', + 'fasp_public_key' => $key_base64, + 'fasp_public_key_fingerprint' => Fasp::get_public_key_fingerprint( $key_base64 ), + 'server_public_key' => 'c2VydmVyLXB1YmxpYy1rZXk=', + 'status' => 'approved', + 'requested_at' => current_time( 'mysql', true ), + ); + + update_option( 'activitypub_fasp_registrations', array( 'test-fasp-123' => $registration_data ) ); + + add_filter( + 'activitypub_fasp_capabilities', + function ( $capabilities ) { + $capabilities[] = array( + 'id' => 'trends', + 'version' => '1.0', + ); + return $capabilities; + } + ); + + $request = new \WP_REST_Request( 'POST', '/activitypub/1.0/fasp/capabilities/trends/1.0/activation' ); + $request->set_param( 'identifier', 'trends' ); + $request->set_param( 'version', '1.0' ); + + /* + * Set verified keyId to an unknown/unregistered FASP serverId. + * This simulates a request signed by an unknown server. + */ + $request->set_param( 'activitypub_verified_keyid', 'unknown-server-456' ); + + $response = $this->controller->enable_capability( $request ); + + $this->assertInstanceOf( 'WP_Error', $response ); + $this->assertEquals( 'fasp_not_found', $response->get_error_code() ); + + remove_all_filters( 'activitypub_fasp_capabilities' ); + } + + /** + * Test get_registration_by_server_id returns correct registration. + * + * @covers Activitypub\Fasp::get_registration_by_server_id + */ + public function test_get_registration_by_server_id() { + $registration_data = array( + 'fasp_id' => 'test-fasp-456', + 'name' => 'Test FASP by Server ID', + 'base_url' => 'https://fasp.example.com', + 'server_id' => 'unique-server-id-789', + 'fasp_public_key' => 'dGVzdC1wdWJsaWMta2V5', + 'status' => 'approved', + 'requested_at' => current_time( 'mysql', true ), + ); + + update_option( 'activitypub_fasp_registrations', array( 'test-fasp-456' => $registration_data ) ); + + // Test finding by server_id. + $found = Fasp::get_registration_by_server_id( 'unique-server-id-789' ); + $this->assertNotNull( $found ); + $this->assertEquals( 'test-fasp-456', $found['fasp_id'] ); + $this->assertEquals( 'Test FASP by Server ID', $found['name'] ); + + // Test not finding unknown server_id. + $not_found = Fasp::get_registration_by_server_id( 'unknown-server-id' ); + $this->assertNull( $not_found ); + } + + /** + * Test public key filter returns Ed25519 key for approved FASP. + * + * @covers Activitypub\Fasp::get_public_key_for_server_id + */ + public function test_public_key_filter_returns_ed25519_key() { + // Generate a valid Ed25519 keypair for testing. + $keypair = sodium_crypto_sign_keypair(); + $public_key = sodium_crypto_sign_publickey( $keypair ); + $key_base64 = base64_encode( $public_key ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + $registration_data = array( + 'fasp_id' => 'ed25519-fasp', + 'name' => 'Ed25519 Test FASP', + 'base_url' => 'https://fasp.example.com', + 'server_id' => 'ed25519-server-id', + 'fasp_public_key' => $key_base64, + 'status' => 'approved', + 'requested_at' => current_time( 'mysql', true ), + ); + + update_option( 'activitypub_fasp_registrations', array( 'ed25519-fasp' => $registration_data ) ); + + // Ensure filter is registered. + Fasp::init(); + + // Call the filter directly. + $result = Fasp::get_public_key_for_server_id( null, 'ed25519-server-id' ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'ed25519', $result['type'] ); + $this->assertEquals( $public_key, $result['key'] ); + } + + /** + * Test public key filter returns error for unapproved FASP. + * + * @covers Activitypub\Fasp::get_public_key_for_server_id + */ + public function test_public_key_filter_rejects_unapproved_fasp() { + $registration_data = array( + 'fasp_id' => 'pending-fasp', + 'name' => 'Pending FASP', + 'base_url' => 'https://fasp.example.com', + 'server_id' => 'pending-server-id', + 'fasp_public_key' => 'dGVzdC1wdWJsaWMta2V5', + 'status' => 'pending', // Not approved. + 'requested_at' => current_time( 'mysql', true ), + ); + + update_option( 'activitypub_fasp_registrations', array( 'pending-fasp' => $registration_data ) ); + + $result = Fasp::get_public_key_for_server_id( null, 'pending-server-id' ); + + $this->assertInstanceOf( 'WP_Error', $result ); + $this->assertEquals( 'fasp_not_approved', $result->get_error_code() ); + } + + /** + * Test public key filter returns null for non-FASP keyIds. + * + * @covers Activitypub\Fasp::get_public_key_for_server_id + */ + public function test_public_key_filter_passes_through_non_fasp_keyids() { + // No FASP registrations. + delete_option( 'activitypub_fasp_registrations' ); + + // Should return null for unknown keyIds, allowing default lookup. + $result = Fasp::get_public_key_for_server_id( null, 'https://example.com/users/test#main-key' ); + $this->assertNull( $result ); + } + + /** + * Test public key filter doesn't override existing key. + * + * @covers Activitypub\Fasp::get_public_key_for_server_id + */ + public function test_public_key_filter_respects_existing_key() { + $existing_key = 'existing-key-from-another-filter'; + + $result = Fasp::get_public_key_for_server_id( $existing_key, 'any-server-id' ); + + $this->assertEquals( $existing_key, $result ); + } +} diff --git a/tests/phpunit/tests/includes/class-test-signature.php b/tests/phpunit/tests/includes/class-test-signature.php index ef4d56e131..18d56460bf 100644 --- a/tests/phpunit/tests/includes/class-test-signature.php +++ b/tests/phpunit/tests/includes/class-test-signature.php @@ -5,13 +5,14 @@ * @package Activitypub */ -// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode +// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode namespace Activitypub\Tests; use Activitypub\Collection\Actors; use Activitypub\Http; use Activitypub\Signature; +use Activitypub\Signature\Http_Message_Signature; /** * Test class for Signature. @@ -100,7 +101,8 @@ public function test_valid_hs2019_signatures_for_ec_curves( $curve, $algo ) { }; \add_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); - $this->assertTrue( Signature::verify_http_signature( $request ), "Valid hs2019 signature for curve {$curve} should verify" ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result, "Valid hs2019 signature for curve {$curve} should verify" ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); } @@ -198,7 +200,8 @@ public function test_valid_hs2019_signatures_for_rsa_sizes( $bits, $algo ) { ); }; \add_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); - $this->assertTrue( Signature::verify_http_signature( $request ), "Valid hs2019 signature for RSA {$bits} bits should verify" ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result, "Valid hs2019 signature for RSA {$bits} bits should verify" ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); } @@ -329,7 +332,8 @@ public function test_verify_http_signature_with_digest() { $request->set_body( $args['body'] ); $request->set_headers( $args['headers'] ); - $this->assertTrue( Signature::verify_http_signature( $request ) ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result ); // Create a request with a modified body but the original digest. $request->set_body( '{"type":"Create","actor":"https://example.org/author/admin","object":{"type":"Note","content":"Modified content."}}' ); @@ -349,7 +353,8 @@ public function test_verify_http_signature_with_digest() { 'HTTP_SIGNATURE' => $args['headers']['Signature'], ); - $this->assertTrue( Signature::verify_http_signature( $request ) ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); } @@ -408,7 +413,8 @@ public function test_verify_http_signature_rfc9421() { $request->set_headers( $args['headers'] ); // The verification should succeed. - $this->assertTrue( Signature::verify_http_signature( $request ) ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result ); // Create a request with a modified body but the original digest. $request->set_body( '{"type":"Create","actor":"https://example.org/author/admin","object":{"type":"Note","content":"Modified content."}}' ); @@ -430,7 +436,8 @@ public function test_verify_http_signature_rfc9421() { ); // The verification should succeed. - $this->assertTrue( Signature::verify_http_signature( $request ) ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); \delete_option( 'activitypub_rfc9421_signature' ); @@ -545,7 +552,8 @@ public function test_verify_http_signature_rfc9421_get_request() { $request->set_header( 'Signature', $signature_header ); // The verification should succeed. - $this->assertTrue( Signature::verify_http_signature( $request ) ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); } @@ -650,7 +658,8 @@ private function verify_rfc9421_signature_with_keys( $keys, $algorithm ) { $request->set_header( 'Signature', $signature_header ); // The verification should succeed. - $this->assertTrue( Signature::verify_http_signature( $request ) ); + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result ); \remove_filter( 'activitypub_pre_http_get_remote_object', $mock_remote_key_retrieval ); } @@ -745,4 +754,276 @@ public function test_set_rfc9421_unsupported() { \delete_option( 'activitypub_rfc9421_signature' ); \remove_filter( 'pre_http_request', $mock_callback ); } + + /** + * Test Ed25519 signature verification via activitypub_pre_get_public_key filter. + * + * @covers ::verify_http_signature + * @covers \Activitypub\Signature\Http_Message_Signature::verify_ed25519_signature + */ + public function test_ed25519_signature_verification() { + // Generate Ed25519 keypair. + $keypair = \sodium_crypto_sign_keypair(); + $public_key = \sodium_crypto_sign_publickey( $keypair ); + $private_key = \sodium_crypto_sign_secretkey( $keypair ); + + // Create signature base string. + $date = \gmdate( 'D, d M Y H:i:s T' ); + $created = \time(); + $params_string = \sprintf( + '("@method" "@target-uri" "date");created=%d;keyid="test-fasp-server-id"', + $created + ); + $signature_base = "\"@method\": POST\n"; + $signature_base .= "\"@target-uri\": https://example.org/wp-json/activitypub/1.0/fasp/capabilities/test/1/activation\n"; + $signature_base .= "\"date\": $date\n"; + $signature_base .= "\"@signature-params\": $params_string"; + + // Sign with Ed25519. + $signature = \sodium_crypto_sign_detached( $signature_base, $private_key ); + + // Create signature headers. + $signature_input = "sig=$params_string"; + $signature_header = 'sig=:' . \base64_encode( $signature ) . ':'; + + // Mock the public key retrieval to return Ed25519 key. + $mock_ed25519_key = function ( $key, $key_id ) use ( $public_key ) { + if ( 'test-fasp-server-id' === $key_id ) { + return array( + 'type' => 'ed25519', + 'key' => $public_key, + ); + } + return $key; + }; + \add_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key, 10, 2 ); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/' . \rest_get_url_prefix() . '/' . ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation'; + $_SERVER['HTTP_HOST'] = 'example.org'; + $_SERVER['HTTPS'] = 'on'; + + // Create REST request with Ed25519 signature. + $request = new \WP_REST_Request( 'POST', ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation' ); + $request->set_header( 'Date', $date ); + $request->set_header( 'Host', 'example.org' ); + $request->set_header( 'Signature-Input', $signature_input ); + $request->set_header( 'Signature', $signature_header ); + + // Verification should succeed and return the keyId. + $result = Signature::verify_http_signature( $request ); + $this->assertIsString( $result, 'Valid Ed25519 signature should verify' ); + $this->assertEquals( 'test-fasp-server-id', $result, 'Verified keyId should match' ); + + \remove_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key ); + } + + /** + * Test Ed25519 signature verification fails with invalid signature. + * + * @covers ::verify_http_signature + * @covers \Activitypub\Signature\Http_Message_Signature::verify_ed25519_signature + */ + public function test_ed25519_invalid_signature_fails() { + // Generate Ed25519 keypair. + $keypair = \sodium_crypto_sign_keypair(); + $public_key = \sodium_crypto_sign_publickey( $keypair ); + + // Create a different keypair to sign with (simulates wrong key). + $wrong_keypair = \sodium_crypto_sign_keypair(); + $wrong_secret_key = \sodium_crypto_sign_secretkey( $wrong_keypair ); + + // Create signature base string. + $date = \gmdate( 'D, d M Y H:i:s T' ); + $created = \time(); + $params_string = \sprintf( + '("@method" "@target-uri" "date");created=%d;keyid="test-fasp-server-id"', + $created + ); + $signature_base = "\"@method\": POST\n"; + $signature_base .= "\"@target-uri\": https://example.org/wp-json/activitypub/1.0/fasp/capabilities/test/1/activation\n"; + $signature_base .= "\"date\": $date\n"; + $signature_base .= "\"@signature-params\": $params_string"; + + // Sign with WRONG key. + $signature = \sodium_crypto_sign_detached( $signature_base, $wrong_secret_key ); + + // Create signature headers. + $signature_input = "sig=$params_string"; + $signature_header = 'sig=:' . \base64_encode( $signature ) . ':'; + + // Mock the public key retrieval to return the CORRECT public key. + $mock_ed25519_key = function ( $key, $key_id ) use ( $public_key ) { + if ( 'test-fasp-server-id' === $key_id ) { + return array( + 'type' => 'ed25519', + 'key' => $public_key, + ); + } + return $key; + }; + \add_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key, 10, 2 ); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/' . \rest_get_url_prefix() . '/' . ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation'; + $_SERVER['HTTP_HOST'] = 'example.org'; + $_SERVER['HTTPS'] = 'on'; + + // Create REST request with Ed25519 signature (signed with wrong key). + $request = new \WP_REST_Request( 'POST', ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation' ); + $request->set_header( 'Date', $date ); + $request->set_header( 'Host', 'example.org' ); + $request->set_header( 'Signature-Input', $signature_input ); + $request->set_header( 'Signature', $signature_header ); + + // Verification should fail. + $result = Signature::verify_http_signature( $request ); + $this->assertWPError( $result, 'Invalid Ed25519 signature should fail verification' ); + $this->assertEquals( 'activitypub_signature', $result->get_error_code() ); + + \remove_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key ); + } + + /** + * Test Ed25519 signature verification fails with invalid key length. + * + * @covers ::verify_http_signature + * @covers \Activitypub\Signature\Http_Message_Signature::verify_ed25519_signature + */ + public function test_ed25519_invalid_key_length_fails() { + // Create signature headers with dummy values. + $date = \gmdate( 'D, d M Y H:i:s T' ); + $created = \time(); + $params_string = \sprintf( + '("@method" "@target-uri" "date");created=%d;keyid="test-fasp-server-id"', + $created + ); + $signature_input = "sig=$params_string"; + $signature_header = 'sig=:' . \base64_encode( \str_repeat( 'x', 64 ) ) . ':'; // 64 bytes for signature. + + // Mock the public key retrieval to return an invalid length key. + $mock_invalid_key = function ( $key, $key_id ) { + if ( 'test-fasp-server-id' === $key_id ) { + return array( + 'type' => 'ed25519', + 'key' => 'too-short', // Invalid key length. + ); + } + return $key; + }; + \add_filter( 'activitypub_pre_get_public_key', $mock_invalid_key, 10, 2 ); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/' . \rest_get_url_prefix() . '/' . ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation'; + $_SERVER['HTTP_HOST'] = 'example.org'; + $_SERVER['HTTPS'] = 'on'; + + // Create REST request. + $request = new \WP_REST_Request( 'POST', ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation' ); + $request->set_header( 'Date', $date ); + $request->set_header( 'Host', 'example.org' ); + $request->set_header( 'Signature-Input', $signature_input ); + $request->set_header( 'Signature', $signature_header ); + + // Verification should fail due to invalid key length. + $result = Signature::verify_http_signature( $request ); + $this->assertWPError( $result, 'Invalid Ed25519 key length should fail verification' ); + $this->assertEquals( 'invalid_key_length', $result->get_error_code() ); + + \remove_filter( 'activitypub_pre_get_public_key', $mock_invalid_key ); + } + + /** + * Test server Ed25519 keypair generation and retrieval. + * + * @covers Activitypub\Signature::get_server_ed25519_keypair + * @covers Activitypub\Signature::get_server_ed25519_public_key + */ + public function test_server_ed25519_keypair() { + // Clear any existing keypair. + \delete_option( 'activitypub_server_ed25519_keypair' ); + + // Get keypair should generate one. + $keypair = Signature::get_server_ed25519_keypair(); + + $this->assertIsArray( $keypair ); + $this->assertArrayHasKey( 'public', $keypair ); + $this->assertArrayHasKey( 'private', $keypair ); + + // Verify key lengths (Ed25519: 32 bytes public, 64 bytes private). + $this->assertEquals( SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, \strlen( $keypair['public'] ) ); + $this->assertEquals( SODIUM_CRYPTO_SIGN_SECRETKEYBYTES, \strlen( $keypair['private'] ) ); + + // Get public key should return base64. + $public_key_b64 = Signature::get_server_ed25519_public_key(); + $this->assertIsString( $public_key_b64 ); + $this->assertEquals( $keypair['public'], \base64_decode( $public_key_b64 ) ); + + // Subsequent calls should return the same keypair. + $keypair2 = Signature::get_server_ed25519_keypair(); + $this->assertEquals( $keypair['public'], $keypair2['public'] ); + $this->assertEquals( $keypair['private'], $keypair2['private'] ); + } + + /** + * Test Ed25519 response signing. + * + * @covers Activitypub\Signature\Http_Message_Signature::sign_response_ed25519 + */ + public function test_ed25519_response_signing() { + // Generate keypair. + $keypair = \sodium_crypto_sign_keypair(); + $public_key = \sodium_crypto_sign_publickey( $keypair ); + $private_key = \sodium_crypto_sign_secretkey( $keypair ); + + // Create a response. + $response = new \WP_REST_Response( array( 'test' => 'data' ), 200 ); + $content = \wp_json_encode( array( 'test' => 'data' ) ); + + // Add content-digest header. + $signature_helper = new Http_Message_Signature(); + $digest = $signature_helper->generate_digest( $content ); + $response->header( 'Content-Digest', $digest ); + + // Sign the response. + $signature_helper->sign_response_ed25519( + $response, + $private_key, + 'test-key-id', + 'sig' + ); + + // Verify headers were added. + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'Signature-Input', $headers ); + $this->assertArrayHasKey( 'Signature', $headers ); + + // Verify signature format. + $this->assertStringContainsString( 'sig=', $headers['Signature-Input'] ); + $this->assertStringContainsString( '"@status"', $headers['Signature-Input'] ); + $this->assertStringContainsString( 'alg="ed25519"', $headers['Signature-Input'] ); + $this->assertStringContainsString( 'keyid="test-key-id"', $headers['Signature-Input'] ); + $this->assertStringStartsWith( 'sig=:', $headers['Signature'] ); + + // Extract and verify the signature. + \preg_match( '/sig=:([^:]+):/', $headers['Signature'], $matches ); + $signature = \base64_decode( $matches[1] ); + $this->assertEquals( SODIUM_CRYPTO_SIGN_BYTES, \strlen( $signature ) ); + + /* + * Verify the signature is valid by reconstructing the signature base. + * Extract created timestamp from Signature-Input. + */ + \preg_match( '/created=(\d+)/', $headers['Signature-Input'], $created_matches ); + $created = $created_matches[1]; + + $signature_base = "\"@status\": 200\n"; + $signature_base .= "\"content-digest\": {$digest}\n"; + $signature_base .= "\"@signature-params\": (\"@status\" \"content-digest\");created={$created};keyid=\"test-key-id\";alg=\"ed25519\""; + + $this->assertTrue( + \sodium_crypto_sign_verify_detached( $signature, $signature_base, $public_key ), + 'Ed25519 response signature should be valid' + ); + } }