Skip to content

Commit 396e265

Browse files
committed
Integrate FASP signature handling with existing system.
- Add `activitypub_pre_get_public_key` filter to Remote_Actors::get_public_key() allowing custom key resolution for non-URL keyIds (like FASP serverIds) - Add Ed25519 signature verification using WordPress's sodium_compat: - Http_Message_Signature now handles Ed25519 keys via sodium_crypto_sign_verify_detached() - Ed25519 keys are passed as arrays: ['type' => 'ed25519', 'key' => $raw_bytes] - FASP integration: - Fasp::init() registers filter to provide Ed25519 public keys for serverId lookups - Fasp::get_registration_by_server_id() added for server_id based lookups - FASP now uses the same signature verification code path as ActivityPub - Reuse Application actor's RSA keypair for signing FASP responses (already in place)
1 parent 59c0b8f commit 396e265

4 files changed

Lines changed: 179 additions & 21 deletions

File tree

includes/class-fasp.php

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,88 @@ class Fasp {
1919
* Initialize the class, registering WordPress hooks.
2020
*/
2121
public static function init() {
22-
// No hooks needed currently.
22+
\add_filter( 'activitypub_pre_get_public_key', array( __CLASS__, 'get_public_key_for_server_id' ), 10, 2 );
23+
}
24+
25+
/**
26+
* Provide public key for FASP serverId lookups.
27+
*
28+
* This filter integrates FASP signature verification with the existing
29+
* ActivityPub signature system. When a signature's keyId matches a
30+
* registered FASP's serverId, we return the stored public key.
31+
*
32+
* FASP uses Ed25519 keys, so we return an array with type information
33+
* that the signature verification system can use.
34+
*
35+
* @param resource|string|array|\WP_Error|null $public_key The current public key (null to continue lookup).
36+
* @param string $key_id The key ID from the signature.
37+
* @return resource|string|array|\WP_Error|null The public key or null to continue default lookup.
38+
*/
39+
public static function get_public_key_for_server_id( $public_key, $key_id ) {
40+
// If another filter already provided a key, don't override.
41+
if ( null !== $public_key ) {
42+
return $public_key;
43+
}
44+
45+
// Try to find a FASP registration matching this serverId.
46+
$registration = self::get_registration_by_server_id( $key_id );
47+
48+
if ( ! $registration ) {
49+
return null; // Not a FASP serverId, continue with default lookup.
50+
}
51+
52+
// Check if FASP is approved.
53+
if ( 'approved' !== $registration['status'] ) {
54+
return new \WP_Error(
55+
'fasp_not_approved',
56+
'FASP registration is not approved',
57+
array( 'status' => 403 )
58+
);
59+
}
60+
61+
// Return the stored public key.
62+
if ( empty( $registration['fasp_public_key'] ) ) {
63+
return new \WP_Error(
64+
'fasp_no_public_key',
65+
'FASP registration does not have a public key',
66+
array( 'status' => 401 )
67+
);
68+
}
69+
70+
// FASP uses Ed25519 keys stored as base64.
71+
// Decode and return as Ed25519 key array for signature verification.
72+
$raw_key = base64_decode( $registration['fasp_public_key'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
73+
74+
if ( false === $raw_key ) {
75+
return new \WP_Error(
76+
'fasp_invalid_key',
77+
'FASP public key is not valid base64',
78+
array( 'status' => 401 )
79+
);
80+
}
81+
82+
return array(
83+
'type' => 'ed25519',
84+
'key' => $raw_key,
85+
);
86+
}
87+
88+
/**
89+
* Get registration by server ID.
90+
*
91+
* @param string $server_id The server ID from the FASP.
92+
* @return array|null Registration data or null if not found.
93+
*/
94+
public static function get_registration_by_server_id( $server_id ) {
95+
$registrations = self::get_registrations_store();
96+
97+
foreach ( $registrations as $registration ) {
98+
if ( isset( $registration['server_id'] ) && $registration['server_id'] === $server_id ) {
99+
return $registration;
100+
}
101+
}
102+
103+
return null;
23104
}
24105

25106

includes/collection/class-remote-actors.php

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,47 @@ public static function normalize_identifier( $actor ) {
633633
/**
634634
* Get public key from key_id.
635635
*
636-
* @param string $key_id The URL to the public key.
636+
* @param string $key_id The key ID (typically a URL to the public key, but can be any identifier).
637637
*
638-
* @return resource|\WP_Error The public key resource or WP_Error.
638+
* @return resource|array|\WP_Error The public key resource, Ed25519 key array, or WP_Error.
639639
*/
640640
public static function get_public_key( $key_id ) {
641+
/**
642+
* Filter to allow custom public key resolution for non-URL key IDs.
643+
*
644+
* This filter allows other protocols (like FASP) to provide public keys
645+
* for key IDs that are not ActivityPub actor URLs.
646+
*
647+
* Return formats:
648+
* - OpenSSL resource: Standard RSA/EC key
649+
* - PEM string: Will be converted to OpenSSL resource
650+
* - Array with 'type' => 'ed25519' and 'key' => raw bytes: Ed25519 key
651+
* - WP_Error: Return error to caller
652+
* - null: Continue with default ActivityPub lookup
653+
*
654+
* @param resource|string|array|\WP_Error|null $public_key The public key.
655+
* @param string $key_id The key ID from the signature.
656+
*/
657+
$public_key = \apply_filters( 'activitypub_pre_get_public_key', null, $key_id );
658+
659+
if ( null !== $public_key ) {
660+
// If filter returned an Ed25519 key array, pass it through.
661+
if ( \is_array( $public_key ) && isset( $public_key['type'] ) && 'ed25519' === $public_key['type'] ) {
662+
return $public_key;
663+
}
664+
665+
// If filter returned a PEM string, convert to resource.
666+
if ( \is_string( $public_key ) && ! \is_wp_error( $public_key ) ) {
667+
$key_resource = \openssl_pkey_get_public( \rtrim( $public_key ) );
668+
if ( $key_resource ) {
669+
return $key_resource;
670+
}
671+
return new \WP_Error( 'activitypub_invalid_key', 'Invalid public key format', array( 'status' => 401 ) );
672+
}
673+
674+
return $public_key;
675+
}
676+
641677
$actor = self::get_by_uri( \strip_fragment_from_url( $key_id ) );
642678

643679
if ( \is_wp_error( $actor ) ) {

includes/rest/class-fasp-controller.php

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ private function extract_keyid_from_request( $headers ) {
509509
}
510510

511511
/**
512-
* Look up FASP registration by keyId.
512+
* Look up FASP registration by keyId (serverId).
513513
*
514514
* Per FASP spec, the keyId MUST be the identifier exchanged during registration (serverId).
515515
*
@@ -519,20 +519,17 @@ private function extract_keyid_from_request( $headers ) {
519519
* @return array|\WP_Error FASP data or error.
520520
*/
521521
private function get_fasp_by_keyid( $keyid ) {
522-
$registrations = $this->get_registration_records();
522+
$registration = Fasp::get_registration_by_server_id( $keyid );
523523

524-
// Match by server_id (the identifier exchanged during registration).
525-
foreach ( $registrations as $registration ) {
526-
if ( $keyid === $registration['server_id'] ) {
527-
return $registration;
528-
}
524+
if ( ! $registration ) {
525+
return new \WP_Error(
526+
'fasp_not_found',
527+
'FASP not found for provided keyId',
528+
array( 'status' => 404 )
529+
);
529530
}
530531

531-
return new \WP_Error(
532-
'fasp_not_found',
533-
'FASP not found for provided keyId',
534-
array( 'status' => 404 )
535-
);
532+
return $registration;
536533
}
537534

538535
/**

includes/signature/class-http-message-signature.php

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,6 @@ private function verify_signature_label( $data, $headers, $body ) {
277277
return $public_key;
278278
}
279279

280-
// Algorithm verification.
281-
$algorithm = $this->verify_algorithm( $params['alg'] ?? '', $public_key );
282-
if ( \is_wp_error( $algorithm ) ) {
283-
return $algorithm;
284-
}
285-
286280
// Digest verification.
287281
$result = $this->verify_content_digest( $headers, $body );
288282
if ( \is_wp_error( $result ) ) {
@@ -292,6 +286,17 @@ private function verify_signature_label( $data, $headers, $body ) {
292286
$components = $this->get_component_values( $data['components'], $headers );
293287
$signature_base = $this->get_signature_base_string( $components, $params );
294288

289+
// Handle Ed25519 keys (e.g., from FASP).
290+
if ( \is_array( $public_key ) && isset( $public_key['type'] ) && 'ed25519' === $public_key['type'] ) {
291+
return $this->verify_ed25519_signature( $signature_base, $data['signature'], $public_key['key'] );
292+
}
293+
294+
// Standard OpenSSL verification for RSA/EC keys.
295+
$algorithm = $this->verify_algorithm( $params['alg'] ?? '', $public_key );
296+
if ( \is_wp_error( $algorithm ) ) {
297+
return $algorithm;
298+
}
299+
295300
$verified = \openssl_verify( $signature_base, $data['signature'], $public_key, $algorithm ) > 0;
296301
if ( ! $verified ) {
297302
return new \WP_Error( 'activitypub_signature', 'Invalid signature' );
@@ -300,6 +305,45 @@ private function verify_signature_label( $data, $headers, $body ) {
300305
return true;
301306
}
302307

308+
/**
309+
* Verify an Ed25519 signature using WordPress's sodium_compat.
310+
*
311+
* @param string $message The message that was signed.
312+
* @param string $signature The signature to verify.
313+
* @param string $public_key The Ed25519 public key (32 bytes).
314+
* @return bool|\WP_Error True if valid, WP_Error on failure.
315+
*/
316+
private function verify_ed25519_signature( $message, $signature, $public_key ) {
317+
// Ed25519 signatures are 64 bytes.
318+
if ( \strlen( $signature ) !== SODIUM_CRYPTO_SIGN_BYTES ) {
319+
return new \WP_Error(
320+
'invalid_signature_length',
321+
\sprintf( 'Invalid Ed25519 signature length: expected %d bytes, got %d', SODIUM_CRYPTO_SIGN_BYTES, \strlen( $signature ) )
322+
);
323+
}
324+
325+
// Ed25519 public keys are 32 bytes.
326+
if ( \strlen( $public_key ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) {
327+
return new \WP_Error(
328+
'invalid_key_length',
329+
\sprintf( 'Invalid Ed25519 public key length: expected %d bytes, got %d', SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES, \strlen( $public_key ) )
330+
);
331+
}
332+
333+
try {
334+
// Use WordPress's sodium_compat for Ed25519 verification.
335+
$verified = \sodium_crypto_sign_verify_detached( $signature, $message, $public_key );
336+
} catch ( \Exception $e ) {
337+
return new \WP_Error( 'ed25519_verification_failed', 'Ed25519 signature verification failed: ' . $e->getMessage() );
338+
}
339+
340+
if ( ! $verified ) {
341+
return new \WP_Error( 'activitypub_signature', 'Invalid Ed25519 signature' );
342+
}
343+
344+
return true;
345+
}
346+
303347
/**
304348
* Verify the Content-Digest header against the request body.
305349
*

0 commit comments

Comments
 (0)