PluginProbe
ActivityPub / 7.8.2
ActivityPub v7.8.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 -269 1.0.8 → 7.8.2 View file →
@@ -1,14 +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 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;
8 14
9 15 /**
10 - * ActivityPub Signature Class
16 + * ActivityPub Signature Class.
11 17 *
12 18 * @author Matthias Pfefferle
13 19 * @author Django Doucet
14 20 */
@@ -14,191 +20,266 @@
14 20 */
15 21 class Signature {
16 22
17 23 /**
18 - * 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.
19 33 *
20 - * @param int $user_id The WordPress User ID.
21 - * @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.
22 36 *
23 - * @return mixed The public key.
37 + * @return array Request arguments with signature headers.
24 38 */
25 - public static function get_public_key_for( $user_id, $force = false ) {
26 - if ( $force ) {
27 - 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;
28 43 }
29 44
30 - $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 + }
31 50
32 - return $key_pair['public_key'];
51 + return $signature->sign( $args, $url );
33 52 }
34 53
35 54 /**
36 - * Return the private key for a given user.
55 + * Verifies the http signatures
37 56 *
38 - * @param int $user_id The WordPress User ID.
39 - * @param bool $force Force the generation of a new key pair.
57 + * @param \WP_REST_Request|array $request The request object or $_SERVER array.
40 58 *
41 - * @return mixed The private key.
59 + * @return bool|\WP_Error A boolean or WP_Error.
42 60 */
43 - public static function get_private_key_for( $user_id, $force = false ) {
44 - if ( $force ) {
45 - 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];
46 69 }
47 70
48 - $key_pair = self::get_keypair_for( $user_id );
71 + $signature = isset( $headers['signature_input'] ) ? new Http_Message_Signature() : new Http_Signature_Draft();
49 72
50 - return $key_pair['private_key'];
73 + return $signature->verify( $headers, $body ?? null );
51 74 }
52 75
53 76 /**
54 - * 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.
55 78 *
56 - * @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.
57 82 *
58 - * @return array The key pair.
83 + * @return array The HTTP response.
59 84 */
60 - public static function get_keypair_for( $user_id ) {
61 - $option_key = self::get_signature_options_key_for( $user_id );
62 - $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 + }
63 90
64 - if ( ! $key_pair ) {
65 - $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 );
66 100 }
67 101
68 - return $key_pair;
102 + return $response;
69 103 }
70 104
71 105 /**
72 - * Generates the pair keys
106 + * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
107 + * for use with verify_http_signature().
73 108 *
74 - * @param int $user_id The WordPress User ID.
109 + * @param array $server The $_SERVER array.
75 110 *
76 - * @return array The key pair.
111 + * @return array $request The formatted request array.
77 112 */
78 - protected static function generate_key_pair_for( $user_id ) {
79 - $option_key = self::get_signature_options_key_for( $user_id );
80 - $key_pair = self::check_legacy_key_pair_for( $user_id );
113 + public static function format_server_request( $server ) {
114 + $headers = array();
81 115
82 - if ( $key_pair ) {
83 - \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 );
84 119
85 - return $key_pair;
86 120 }
87 121
88 - $config = array(
89 - 'digest_alg' => 'sha512',
90 - 'private_key_bits' => 2048,
91 - 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
92 - );
122 + return $headers;
123 + }
93 124
94 - $key = \openssl_pkey_new( $config );
95 - $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 + }
96 139
97 - \openssl_pkey_export( $key, $priv_key );
140 + // Fix route for subdirectory installations.
141 + $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
98 142
99 - $detail = \openssl_pkey_get_details( $key );
143 + if ( \is_string( $path ) ) {
144 + $path = trim( $path, '/' );
145 + }
100 146
101 - // check if keys are valid
102 - if (
103 - empty( $priv_key ) || ! is_string( $priv_key ) ||
104 - ! isset( $detail['key'] ) || ! is_string( $detail['key'] )
105 - ) {
106 - return array(
107 - 'private_key' => null,
108 - 'public_key' => null,
109 - );
147 + if ( $path ) {
148 + $route = '/' . $path . $route;
110 149 }
111 150
112 - $key_pair = array(
113 - 'private_key' => $priv_key,
114 - 'public_key' => $detail['key'],
115 - );
151 + return $route;
152 + }
116 153
117 - // persist keys
118 - \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() );
119 164
120 - 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;
121 175 }
122 176
123 177 /**
124 - * Return the option key for a given user.
178 + * Set RFC-9421 signature unsupported for a given host.
125 179 *
126 - * @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.
127 192 *
128 - * @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.
129 199 */
130 - protected static function get_signature_options_key_for( $user_id ) {
131 - $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' );
132 202
133 - if ( $user_id > 0 ) {
134 - $user = \get_userdata( $user_id );
135 - // sanatize username because it could include spaces and special chars
136 - $id = sanitize_title( $user->user_login );
137 - }
203 + return Actors::get_public_key( $user_id, $force );
204 + }
138 205
139 - 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 );
140 220 }
141 221
142 222 /**
143 - * Check if there is a legacy key pair
223 + * Return the key pair for a given user.
144 224 *
225 + * @deprecated 7.0.0 Use {@see Actors::get_keypair()}.
226 + *
145 227 * @param int $user_id The WordPress User ID.
146 228 *
147 - * @return array|bool The key pair or false.
229 + * @return array The key pair.
148 230 */
149 - protected static function check_legacy_key_pair_for( $user_id ) {
150 - switch ( $user_id ) {
151 - case 0:
152 - $public_key = \get_option( 'activitypub_blog_user_public_key' );
153 - $private_key = \get_option( 'activitypub_blog_user_private_key' );
154 - break;
155 - case -1:
156 - $public_key = \get_option( 'activitypub_application_user_public_key' );
157 - $private_key = \get_option( 'activitypub_application_user_private_key' );
158 - break;
159 - default:
160 - $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true );
161 - $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true );
162 - break;
163 - }
231 + public static function get_keypair_for( $user_id ) {
232 + \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_keypair' );
164 233
165 - if ( ! empty( $public_key ) && is_string( $public_key ) && ! empty( $private_key ) && is_string( $private_key ) ) {
166 - return array(
167 - 'private_key' => $private_key,
168 - 'public_key' => $public_key,
169 - );
170 - }
234 + return Actors::get_keypair( $user_id );
235 + }
171 236
172 - 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 );
173 250 }
174 251
175 252 /**
176 - * Generates the Signature for a HTTP Request
253 + * Generates the Signature for an HTTP Request.
177 254 *
255 + * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
256 + *
178 257 * @param int $user_id The WordPress User ID.
179 258 * @param string $http_method The HTTP method.
180 259 * @param string $url The URL to send the request to.
181 260 * @param string $date The date the request is sent.
182 - * @param string $digest The digest of the request body.
261 + * @param string $digest Optional. The digest of the request body. Default null.
183 262 *
184 263 * @return string The signature.
185 264 */
186 265 public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
187 - $user = Users::get_by_id( $user_id );
188 - $key = self::get_private_key_for( $user->get__id() );
266 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request()' );
189 267
268 + $user = Actors::get_by_id( $user_id );
269 + $key = Actors::get_private_key( $user_id );
270 +
190 271 $url_parts = \wp_parse_url( $url );
191 272
192 273 $host = $url_parts['host'];
193 274 $path = '/';
194 275
195 - // add path
276 + // Add path.
196 277 if ( ! empty( $url_parts['path'] ) ) {
197 278 $path = $url_parts['path'];
198 279 }
199 280
200 - // add query
281 + // Add query.
201 282 if ( ! empty( $url_parts['query'] ) ) {
202 283 $path .= '?' . $url_parts['query'];
203 284 }
204 285
@@ -211,11 +292,11 @@
211 292 }
212 293
213 294 $signature = null;
214 295 \openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
215 - $signature = \base64_encode( $signature ); // phpcs:ignore
296 + $signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
216 297
217 - $key_id = $user->get_url() . '#main-key';
298 + $key_id = $user->get_id() . '#main-key';
218 299
219 300 if ( ! empty( $digest ) ) {
220 301 return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
221 302 } else {
@@ -223,157 +304,53 @@
223 304 }
224 305 }
225 306
226 307 /**
227 - * Verifies the http signatures
308 + * Gets the signature algorithm from the signature header.
228 309 *
229 - * @param WP_REQUEST|array $request The request object or $_SERVER array.
310 + * @deprecated 7.0.0 Use {@see Signature::verify()}.
230 311 *
231 - * @return mixed A boolean or WP_Error.
232 - */
233 - public static function verify_http_signature( $request ) {
234 - if ( is_object( $request ) ) { // REST Request object
235 - // check if route starts with "index.php"
236 - if ( str_starts_with( $request->get_route(), '/index.php' ) || ! rest_get_url_prefix() ) {
237 - $route = $request->get_route();
238 - } else {
239 - $route = '/' . rest_get_url_prefix() . '/' . ltrim( $request->get_route(), '/' );
240 - }
241 -
242 - // fix route for subdirectory installs
243 - $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
244 -
245 - if ( \is_string( $path ) ) {
246 - $path = trim( $path, '/' );
247 - }
248 -
249 - if ( $path ) {
250 - $route = '/' . $path . $route;
251 - }
252 -
253 - $headers = $request->get_headers();
254 - $headers['(request-target)'][0] = strtolower( $request->get_method() ) . ' ' . $route;
255 - } else {
256 - $request = self::format_server_request( $request );
257 - $headers = $request['headers']; // $_SERVER array
258 - $headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
259 - }
260 -
261 - if ( ! isset( $headers['signature'] ) ) {
262 - return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) );
263 - }
264 -
265 - if ( array_key_exists( 'signature', $headers ) ) {
266 - $signature_block = self::parse_signature_header( $headers['signature'][0] );
267 - } elseif ( array_key_exists( 'authorization', $headers ) ) {
268 - $signature_block = self::parse_signature_header( $headers['authorization'][0] );
269 - }
270 -
271 - if ( ! isset( $signature_block ) || ! $signature_block ) {
272 - return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) );
273 - }
274 -
275 - $signed_headers = $signature_block['headers'];
276 - if ( ! $signed_headers ) {
277 - $signed_headers = array( 'date' );
278 - }
279 -
280 - $signed_data = self::get_signed_data( $signed_headers, $signature_block, $headers );
281 - if ( ! $signed_data ) {
282 - return new WP_Error( 'activitypub_signature', __( 'Signed request date outside acceptable time window', 'activitypub' ), array( 'status' => 401 ) );
283 - }
284 -
285 - $algorithm = self::get_signature_algorithm( $signature_block );
286 - if ( ! $algorithm ) {
287 - return new WP_Error( 'activitypub_signature', __( 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)', 'activitypub' ), array( 'status' => 401 ) );
288 - }
289 -
290 - if ( \in_array( 'digest', $signed_headers, true ) && isset( $body ) ) {
291 - if ( is_array( $headers['digest'] ) ) {
292 - $headers['digest'] = $headers['digest'][0];
293 - }
294 - $digest = explode( '=', $headers['digest'], 2 );
295 - if ( 'SHA-256' === $digest[0] ) {
296 - $hashalg = 'sha256';
297 - }
298 - if ( 'SHA-512' === $digest[0] ) {
299 - $hashalg = 'sha512';
300 - }
301 -
302 - if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore
303 - return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 401 ) );
304 - }
305 - }
306 -
307 - $public_key = self::get_remote_key( $signature_block['keyId'] );
308 -
309 - if ( \is_wp_error( $public_key ) ) {
310 - return $public_key;
311 - }
312 -
313 - $verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
314 -
315 - if ( ! $verified ) {
316 - return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) );
317 - }
318 - return $verified;
319 - }
320 -
321 - /**
322 - * Get public key from key_id
312 + * @param array $signature_block The signature block.
323 313 *
324 - * @param string $key_id The URL to the public key.
325 - *
326 - * @return WP_Error|string The public key.
314 + * @return string|bool The signature algorithm or false if not found.
327 315 */
328 - public static function get_remote_key( $key_id ) { // phpcs:ignore
329 - $actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) ); // phpcs:ignore
330 - if ( \is_wp_error( $actor ) ) {
331 - return $actor;
332 - }
333 - if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
334 - return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore
335 - }
336 - return new WP_Error( 'activitypub_no_remote_key_found', __( 'No Public-Key found', 'activitypub' ), array( 'status' => 401 ) );
337 - }
316 + public static function get_signature_algorithm( $signature_block ) { // phpcs:ignore
317 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
338 318
339 - /**
340 - * Gets the signature algorithm from the signature header
341 - *
342 - * @param array $signature_block
343 - *
344 - * @return string The signature algorithm.
345 - */
346 - public static function get_signature_algorithm( $signature_block ) {
347 - if ( $signature_block['algorithm'] ) {
319 + if ( ! empty( $signature_block['algorithm'] ) ) {
348 320 switch ( $signature_block['algorithm'] ) {
349 321 case 'rsa-sha-512':
350 - 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.
351 323 default:
352 324 return 'sha256';
353 325 }
354 326 }
327 +
355 328 return false;
356 329 }
357 330
358 331 /**
359 - * Parses the Signature header
332 + * Parses the Signature header.
360 333 *
334 + * @deprecated 7.0.0 Use {@see Signature::verify()}.
335 + *
361 336 * @param string $signature The signature header.
362 337 *
363 - * @return array signature parts
338 + * @return array Signature parts.
364 339 */
365 - public static function parse_signature_header( $signature ) {
366 - $parsed_header = array();
367 - $matches = array();
340 + public static function parse_signature_header( $signature ) { // phpcs:ignore
341 + \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
368 342
343 + $parsed_header = array();
344 + $matches = array();
345 +
369 346 if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
370 347 $parsed_header['keyId'] = trim( $matches[1] );
371 348 }
372 - if ( \preg_match( '/created=([0-9]*)/ism', $signature, $matches ) ) {
349 + if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
373 350 $parsed_header['(created)'] = trim( $matches[1] );
374 351 }
375 - if ( \preg_match( '/expires=([0-9]*)/ism', $signature, $matches ) ) {
352 + if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
376 353 $parsed_header['(expires)'] = trim( $matches[1] );
377 354 }
378 355 if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
379 356 $parsed_header['algorithm'] = trim( $matches[1] );
@@ -381,12 +358,12 @@
381 358 if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
382 359 $parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
383 360 }
384 361 if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
385 - $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
386 363 }
387 364
388 - if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
365 + if ( empty( $parsed_header['headers'] ) ) {
389 366 $parsed_header['headers'] = array( 'date' );
390 367 }
391 368
392 369 return $parsed_header;
@@ -392,18 +369,23 @@
392 369 return $parsed_header;
393 370 }
394 371
395 372 /**
396 - * Gets the header data from the included pseudo headers
373 + * Gets the header data from the included pseudo headers.
397 374 *
375 + * @deprecated 7.0.0 Use {@see Signature::verify()}.
376 + *
398 377 * @param array $signed_headers The signed headers.
399 - * @param array $signature_block (pseudo-headers)
400 - * @param array $headers (http headers)
378 + * @param array $signature_block The signature block.
379 + * @param array $headers The HTTP headers.
401 380 *
402 381 * @return string signed headers for comparison
403 382 */
404 - 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 +
405 386 $signed_data = '';
387 +
406 388 // This also verifies time-based values by returning false if any of these are out of range.
407 389 foreach ( $signed_headers as $header ) {
408 390 if ( 'host' === $header ) {
409 391 if ( isset( $headers['x_original_host'] ) ) {
@@ -420,71 +402,160 @@
420 402 continue;
421 403 }
422 404 if ( '(created)' === $header ) {
423 405 if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
424 - // created in future
406 + // Created in the future.
425 407 return false;
426 408 }
409 +
410 + if ( ! array_key_exists( '(created)', $headers ) ) {
411 + $signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
412 + continue;
413 + }
427 414 }
428 415 if ( '(expires)' === $header ) {
429 416 if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
430 - // expired in past
417 + // Expired in the past.
431 418 return false;
432 419 }
420 +
421 + if ( ! array_key_exists( '(expires)', $headers ) ) {
422 + $signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
423 + continue;
424 + }
433 425 }
434 426 if ( 'date' === $header ) {
435 - // allow a bit of leeway for misconfigured clocks.
436 - $d = new DateTime( $headers[ $header ][0] );
437 - $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' ) );
438 434 $c = $d->format( 'U' );
439 435
440 - $dplus = time() + ( 3 * HOUR_IN_SECONDS );
441 - $dminus = time() - ( 3 * HOUR_IN_SECONDS );
436 + $d_plus = time() + ( 3 * HOUR_IN_SECONDS );
437 + $d_minus = time() - ( 3 * HOUR_IN_SECONDS );
442 438
443 - if ( $c > $dplus || $c < $dminus ) {
444 - // time out of range
439 + if ( $c > $d_plus || $c < $d_minus ) {
440 + // Time out of range.
445 441 return false;
446 442 }
447 443 }
448 - $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
444 +
445 + if ( ! empty( $headers[ $header ][0] ) ) {
446 + $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
447 + }
449 448 }
449 +
450 450 return \rtrim( $signed_data, "\n" );
451 451 }
452 452
453 453 /**
454 - * Generates the digest for a HTTP Request
454 + * Generates the digest for an HTTP Request.
455 455 *
456 + * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
457 + *
456 458 * @param string $body The body of the request.
457 459 *
458 460 * @return string The digest.
459 461 */
460 462 public static function generate_digest( $body ) {
461 - $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
462 466 return "SHA-256=$digest";
463 467 }
464 468
465 469 /**
466 - * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
467 - * for use with verify_http_signature()
470 + * Compute the collection digest for a specific instance.
468 471 *
469 - * @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.
470 475 *
471 - * @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.
472 481 */
473 - public static function format_server_request( $server ) {
474 - $request = array();
475 - foreach ( $server as $param_key => $param_val ) {
476 - $req_param = strtolower( $param_key );
477 - if ( 'REQUEST_URI' === $req_param ) {
478 - $request['headers']['route'][] = $param_val;
479 - } else {
480 - $header_key = str_replace(
481 - 'http_',
482 - '',
483 - $req_param
484 - );
485 - $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];
486 551 }
487 552 }
488 - 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;
489 560 }
490 561 }