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'
+ );
+ }
}