PluginProbe
ActivityPub / 8.0.2
ActivityPub v8.0.2
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
← All changes | includes/class-signature.php +340 -279 2.0.18.0.2 View file →
@@ -1,15 +1,20 @@
1 1 <?php
2 +/**
3 + * Signature class file.
4 + *
5 + * @package Activitypub
6 + */
7 +
2 8 namespace Activitypub;
3 9
4 -use WP_Error;
5 -use DateTime;
6 -use DateTimeZone;
7 -use WP_REST_Request;
8 -use Activitypub\Collection\Users;
10 +use Activitypub\Collection\Actors;
11 +use Activitypub\Collection\Remote_Actors;
12 +use Activitypub\Signature\Http_Message_Signature;
13 +use Activitypub\Signature\Http_Signature_Draft;
9 14
10 15 /**
11 - * ActivityPub Signature Class
16 + * ActivityPub Signature Class.
12 17 *
13 18 * @author Matthias Pfefferle
14 19 * @author Django Doucet
15 20 */
@@ -15,191 +20,266 @@
15 20 */
16 21 class Signature {
17 22
18 23 /**
19 - * Return the public key for a given user.
24 + * Initialize the class.
25 + */
26 + public static function init() {
27 + \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 0, 2 ); // Ahead of all other filters, so signature is set.
28 + \add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 );
29 + }
30 +
31 + /**
32 + * Sign an HTTP Request.
20 33 *
21 - * @param int $user_id The WordPress User ID.
22 - * @param bool $force Force the generation of a new key pair.
34 + * @param array $args An array of HTTP request arguments.
35 + * @param string $url The request URL.
23 36 *
24 - * @return mixed The public key.
37 + * @return array Request arguments with signature headers.
25 38 */
26 - public static function get_public_key_for( $user_id, $force = false ) {
27 - if ( $force ) {
28 - self::generate_key_pair_for( $user_id );
39 + public static function sign_request( $args, $url ) {
40 + // Bail if there's nothing to sign with.
41 + if ( ! isset( $args['key_id'], $args['private_key'] ) ) {
42 + return $args;
29 43 }
30 44
31 - $key_pair = self::get_keypair_for( $user_id );
45 + if ( '1' === \get_option( 'activitypub_rfc9421_signature' ) && self::could_support_rfc9421( $url ) ) {
46 + $signature = new Http_Message_Signature();
47 + } else {
48 + $signature = new Http_Signature_Draft();
49 + }
32 50
33 - return $key_pair['public_key'];
51 + return $signature->sign( $args, $url );
34 52 }
35 53
36 54 /**
37 - * Return the private key for a given user.
55 + * Verifies the http signatures
38 56 *
39 - * @param int $user_id The WordPress User ID.
40 - * @param bool $force Force the generation of a new key pair.
57 + * @param \WP_REST_Request|array $request The request object or $_SERVER array.
41 58 *
42 - * @return mixed The private key.
59 + * @return bool|\WP_Error A boolean or WP_Error.
43 60 */
44 - public static function get_private_key_for( $user_id, $force = false ) {
45 - if ( $force ) {
46 - self::generate_key_pair_for( $user_id );
61 + public static function verify_http_signature( $request ) {
62 + if ( is_object( $request ) ) { // REST Request object.
63 + $body = $request->get_body();
64 + $headers = $request->get_headers();
65 + $headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . self::get_route( $request );
66 + } else {
67 + $headers = self::format_server_request( $request );
68 + $headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
47 69 }
48 70
49 - $key_pair = self::get_keypair_for( $user_id );
71 + $signature = isset( $headers['signature_input'] ) ? new Http_Message_Signature() : new Http_Signature_Draft();
50 72
51 - return $key_pair['private_key'];
73 + return $signature->verify( $headers, $body ?? null );
52 74 }
53 75
54 76 /**
55 - * Return the key pair for a given user.
77 + * If a request with RFC-9421 signature fails, we try again with the Draft Cavage signature.
56 78 *
57 - * @param int $user_id The WordPress User ID.
79 + * @param array $response HTTP response.
80 + * @param array $args HTTP request arguments.
81 + * @param string $url The request URL.
58 82 *
59 - * @return array The key pair.
83 + * @return array The HTTP response.
60 84 */
61 - public static function get_keypair_for( $user_id ) {
62 - $option_key = self::get_signature_options_key_for( $user_id );
63 - $key_pair = \get_option( $option_key );
85 + public static function maybe_double_knock( $response, $args, $url ) {
86 + // Bail if it didn't use an RFC-9421 signature or there's nothing to sign with.
87 + if ( ! isset( $args['key_id'], $args['private_key'], $args['headers']['Signature-Input'] ) ) {
88 + return $response;
89 + }
64 90
65 - if ( ! $key_pair ) {
66 - $key_pair = self::generate_key_pair_for( $user_id );
91 + $response_code = \wp_remote_retrieve_response_code( $response );
92 +
93 + // Fall back to Draft Cavage signature for any 4xx responses.
94 + if ( $response_code >= 400 && $response_code < 500 ) {
95 + unset( $args['headers']['Signature'], $args['headers']['Signature-Input'], $args['headers']['Content-Digest'] );
96 + self::rfc9421_add_unsupported_host( $url );
97 +
98 + $args = ( new Http_Signature_Draft() )->sign( $args, $url );
99 + $response = \wp_remote_request( $url, $args );
67 100 }
68 101
69 - return $key_pair;
102 + return $response;
70 103 }
71 104
72 105 /**
73 - * Generates the pair keys
106 + * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
107 + * for use with verify_http_signature().
74 108 *
75 - * @param int $user_id The WordPress User ID.
109 + * @param array $server The $_SERVER array.
76 110 *
77 - * @return array The key pair.
111 + * @return array $request The formatted request array.
78 112 */
79 - protected static function generate_key_pair_for( $user_id ) {
80 - $option_key = self::get_signature_options_key_for( $user_id );
81 - $key_pair = self::check_legacy_key_pair_for( $user_id );
113 + public static function format_server_request( $server ) {
114 + $headers = array();
82 115
83 - if ( $key_pair ) {
84 - \add_option( $option_key, $key_pair );
116 + foreach ( $server as $key => $value ) {
117 + $key = \str_replace( 'http_', '', \strtolower( $key ) );
118 + $headers[ $key ][] = \wp_unslash( $value );
85 119
86 - return $key_pair;
87 120 }
88 121
89 - $config = array(
90 - 'digest_alg' => 'sha512',
91 - 'private_key_bits' => 2048,
92 - 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
93 - );
122 + return $headers;
123 + }
94 124
95 - $key = \openssl_pkey_new( $config );
96 - $priv_key = null;
125 + /**
126 + * Returns route.
127 + *
128 + * @param \WP_REST_Request $request The request object.
129 + *
130 + * @return string
131 + */
132 + private static function get_route( $request ) {
133 + // Check if the route starts with "index.php".
134 + if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
135 + $route = $request->get_route();
136 + } else {
137 + $route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
138 + }
97 139
98 - \openssl_pkey_export( $key, $priv_key );
140 + // Fix route for subdirectory installations.
141 + $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
99 142
100 - $detail = \openssl_pkey_get_details( $key );
143 + if ( \is_string( $path ) ) {
144 + $path = trim( $path, '/' );
145 + }
101 146
102 - // check if keys are valid
103 - if (
104 - empty( $priv_key ) || ! is_string( $priv_key ) ||
105 - ! isset( $detail['key'] ) || ! is_string( $detail['key'] )
106 - ) {
107 - return array(
108 - 'private_key' => null,
109 - 'public_key' => null,
110 - );
147 + if ( $path ) {
148 + $route = '/' . $path . $route;
111 149 }
112 150
113 - $key_pair = array(
114 - 'private_key' => $priv_key,
115 - 'public_key' => $detail['key'],
116 - );
151 + return $route;
152 + }
117 153
118 - // persist keys
119 - \add_option( $option_key, $key_pair );
154 + /**
155 + * Check if RFC-9421 signature could be supported.
156 + *
157 + * @param string $url The URL to check.
158 + *
159 + * @return bool True, if RFC-9421 signature could be supported, false otherwise.
160 + */
161 + private static function could_support_rfc9421( $url ) {
162 + $host = \wp_parse_url( $url, \PHP_URL_HOST );
163 + $list = \get_option( 'activitypub_rfc9421_unsupported', array() );
120 164
121 - return $key_pair;
165 + if ( isset( $list[ $host ] ) ) {
166 + if ( $list[ $host ] > \time() ) {
167 + return false;
168 + }
169 +
170 + unset( $list[ $host ] );
171 + \update_option( 'activitypub_rfc9421_unsupported', $list );
172 + }
173 +
174 + return true;
122 175 }
123 176
124 177 /**
125 - * Return the option key for a given user.
178 + * Set RFC-9421 signature unsupported for a given host.
126 179 *
127 - * @param int $user_id The WordPress User ID.
180 + * @param string $url The URL to set.
181 + */
182 + private static function rfc9421_add_unsupported_host( $url ) {
183 + $list = \get_option( 'activitypub_rfc9421_unsupported', array() );
184 + $host = \wp_parse_url( $url, \PHP_URL_HOST );
185 +
186 + $list[ $host ] = \time() + MONTH_IN_SECONDS;
187 + \update_option( 'activitypub_rfc9421_unsupported', $list, false );
188 + }
189 +
190 + /**
191 + * Return the public key for a given user.
128 192 *
129 - * @return string The option key.
193 + * @deprecated 7.0.0 Use {@see Actors::get_public_key()}.
194 + *
195 + * @param int $user_id The WordPress User ID.
196 + * @param bool $force Optional. Force the generation of a new key pair. Default false.
197 + *
198 + * @return string The public key.
130 199 */
131 - protected static function get_signature_options_key_for( $user_id ) {
132 - $id = $user_id;
200 + public static function get_public_key_for( $user_id, $force = false ) {
201 + \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_public_key' );
133 202
134 - if ( $user_id > 0 ) {
135 - $user = \get_userdata( $user_id );
136 - // sanatize username because it could include spaces and special chars
137 - $id = sanitize_title( $user->user_login );
138 - }
203 + return Actors::get_public_key( $user_id, $force );
204 + }
139 205
140 - return 'activitypub_keypair_for_' . $id;
206 + /**
207 + * Return the private key for a given user.
208 + *
209 + * @deprecated 7.0.0 Use {@see Actors::get_private_key()}.
210 + *
211 + * @param int $user_id The WordPress User ID.
212 + * @param bool $force Optional. Force the generation of a new key pair. Default false.
213 + *
214 + * @return string The private key.
215 + */
216 + public static function get_private_key_for( $user_id, $force = false ) {
217 + \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_private_key' );
218 +
219 + return Actors::get_private_key( $user_id, $force );
141 220 }
142 221
143 222 /**
144 - * Check if there is a legacy key pair
223 + * Return the key pair for a given user.
145 224 *
225 + * @deprecated 7.0.0 Use {@see Actors::get_keypair()}.
226 + *
146 227 * @param int $user_id The WordPress User ID.
147 228 *
148 - * @return array|bool The key pair or false.
229 + * @return array The key pair.
149 230 */
150 - protected static function check_legacy_key_pair_for( $user_id ) {
151 - switch ( $user_id ) {
152 - case 0:
153 - $public_key = \get_option( 'activitypub_blog_user_public_key' );
154 - $private_key = \get_option( 'activitypub_blog_user_private_key' );
155 - break;
156 - case -1:
157 - $public_key = \get_option( 'activitypub_application_user_public_key' );
158 - $private_key = \get_option( 'activitypub_application_user_private_key' );
159 - break;
160 - default:
161 - $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
162 - $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
163 - break;
164 - }
231 + public static function get_keypair_for( $user_id ) {
232 + \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_keypair' );
165 233
166 - if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) {
167 - return array(
168 - 'private_key' => $private_key,
169 - 'public_key' => $public_key,
170 - );
171 - }
234 + return Actors::get_keypair( $user_id );
235 + }
172 236
173 - return false;
237 + /**
238 + * Get public key from key_id.
239 + *
240 + * @deprecated 7.4.0 Use {@see Remote_Actors::get_public_key()}.
241 + *
242 + * @param string $key_id The URL to the public key.
243 + *
244 + * @return resource|\WP_Error The public key resource or WP_Error.
245 + */
246 + public static function get_remote_key( $key_id ) {
247 + \_deprecated_function( __METHOD__, '7.4.0', 'Activitypub\Collection\Remote_Actors::get_public_key()' );
248 +
249 + return Remote_Actors::get_public_key( $key_id );
174 250 }
175 251
176 252 /**
177 - * Generates the Signature for a HTTP Request
253 + * Generates the Signature for an HTTP Request.
178 254 *
255 + * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
256 + *
179 257 * @param int $user_id The WordPress User ID.
180 258 * @param string $http_method The HTTP method.
181 259 * @param string $url The URL to send the request to.
182 260 * @param string $date The date the request is sent.
183 - * @param string $digest The digest of the request body.
261 + * @param string $digest Optional. The digest of the request body. Default null.
184 262 *
185 263 * @return string The signature.
186 264 */
187 265 public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
188 - $user = Users::get_by_id( $user_id );
189 - $key = self::get_private_key_for( $user->get__id() );
266 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request()' );
190 267
268 + $user = Actors::get_by_id( $user_id );
269 + $key = Actors::get_private_key( $user_id );
270 +
191 271 $url_parts = \wp_parse_url( $url );
192 272
193 273 $host = $url_parts['host'];
194 274 $path = '/';
195 275
196 - // add path
276 + // Add path.
197 277 if ( ! empty( $url_parts['path'] ) ) {
198 278 $path = $url_parts['path'];
199 279 }
200 280
201 - // add query
281 + // Add query.
202 282 if ( ! empty( $url_parts['query'] ) ) {
203 283 $path .= '?' . $url_parts['query'];
204 284 }
205 285
@@ -212,11 +292,11 @@
212 292 }
213 293
214 294 $signature = null;
215 295 \openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
216 - $signature = \base64_encode( $signature ); // phpcs:ignore
296 + $signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
217 297
218 - $key_id = $user->get_url() . '#main-key';
298 + $key_id = $user->get_id() . '#main-key';
219 299
220 300 if ( ! empty( $digest ) ) {
221 301 return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
222 302 } else {
@@ -224,166 +304,53 @@
224 304 }
225 305 }
226 306
227 307 /**
228 - * Verifies the http signatures
308 + * Gets the signature algorithm from the signature header.
229 309 *
230 - * @param WP_REST_Request|array $request The request object or $_SERVER array.
310 + * @deprecated 7.0.0 Use {@see Signature::verify()}.
231 311 *
232 - * @return mixed A boolean or WP_Error.
233 - */
234 - public static function verify_http_signature( $request ) {
235 - if ( is_object( $request ) ) { // REST Request object
236 - // check if route starts with "index.php"
237 - if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
238 - $route = $request->get_route();
239 - } else {
240 - $route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
241 - }
242 -
243 - // fix route for subdirectory installs
244 - $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
245 -
246 - if ( \is_string( $path ) ) {
247 - $path = trim( $path, '/' );
248 - }
249 -
250 - if ( $path ) {
251 - $route = '/' . $path . $route;
252 - }
253 -
254 - $headers = $request->get_headers();
255 - $headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . $route;
256 - } else {
257 - $request = self::format_server_request( $request );
258 - $headers = $request['headers']; // $_SERVER array
259 - $headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
260 - }
261 -
262 - if ( ! isset( $headers['signature'] ) ) {
263 - return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) );
264 - }
265 -
266 - if ( array_key_exists( 'signature', $headers ) ) {
267 - $signature_block = self::parse_signature_header( $headers['signature'][0] );
268 - } elseif ( array_key_exists( 'authorization', $headers ) ) {
269 - $signature_block = self::parse_signature_header( $headers['authorization'][0] );
270 - }
271 -
272 - if ( ! isset( $signature_block ) || ! $signature_block ) {
273 - return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
274 - }
275 -
276 - $signed_headers = $signature_block['headers'];
277 - if ( ! $signed_headers ) {
278 - $signed_headers = array( 'date' );
279 - }
280 -
281 - $signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers );
282 - if ( ! $signed_data ) {
283 - return new WP_Error( 'activitypub_signature', __( 'Signed request date outside acceptable time window', 'activitypub' ), array( 'status' => 401 ) );
284 - }
285 -
286 - $algorithm = self::get_signature_algorithm( $signature_block );
287 - if ( ! $algorithm ) {
288 - return new WP_Error( 'activitypub_signature', __( 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', 'activitypub' ), array( 'status' => 401 ) );
289 - }
290 -
291 - if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) {
292 - if ( is_array( $headers['digest'] ) ) {
293 - $headers['digest'] = $headers['digest'][0];
294 - }
295 - $hashalg = 'sha256';
296 - $digest = explode( '=', $headers['digest'], 2 );
297 - if ( 'SHA-256' === $digest[0] ) {
298 - $hashalg = 'sha256';
299 - }
300 - if ( 'SHA-512' === $digest[0] ) {
301 - $hashalg = 'sha512';
302 - }
303 -
304 - if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore
305 - return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 401 ) );
306 - }
307 - }
308 -
309 - $public_key = self::get_remote_key( $signature_block['keyId'] );
310 -
311 - if ( \is_wp_error( $public_key ) ) {
312 - return $public_key;
313 - }
314 -
315 - $verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
316 -
317 - if ( ! $verified ) {
318 - return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) );
319 - }
320 - return $verified;
321 - }
322 -
323 - /**
324 - * Get public key from key_id
312 + * @param array $signature_block The signature block.
325 313 *
326 - * @param string $key_id The URL to the public key.
327 - *
328 - * @return WP_Error|string The public key or WP_Error.
314 + * @return string|bool The signature algorithm or false if not found.
329 315 */
330 - public static function get_remote_key( $key_id ) { // phpcs:ignore
331 - $actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) ); // phpcs:ignore
332 - if ( \is_wp_error( $actor ) ) {
333 - return new WP_Error(
334 - 'activitypub_no_remote_profile_found',
335 - __( 'No Profile found or Profile not accessible', 'activitypub' ),
336 - array( 'status' => 401 )
337 - );
338 - }
339 - if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
340 - return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore
341 - }
342 - return new WP_Error(
343 - 'activitypub_no_remote_key_found',
344 - __( 'No Public-Key found', 'activitypub' ),
345 - array( 'status' => 401 )
346 - );
347 - }
316 + public static function get_signature_algorithm( $signature_block ) { // phpcs:ignore
317 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
348 318
349 - /**
350 - * Gets the signature algorithm from the signature header
351 - *
352 - * @param array $signature_block
353 - *
354 - * @return string The signature algorithm.
355 - */
356 - public static function get_signature_algorithm( $signature_block ) {
357 - if ( $signature_block['algorithm'] ) {
319 + if ( ! empty( $signature_block['algorithm'] ) ) {
358 320 switch ( $signature_block['algorithm'] ) {
359 321 case 'rsa-sha-512':
360 - return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
322 + return 'sha512'; // hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12.
361 323 default:
362 324 return 'sha256';
363 325 }
364 326 }
327 +
365 328 return false;
366 329 }
367 330
368 331 /**
369 - * Parses the Signature header
332 + * Parses the Signature header.
370 333 *
334 + * @deprecated 7.0.0 Use {@see Signature::verify()}.
335 + *
371 336 * @param string $signature The signature header.
372 337 *
373 - * @return array signature parts
338 + * @return array Signature parts.
374 339 */
375 - public static function parse_signature_header( $signature ) {
376 - $parsed_header = array();
377 - $matches = array();
340 + public static function parse_signature_header( $signature ) { // phpcs:ignore
341 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
378 342
343 + $parsed_header = array();
344 + $matches = array();
345 +
379 346 if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
380 347 $parsed_header['keyId'] = trim( $matches[1] );
381 348 }
382 - if ( \preg_match( '/created=([0-9]*)/ism', $signature, $matches ) ) {
349 + if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
383 350 $parsed_header['(created)'] = trim( $matches[1] );
384 351 }
385 - if ( \preg_match( '/expires=([0-9]*)/ism', $signature, $matches ) ) {
352 + if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
386 353 $parsed_header['(expires)'] = trim( $matches[1] );
387 354 }
388 355 if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
389 356 $parsed_header['algorithm'] = trim( $matches[1] );
@@ -391,12 +358,12 @@
391 358 if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
392 359 $parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
393 360 }
394 361 if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
395 - $parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore
362 + $parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
396 363 }
397 364
398 - if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
365 + if ( empty( $parsed_header['headers'] ) ) {
399 366 $parsed_header['headers'] = array( 'date' );
400 367 }
401 368
402 369 return $parsed_header;
@@ -402,18 +369,23 @@
402 369 return $parsed_header;
403 370 }
404 371
405 372 /**
406 - * Gets the header data from the included pseudo headers
373 + * Gets the header data from the included pseudo headers.
407 374 *
375 + * @deprecated 7.0.0 Use {@see Signature::verify()}.
376 + *
408 377 * @param array $signed_headers The signed headers.
409 - * @param array $signature_block (pseudo-headers)
410 - * @param array $headers (http headers)
378 + * @param array $signature_block The signature block.
379 + * @param array $headers The HTTP headers.
411 380 *
412 381 * @return string signed headers for comparison
413 382 */
414 - public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
383 + public static function get_signed_data( $signed_headers, $signature_block, $headers ) { // phpcs:ignore
384 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
385 +
415 386 $signed_data = '';
387 +
416 388 // This also verifies time-based values by returning false if any of these are out of range.
417 389 foreach ( $signed_headers as $header ) {
418 390 if ( 'host' === $header ) {
419 391 if ( isset( $headers['x_original_host'] ) ) {
@@ -430,71 +402,160 @@
430 402 continue;
431 403 }
432 404 if ( '(created)' === $header ) {
433 405 if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
434 - // created in future
406 + // Created in the future.
435 407 return false;
436 408 }
409 +
410 + if ( ! array_key_exists( '(created)', $headers ) ) {
411 + $signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
412 + continue;
413 + }
437 414 }
438 415 if ( '(expires)' === $header ) {
439 416 if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
440 - // expired in past
417 + // Expired in the past.
441 418 return false;
442 419 }
420 +
421 + if ( ! array_key_exists( '(expires)', $headers ) ) {
422 + $signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
423 + continue;
424 + }
443 425 }
444 426 if ( 'date' === $header ) {
445 - // allow a bit of leeway for misconfigured clocks.
446 - $d = new DateTime( $headers[ $header ][0] );
447 - $d->setTimeZone( new DateTimeZone( 'UTC' ) );
427 + if ( empty( $headers[ $header ][0] ) ) {
428 + continue;
429 + }
430 +
431 + // Allow a bit of leeway for misconfigured clocks.
432 + $d = new \DateTime( $headers[ $header ][0] );
433 + $d->setTimeZone( new \DateTimeZone( 'UTC' ) );
448 434 $c = $d->format( 'U' );
449 435
450 - $dplus = time() + ( 3 * HOUR_IN_SECONDS );
451 - $dminus = time() - ( 3 * HOUR_IN_SECONDS );
436 + $d_plus = time() + ( 3 * HOUR_IN_SECONDS );
437 + $d_minus = time() - ( 3 * HOUR_IN_SECONDS );
452 438
453 - if ( $c > $dplus || $c < $dminus ) {
454 - // time out of range
439 + if ( $c > $d_plus || $c < $d_minus ) {
440 + // Time out of range.
455 441 return false;
456 442 }
457 443 }
458 - $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
444 +
445 + if ( ! empty( $headers[ $header ][0] ) ) {
446 + $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
447 + }
459 448 }
449 +
460 450 return \rtrim( $signed_data, "\n" );
461 451 }
462 452
463 453 /**
464 - * Generates the digest for a HTTP Request
454 + * Generates the digest for an HTTP Request.
465 455 *
456 + * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
457 + *
466 458 * @param string $body The body of the request.
467 459 *
468 460 * @return string The digest.
469 461 */
470 462 public static function generate_digest( $body ) {
471 - $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore
463 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request' );
464 +
465 + $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
472 466 return "SHA-256=$digest";
473 467 }
474 468
475 469 /**
476 - * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
477 - * for use with verify_http_signature()
470 + * Compute the collection digest for a specific instance.
478 471 *
479 - * @param array $_SERVER The $_SERVER array.
472 + * Implements FEP-8fcf: Followers collection synchronization.
473 + * The digest is created by XORing together the individual SHA256 digests
474 + * of each follower's ID.
480 475 *
481 - * @return array $request The formatted request array.
476 + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
477 + *
478 + * @param array $collection The user ID whose followers to compute.
479 + *
480 + * @return string|false The hex-encoded digest, or false if no followers.
482 481 */
483 - public static function format_server_request( $server ) {
484 - $request = array();
485 - foreach ( $server as $param_key => $param_val ) {
486 - $req_param = strtolower( $param_key );
487 - if ( 'REQUEST_URI' === $req_param ) {
488 - $request['headers']['route'][] = $param_val;
489 - } else {
490 - $header_key = str_replace(
491 - 'http_',
492 - '',
493 - $req_param
494 - );
495 - $request['headers'][ $header_key ][] = \wp_unslash( $param_val );
482 + public static function get_collection_digest( $collection ) {
483 + if ( empty( $collection ) || ! is_array( $collection ) ) {
484 + return false;
485 + }
486 +
487 + // Initialize with zeros (64 hex chars = 32 bytes = 256 bits).
488 + $digest = str_repeat( '0', 64 );
489 +
490 + foreach ( $collection as $item ) {
491 + // Compute SHA256 hash of the follower ID.
492 + $hash = hash( 'sha256', $item );
493 +
494 + // XOR the hash with the running digest.
495 + $digest = self::xor_hex_strings( $digest, $hash );
496 + }
497 +
498 + return $digest;
499 + }
500 +
501 + /**
502 + * XOR two hexadecimal strings.
503 + *
504 + * Used for FEP-8fcf digest computation.
505 + *
506 + * @param string $hex1 First hex string.
507 + * @param string $hex2 Second hex string.
508 + *
509 + * @return string The XORed result as a hex string.
510 + */
511 + public static function xor_hex_strings( $hex1, $hex2 ) {
512 + $result = '';
513 +
514 + // Ensure both strings are the same length (should be 64 chars for SHA256).
515 + $length = \max( \strlen( $hex1 ), \strlen( $hex2 ) );
516 + $hex1 = \str_pad( $hex1, $length, '0', STR_PAD_LEFT );
517 + $hex2 = \str_pad( $hex2, $length, '0', STR_PAD_LEFT );
518 +
519 + // XOR each pair of hex digits.
520 + for ( $i = 0; $i < $length; $i += 2 ) {
521 + $byte1 = \hexdec( \substr( $hex1, $i, 2 ) );
522 + $byte2 = \hexdec( \substr( $hex2, $i, 2 ) );
523 + $result .= \str_pad( \dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT );
524 + }
525 +
526 + return $result;
527 + }
528 +
529 + /**
530 + * Parse a Collection-Synchronization header (FEP-8fcf).
531 + *
532 + * Parses the signature-style format used by the Collection-Synchronization header.
533 + *
534 + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
535 + *
536 + * @param string $header The header value.
537 + *
538 + * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure.
539 + */
540 + public static function parse_collection_sync_header( $header ) {
541 + if ( empty( $header ) ) {
542 + return false;
543 + }
544 +
545 + // Parse the signature-style format: key="value", key="value".
546 + $params = array();
547 +
548 + if ( \preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) {
549 + foreach ( $matches as $match ) {
550 + $params[ $match[1] ] = $match[2];
496 551 }
497 552 }
498 - return $request;
553 +
554 + // Validate required fields for FEP-8fcf.
555 + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) {
556 + return false;
557 + }
558 +
559 + return $params;
499 560 }
500 561 }