PluginProbe
ActivityPub / 4.0.2
ActivityPub v4.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
activitypub / includes / class-signature.php

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

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