PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
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
activitypub / includes / class-signature.php

class-signature.php in ActivityPub 8.2.1, at includes/class-signature.php

568 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Signature class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
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;
14
15 /**
16 * ActivityPub Signature Class.
17 *
18 * @author Matthias Pfefferle
19 * @author Django Doucet
20 */
21 class Signature {
22
23 /**
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.
33 *
34 * @param array $args An array of HTTP request arguments.
35 * @param string $url The request URL.
36 *
37 * @return array Request arguments with signature headers.
38 */
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;
43 }
44
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 }
50
51 return $signature->sign( $args, $url );
52 }
53
54 /**
55 * Verifies the http signatures
56 *
57 * @param \WP_REST_Request|array $request The request object or $_SERVER array.
58 *
59 * @return bool|\WP_Error A boolean or WP_Error.
60 */
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];
69 }
70
71 $signature = isset( $headers['signature_input'] ) ? new Http_Message_Signature() : new Http_Signature_Draft();
72
73 return $signature->verify( $headers, $body ?? null );
74 }
75
76 /**
77 * If a request with RFC-9421 signature fails, we try again with the Draft Cavage signature.
78 *
79 * @param array $response HTTP response.
80 * @param array $args HTTP request arguments.
81 * @param string $url The request URL.
82 *
83 * @return array The HTTP response.
84 */
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 }
90
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_safe_remote_request( $url, $args );
100 }
101
102 return $response;
103 }
104
105 /**
106 * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
107 * for use with verify_http_signature().
108 *
109 * @param array $server The $_SERVER array.
110 *
111 * @return array $request The formatted request array.
112 */
113 public static function format_server_request( $server ) {
114 $headers = array();
115
116 foreach ( $server as $key => $value ) {
117 $key = \str_replace( 'http_', '', \strtolower( $key ) );
118 $headers[ $key ][] = \wp_unslash( $value );
119
120 }
121
122 return $headers;
123 }
124
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 }
139
140 // Fix route for subdirectory installations.
141 $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
142
143 if ( \is_string( $path ) ) {
144 $path = trim( $path, '/' );
145 }
146
147 if ( $path ) {
148 $route = '/' . $path . $route;
149 }
150
151 return $route;
152 }
153
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() );
164
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;
175 }
176
177 /**
178 * Set RFC-9421 signature unsupported for a given host.
179 *
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.
192 *
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.
199 */
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' );
202
203 return Actors::get_public_key( $user_id, $force );
204 }
205
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 );
220 }
221
222 /**
223 * Return the key pair for a given user.
224 *
225 * @deprecated 7.0.0 Use {@see Actors::get_keypair()}.
226 *
227 * @param int $user_id The WordPress User ID.
228 *
229 * @return array The key pair.
230 */
231 public static function get_keypair_for( $user_id ) {
232 \_deprecated_function( __METHOD__, '7.0.0', 'Activitypub\Collection\Actors::get_keypair' );
233
234 return Actors::get_keypair( $user_id );
235 }
236
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 );
250 }
251
252 /**
253 * Generates the Signature for an HTTP Request.
254 *
255 * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
256 *
257 * @param int $user_id The WordPress User ID.
258 * @param string $http_method The HTTP method.
259 * @param string $url The URL to send the request to.
260 * @param string $date The date the request is sent.
261 * @param string $digest Optional. The digest of the request body. Default null.
262 *
263 * @return string The signature.
264 */
265 public static function generate_signature( $user_id, $http_method, $url, $date, $digest = null ) {
266 \_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request()' );
267
268 $user = Actors::get_by_id( $user_id );
269 $key = Actors::get_private_key( $user_id );
270
271 $url_parts = \wp_parse_url( $url );
272
273 $host = $url_parts['host'];
274 $path = '/';
275
276 // Add path.
277 if ( ! empty( $url_parts['path'] ) ) {
278 $path = $url_parts['path'];
279 }
280
281 // Add query.
282 if ( ! empty( $url_parts['query'] ) ) {
283 $path .= '?' . $url_parts['query'];
284 }
285
286 $http_method = \strtolower( $http_method );
287
288 if ( ! empty( $digest ) ) {
289 $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: $digest";
290 } else {
291 $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date";
292 }
293
294 $signature = null;
295 \openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
296 $signature = \base64_encode( $signature ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
297
298 $key_id = $user->get_id() . '#main-key';
299
300 if ( ! empty( $digest ) ) {
301 return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
302 } else {
303 return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date",signature="%s"', $key_id, $signature );
304 }
305 }
306
307 /**
308 * Gets the signature algorithm from the signature header.
309 *
310 * @deprecated 7.0.0 Use {@see Signature::verify()}.
311 *
312 * @param array $signature_block The signature block.
313 *
314 * @return string|bool The signature algorithm or false if not found.
315 */
316 public static function get_signature_algorithm( $signature_block ) { // phpcs:ignore
317 \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
318
319 if ( ! empty( $signature_block['algorithm'] ) ) {
320 switch ( $signature_block['algorithm'] ) {
321 case 'rsa-sha-512':
322 return 'sha512'; // hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12.
323 default:
324 return 'sha256';
325 }
326 }
327
328 return false;
329 }
330
331 /**
332 * Parses the Signature header.
333 *
334 * @deprecated 7.0.0 Use {@see Signature::verify()}.
335 *
336 * @param string $signature The signature header.
337 *
338 * @return array Signature parts.
339 */
340 public static function parse_signature_header( $signature ) { // phpcs:ignore
341 \_deprecated_function( __METHOD__, '7.0.0', self::class . '::verify' );
342
343 $parsed_header = array();
344 $matches = array();
345
346 if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
347 $parsed_header['keyId'] = trim( $matches[1] );
348 }
349 if ( \preg_match( '/created=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
350 $parsed_header['(created)'] = trim( $matches[1] );
351 }
352 if ( \preg_match( '/expires=["|\']*([0-9]*)["|\']*/ism', $signature, $matches ) ) {
353 $parsed_header['(expires)'] = trim( $matches[1] );
354 }
355 if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
356 $parsed_header['algorithm'] = trim( $matches[1] );
357 }
358 if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
359 $parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
360 }
361 if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
362 $parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
363 }
364
365 if ( empty( $parsed_header['headers'] ) ) {
366 $parsed_header['headers'] = array( 'date' );
367 }
368
369 return $parsed_header;
370 }
371
372 /**
373 * Gets the header data from the included pseudo headers.
374 *
375 * @deprecated 7.0.0 Use {@see Signature::verify()}.
376 *
377 * @param array $signed_headers The signed headers.
378 * @param array $signature_block The signature block.
379 * @param array $headers The HTTP headers.
380 *
381 * @return string signed headers for comparison
382 */
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
386 $signed_data = '';
387
388 // This also verifies time-based values by returning false if any of these are out of range.
389 foreach ( $signed_headers as $header ) {
390 if ( 'host' === $header ) {
391 if ( isset( $headers['x_original_host'] ) ) {
392 $signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
393 continue;
394 }
395 }
396 if ( '(request-target)' === $header ) {
397 $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
398 continue;
399 }
400 if ( str_contains( $header, '-' ) ) {
401 $signed_data .= $header . ': ' . $headers[ str_replace( '-', '_', $header ) ][0] . "\n";
402 continue;
403 }
404 if ( '(created)' === $header ) {
405 if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
406 // Created in the future.
407 return false;
408 }
409
410 if ( ! array_key_exists( '(created)', $headers ) ) {
411 $signed_data .= $header . ': ' . $signature_block['(created)'] . "\n";
412 continue;
413 }
414 }
415 if ( '(expires)' === $header ) {
416 if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
417 // Expired in the past.
418 return false;
419 }
420
421 if ( ! array_key_exists( '(expires)', $headers ) ) {
422 $signed_data .= $header . ': ' . $signature_block['(expires)'] . "\n";
423 continue;
424 }
425 }
426 if ( 'date' === $header ) {
427 // A signed `date` header with no value must fail closed, otherwise the time-window check is skipped.
428 if ( empty( $headers[ $header ][0] ) ) {
429 return false;
430 }
431
432 // date_create() returns false on malformed input; new DateTime() would instead throw.
433 $d = \date_create( $headers[ $header ][0], new \DateTimeZone( 'UTC' ) );
434 if ( false === $d ) {
435 return false;
436 }
437 $d->setTimeZone( new \DateTimeZone( 'UTC' ) );
438 $c = (int) $d->format( 'U' );
439
440 // Match the past-skew of the maintained Http_Signature_Draft verifier (1 hour); use its 5-minute future allowance.
441 $now = \time();
442 $d_plus = $now + ( 5 * MINUTE_IN_SECONDS );
443 $d_minus = $now - HOUR_IN_SECONDS;
444
445 if ( $c > $d_plus || $c < $d_minus ) {
446 // Time out of range.
447 return false;
448 }
449 }
450
451 if ( ! empty( $headers[ $header ][0] ) ) {
452 $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
453 }
454 }
455
456 return \rtrim( $signed_data, "\n" );
457 }
458
459 /**
460 * Generates the digest for an HTTP Request.
461 *
462 * @deprecated 7.0.0 Use {@see Signature::sign_request()}.
463 *
464 * @param string $body The body of the request.
465 *
466 * @return string The digest.
467 */
468 public static function generate_digest( $body ) {
469 \_deprecated_function( __METHOD__, '7.0.0', self::class . '::sign_request' );
470
471 $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
472 return "SHA-256=$digest";
473 }
474
475 /**
476 * Compute the collection digest for a specific instance.
477 *
478 * Implements FEP-8fcf: Followers collection synchronization.
479 * The digest is created by XORing together the individual SHA256 digests
480 * of each follower's ID.
481 *
482 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
483 *
484 * @param array $collection The user ID whose followers to compute.
485 *
486 * @return string|false The hex-encoded digest, or false if no followers.
487 */
488 public static function get_collection_digest( $collection ) {
489 if ( empty( $collection ) || ! is_array( $collection ) ) {
490 return false;
491 }
492
493 // Initialize with zeros (64 hex chars = 32 bytes = 256 bits).
494 $digest = str_repeat( '0', 64 );
495
496 foreach ( $collection as $item ) {
497 // Compute SHA256 hash of the follower ID.
498 $hash = hash( 'sha256', $item );
499
500 // XOR the hash with the running digest.
501 $digest = self::xor_hex_strings( $digest, $hash );
502 }
503
504 return $digest;
505 }
506
507 /**
508 * XOR two hexadecimal strings.
509 *
510 * Used for FEP-8fcf digest computation.
511 *
512 * @param string $hex1 First hex string.
513 * @param string $hex2 Second hex string.
514 *
515 * @return string The XORed result as a hex string.
516 */
517 public static function xor_hex_strings( $hex1, $hex2 ) {
518 $result = '';
519
520 // Ensure both strings are the same length (should be 64 chars for SHA256).
521 $length = \max( \strlen( $hex1 ), \strlen( $hex2 ) );
522 $hex1 = \str_pad( $hex1, $length, '0', STR_PAD_LEFT );
523 $hex2 = \str_pad( $hex2, $length, '0', STR_PAD_LEFT );
524
525 // XOR each pair of hex digits.
526 for ( $i = 0; $i < $length; $i += 2 ) {
527 $byte1 = \hexdec( \substr( $hex1, $i, 2 ) );
528 $byte2 = \hexdec( \substr( $hex2, $i, 2 ) );
529 $result .= \str_pad( \dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT );
530 }
531
532 return $result;
533 }
534
535 /**
536 * Parse a Collection-Synchronization header (FEP-8fcf).
537 *
538 * Parses the signature-style format used by the Collection-Synchronization header.
539 *
540 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
541 *
542 * @param string $header The header value.
543 *
544 * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure.
545 */
546 public static function parse_collection_sync_header( $header ) {
547 if ( empty( $header ) ) {
548 return false;
549 }
550
551 // Parse the signature-style format: key="value", key="value".
552 $params = array();
553
554 if ( \preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) {
555 foreach ( $matches as $match ) {
556 $params[ $match[1] ] = $match[2];
557 }
558 }
559
560 // Validate required fields for FEP-8fcf.
561 if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) {
562 return false;
563 }
564
565 return $params;
566 }
567 }
568