Skip to content

Commit 9cd5a2d

Browse files
pfefferleclaude
andcommitted
Add tests for Ed25519 signature verification and FASP integration.
New FASP tests: - Registration lookup by server_id - Public key filter integration with Ed25519 keys - Rejection of unapproved FASP registrations - Pass-through for non-FASP keyIds - Respecting existing keys from other filters New Signature tests: - Ed25519 signature verification via sodium - Invalid signature detection - Invalid key length detection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 97579c6 commit 9cd5a2d

File tree

2 files changed

+291
-0
lines changed

2 files changed

+291
-0
lines changed

tests/phpunit/tests/includes/class-test-fasp.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,4 +485,118 @@ function ( $capabilities ) {
485485

486486
remove_all_filters( 'activitypub_fasp_capabilities' );
487487
}
488+
489+
/**
490+
* Test get_registration_by_server_id returns correct registration.
491+
*
492+
* @covers Activitypub\Fasp::get_registration_by_server_id
493+
*/
494+
public function test_get_registration_by_server_id() {
495+
$registration_data = array(
496+
'fasp_id' => 'test-fasp-456',
497+
'name' => 'Test FASP by Server ID',
498+
'base_url' => 'https://fasp.example.com',
499+
'server_id' => 'unique-server-id-789',
500+
'fasp_public_key' => 'dGVzdC1wdWJsaWMta2V5',
501+
'status' => 'approved',
502+
'requested_at' => current_time( 'mysql', true ),
503+
);
504+
505+
update_option( 'activitypub_fasp_registrations', array( 'test-fasp-456' => $registration_data ) );
506+
507+
// Test finding by server_id.
508+
$found = Fasp::get_registration_by_server_id( 'unique-server-id-789' );
509+
$this->assertNotNull( $found );
510+
$this->assertEquals( 'test-fasp-456', $found['fasp_id'] );
511+
$this->assertEquals( 'Test FASP by Server ID', $found['name'] );
512+
513+
// Test not finding unknown server_id.
514+
$not_found = Fasp::get_registration_by_server_id( 'unknown-server-id' );
515+
$this->assertNull( $not_found );
516+
}
517+
518+
/**
519+
* Test public key filter returns Ed25519 key for approved FASP.
520+
*
521+
* @covers Activitypub\Fasp::get_public_key_for_server_id
522+
*/
523+
public function test_public_key_filter_returns_ed25519_key() {
524+
// Generate a valid Ed25519 keypair for testing.
525+
$keypair = sodium_crypto_sign_keypair();
526+
$public_key = sodium_crypto_sign_publickey( $keypair );
527+
$key_base64 = base64_encode( $public_key ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
528+
529+
$registration_data = array(
530+
'fasp_id' => 'ed25519-fasp',
531+
'name' => 'Ed25519 Test FASP',
532+
'base_url' => 'https://fasp.example.com',
533+
'server_id' => 'ed25519-server-id',
534+
'fasp_public_key' => $key_base64,
535+
'status' => 'approved',
536+
'requested_at' => current_time( 'mysql', true ),
537+
);
538+
539+
update_option( 'activitypub_fasp_registrations', array( 'ed25519-fasp' => $registration_data ) );
540+
541+
// Ensure filter is registered.
542+
Fasp::init();
543+
544+
// Call the filter directly.
545+
$result = Fasp::get_public_key_for_server_id( null, 'ed25519-server-id' );
546+
547+
$this->assertIsArray( $result );
548+
$this->assertEquals( 'ed25519', $result['type'] );
549+
$this->assertEquals( $public_key, $result['key'] );
550+
}
551+
552+
/**
553+
* Test public key filter returns error for unapproved FASP.
554+
*
555+
* @covers Activitypub\Fasp::get_public_key_for_server_id
556+
*/
557+
public function test_public_key_filter_rejects_unapproved_fasp() {
558+
$registration_data = array(
559+
'fasp_id' => 'pending-fasp',
560+
'name' => 'Pending FASP',
561+
'base_url' => 'https://fasp.example.com',
562+
'server_id' => 'pending-server-id',
563+
'fasp_public_key' => 'dGVzdC1wdWJsaWMta2V5',
564+
'status' => 'pending', // Not approved.
565+
'requested_at' => current_time( 'mysql', true ),
566+
);
567+
568+
update_option( 'activitypub_fasp_registrations', array( 'pending-fasp' => $registration_data ) );
569+
570+
$result = Fasp::get_public_key_for_server_id( null, 'pending-server-id' );
571+
572+
$this->assertInstanceOf( 'WP_Error', $result );
573+
$this->assertEquals( 'fasp_not_approved', $result->get_error_code() );
574+
}
575+
576+
/**
577+
* Test public key filter returns null for non-FASP keyIds.
578+
*
579+
* @covers Activitypub\Fasp::get_public_key_for_server_id
580+
*/
581+
public function test_public_key_filter_passes_through_non_fasp_keyids() {
582+
// No FASP registrations.
583+
delete_option( 'activitypub_fasp_registrations' );
584+
585+
// Should return null for unknown keyIds, allowing default lookup.
586+
$result = Fasp::get_public_key_for_server_id( null, 'https://example.com/users/test#main-key' );
587+
$this->assertNull( $result );
588+
}
589+
590+
/**
591+
* Test public key filter doesn't override existing key.
592+
*
593+
* @covers Activitypub\Fasp::get_public_key_for_server_id
594+
*/
595+
public function test_public_key_filter_respects_existing_key() {
596+
$existing_key = 'existing-key-from-another-filter';
597+
598+
$result = Fasp::get_public_key_for_server_id( $existing_key, 'any-server-id' );
599+
600+
$this->assertEquals( $existing_key, $result );
601+
}
488602
}

tests/phpunit/tests/includes/class-test-signature.php

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,4 +745,181 @@ public function test_set_rfc9421_unsupported() {
745745
\delete_option( 'activitypub_rfc9421_signature' );
746746
\remove_filter( 'pre_http_request', $mock_callback );
747747
}
748+
749+
/**
750+
* Test Ed25519 signature verification via activitypub_pre_get_public_key filter.
751+
*
752+
* @covers ::verify_http_signature
753+
* @covers \Activitypub\Signature\Http_Message_Signature::verify_ed25519_signature
754+
*/
755+
public function test_ed25519_signature_verification() {
756+
// Generate Ed25519 keypair.
757+
$keypair = \sodium_crypto_sign_keypair();
758+
$public_key = \sodium_crypto_sign_publickey( $keypair );
759+
$private_key = \sodium_crypto_sign_secretkey( $keypair );
760+
761+
// Create signature base string.
762+
$date = \gmdate( 'D, d M Y H:i:s T' );
763+
$created = \time();
764+
$params_string = \sprintf(
765+
'("@method" "@target-uri" "date");created=%d;keyid="test-fasp-server-id"',
766+
$created
767+
);
768+
$signature_base = "\"@method\": POST\n";
769+
$signature_base .= "\"@target-uri\": https://example.org/wp-json/activitypub/1.0/fasp/capabilities/test/1/activation\n";
770+
$signature_base .= "\"date\": $date\n";
771+
$signature_base .= "\"@signature-params\": $params_string";
772+
773+
// Sign with Ed25519.
774+
$signature = \sodium_crypto_sign_detached( $signature_base, $private_key );
775+
776+
// Create signature headers.
777+
$signature_input = "sig=$params_string";
778+
$signature_header = 'sig=:' . \base64_encode( $signature ) . ':';
779+
780+
// Mock the public key retrieval to return Ed25519 key.
781+
$mock_ed25519_key = function ( $key, $key_id ) use ( $public_key ) {
782+
if ( 'test-fasp-server-id' === $key_id ) {
783+
return array(
784+
'type' => 'ed25519',
785+
'key' => $public_key,
786+
);
787+
}
788+
return $key;
789+
};
790+
\add_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key, 10, 2 );
791+
792+
$_SERVER['REQUEST_METHOD'] = 'POST';
793+
$_SERVER['REQUEST_URI'] = '/' . \rest_get_url_prefix() . '/' . ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation';
794+
$_SERVER['HTTP_HOST'] = 'example.org';
795+
$_SERVER['HTTPS'] = 'on';
796+
797+
// Create REST request with Ed25519 signature.
798+
$request = new \WP_REST_Request( 'POST', ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation' );
799+
$request->set_header( 'Date', $date );
800+
$request->set_header( 'Host', 'example.org' );
801+
$request->set_header( 'Signature-Input', $signature_input );
802+
$request->set_header( 'Signature', $signature_header );
803+
804+
// Verification should succeed.
805+
$result = Signature::verify_http_signature( $request );
806+
$this->assertTrue( $result, 'Valid Ed25519 signature should verify' );
807+
808+
\remove_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key );
809+
}
810+
811+
/**
812+
* Test Ed25519 signature verification fails with invalid signature.
813+
*
814+
* @covers ::verify_http_signature
815+
* @covers \Activitypub\Signature\Http_Message_Signature::verify_ed25519_signature
816+
*/
817+
public function test_ed25519_invalid_signature_fails() {
818+
// Generate Ed25519 keypair.
819+
$keypair = \sodium_crypto_sign_keypair();
820+
$public_key = \sodium_crypto_sign_publickey( $keypair );
821+
822+
// Create a different keypair to sign with (simulates wrong key).
823+
$wrong_keypair = \sodium_crypto_sign_keypair();
824+
$wrong_secret_key = \sodium_crypto_sign_secretkey( $wrong_keypair );
825+
826+
// Create signature base string.
827+
$date = \gmdate( 'D, d M Y H:i:s T' );
828+
$created = \time();
829+
$params_string = \sprintf(
830+
'("@method" "@target-uri" "date");created=%d;keyid="test-fasp-server-id"',
831+
$created
832+
);
833+
$signature_base = "\"@method\": POST\n";
834+
$signature_base .= "\"@target-uri\": https://example.org/wp-json/activitypub/1.0/fasp/capabilities/test/1/activation\n";
835+
$signature_base .= "\"date\": $date\n";
836+
$signature_base .= "\"@signature-params\": $params_string";
837+
838+
// Sign with WRONG key.
839+
$signature = \sodium_crypto_sign_detached( $signature_base, $wrong_secret_key );
840+
841+
// Create signature headers.
842+
$signature_input = "sig=$params_string";
843+
$signature_header = 'sig=:' . \base64_encode( $signature ) . ':';
844+
845+
// Mock the public key retrieval to return the CORRECT public key.
846+
$mock_ed25519_key = function ( $key, $key_id ) use ( $public_key ) {
847+
if ( 'test-fasp-server-id' === $key_id ) {
848+
return array(
849+
'type' => 'ed25519',
850+
'key' => $public_key,
851+
);
852+
}
853+
return $key;
854+
};
855+
\add_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key, 10, 2 );
856+
857+
$_SERVER['REQUEST_METHOD'] = 'POST';
858+
$_SERVER['REQUEST_URI'] = '/' . \rest_get_url_prefix() . '/' . ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation';
859+
$_SERVER['HTTP_HOST'] = 'example.org';
860+
$_SERVER['HTTPS'] = 'on';
861+
862+
// Create REST request with Ed25519 signature (signed with wrong key).
863+
$request = new \WP_REST_Request( 'POST', ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation' );
864+
$request->set_header( 'Date', $date );
865+
$request->set_header( 'Host', 'example.org' );
866+
$request->set_header( 'Signature-Input', $signature_input );
867+
$request->set_header( 'Signature', $signature_header );
868+
869+
// Verification should fail.
870+
$result = Signature::verify_http_signature( $request );
871+
$this->assertWPError( $result, 'Invalid Ed25519 signature should fail verification' );
872+
$this->assertEquals( 'activitypub_signature', $result->get_error_code() );
873+
874+
\remove_filter( 'activitypub_pre_get_public_key', $mock_ed25519_key );
875+
}
876+
877+
/**
878+
* Test Ed25519 signature verification fails with invalid key length.
879+
*
880+
* @covers ::verify_http_signature
881+
* @covers \Activitypub\Signature\Http_Message_Signature::verify_ed25519_signature
882+
*/
883+
public function test_ed25519_invalid_key_length_fails() {
884+
// Create signature headers with dummy values.
885+
$date = \gmdate( 'D, d M Y H:i:s T' );
886+
$created = \time();
887+
$params_string = \sprintf(
888+
'("@method" "@target-uri" "date");created=%d;keyid="test-fasp-server-id"',
889+
$created
890+
);
891+
$signature_input = "sig=$params_string";
892+
$signature_header = 'sig=:' . \base64_encode( \str_repeat( 'x', 64 ) ) . ':'; // 64 bytes for signature.
893+
894+
// Mock the public key retrieval to return an invalid length key.
895+
$mock_invalid_key = function ( $key, $key_id ) {
896+
if ( 'test-fasp-server-id' === $key_id ) {
897+
return array(
898+
'type' => 'ed25519',
899+
'key' => 'too-short', // Invalid key length.
900+
);
901+
}
902+
return $key;
903+
};
904+
\add_filter( 'activitypub_pre_get_public_key', $mock_invalid_key, 10, 2 );
905+
906+
$_SERVER['REQUEST_METHOD'] = 'POST';
907+
$_SERVER['REQUEST_URI'] = '/' . \rest_get_url_prefix() . '/' . ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation';
908+
$_SERVER['HTTP_HOST'] = 'example.org';
909+
$_SERVER['HTTPS'] = 'on';
910+
911+
// Create REST request.
912+
$request = new \WP_REST_Request( 'POST', ACTIVITYPUB_REST_NAMESPACE . '/fasp/capabilities/test/1/activation' );
913+
$request->set_header( 'Date', $date );
914+
$request->set_header( 'Host', 'example.org' );
915+
$request->set_header( 'Signature-Input', $signature_input );
916+
$request->set_header( 'Signature', $signature_header );
917+
918+
// Verification should fail due to invalid key length.
919+
$result = Signature::verify_http_signature( $request );
920+
$this->assertWPError( $result, 'Invalid Ed25519 key length should fail verification' );
921+
$this->assertEquals( 'invalid_key_length', $result->get_error_code() );
922+
923+
\remove_filter( 'activitypub_pre_get_public_key', $mock_invalid_key );
924+
}
748925
}

0 commit comments

Comments
 (0)