PluginProbe
ActivityPub / 1.2.0
ActivityPub v1.2.0
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 1.2.0, at includes/class-signature.php

500 lines 14.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Activitypub;
3
4 use WP_Error;
5 use DateTime;
6 use DateTimeZone;
7 use WP_REST_Request;
8 use Activitypub\Collection\Users;
9
10 /**
11 * ActivityPub Signature Class
12 *
13 * @author Matthias Pfefferle
14 * @author Django Doucet
15 */
16 class Signature {
17
18 /**
19 * Return the public key for a given user.
20 *
21 * @param int $user_id The WordPress User ID.
22 * @param bool $force Force the generation of a new key pair.
23 *
24 * @return mixed The public key.
25 */
26 public static function get_public_key_for( $user_id, $force = false ) {
27 if ( $force ) {
28 self::generate_key_pair_for( $user_id );
29 }
30
31 $key_pair = self::get_keypair_for( $user_id );
32
33 return $key_pair['public_key'];
34 }
35
36 /**
37 * Return the private key for a given user.
38 *
39 * @param int $user_id The WordPress User ID.
40 * @param bool $force Force the generation of a new key pair.
41 *
42 * @return mixed The private key.
43 */
44 public static function get_private_key_for( $user_id, $force = false ) {
45 if ( $force ) {
46 self::generate_key_pair_for( $user_id );
47 }
48
49 $key_pair = self::get_keypair_for( $user_id );
50
51 return $key_pair['private_key'];
52 }
53
54 /**
55 * Return the key pair for a given user.
56 *
57 * @param int $user_id The WordPress User ID.
58 *
59 * @return array The key pair.
60 */
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 );
64
65 if ( ! $key_pair ) {
66 $key_pair = self::generate_key_pair_for( $user_id );
67 }
68
69 return $key_pair;
70 }
71
72 /**
73 * Generates the pair keys
74 *
75 * @param int $user_id The WordPress User ID.
76 *
77 * @return array The key pair.
78 */
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 );
82
83 if ( $key_pair ) {
84 \add_option( $option_key, $key_pair );
85
86 return $key_pair;
87 }
88
89 $config = array(
90 'digest_alg' => 'sha512',
91 'private_key_bits' => 2048,
92 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
93 );
94
95 $key = \openssl_pkey_new( $config );
96 $priv_key = null;
97
98 \openssl_pkey_export( $key, $priv_key );
99
100 $detail = \openssl_pkey_get_details( $key );
101
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 );
111 }
112
113 $key_pair = array(
114 'private_key' => $priv_key,
115 'public_key' => $detail['key'],
116 );
117
118 // persist keys
119 \add_option( $option_key, $key_pair );
120
121 return $key_pair;
122 }
123
124 /**
125 * Return the option key for a given user.
126 *
127 * @param int $user_id The WordPress User ID.
128 *
129 * @return string The option key.
130 */
131 protected static function get_signature_options_key_for( $user_id ) {
132 $id = $user_id;
133
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 }
139
140 return 'activitypub_keypair_for_' . $id;
141 }
142
143 /**
144 * Check if there is a legacy key pair
145 *
146 * @param int $user_id The WordPress User ID.
147 *
148 * @return array|bool The key pair or false.
149 */
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 }
165
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 }
172
173 return false;
174 }
175
176 /**
177 * Generates the Signature for a HTTP Request
178 *
179 * @param int $user_id The WordPress User ID.
180 * @param string $http_method The HTTP method.
181 * @param string $url The URL to send the request to.
182 * @param string $date The date the request is sent.
183 * @param string $digest The digest of the request body.
184 *
185 * @return string The signature.
186 */
187 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() );
190
191 $url_parts = \wp_parse_url( $url );
192
193 $host = $url_parts['host'];
194 $path = '/';
195
196 // add path
197 if ( ! empty( $url_parts['path'] ) ) {
198 $path = $url_parts['path'];
199 }
200
201 // add query
202 if ( ! empty( $url_parts['query'] ) ) {
203 $path .= '?' . $url_parts['query'];
204 }
205
206 $http_method = \strtolower( $http_method );
207
208 if ( ! empty( $digest ) ) {
209 $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: $digest";
210 } else {
211 $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date";
212 }
213
214 $signature = null;
215 \openssl_sign( $signed_string, $signature, $key, \OPENSSL_ALGO_SHA256 );
216 $signature = \base64_encode( $signature ); // phpcs:ignore
217
218 $key_id = $user->get_url() . '#main-key';
219
220 if ( ! empty( $digest ) ) {
221 return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="%s"', $key_id, $signature );
222 } else {
223 return \sprintf( 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date",signature="%s"', $key_id, $signature );
224 }
225 }
226
227 /**
228 * Verifies the http signatures
229 *
230 * @param WP_REST_Request|array $request The request object or $_SERVER array.
231 *
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 $digest = explode( '=', $headers['digest'], 2 );
296 if ( 'SHA-256' === $digest[0] ) {
297 $hashalg = 'sha256';
298 }
299 if ( 'SHA-512' === $digest[0] ) {
300 $hashalg = 'sha512';
301 }
302
303 if ( \base64_encode( \hash( $hashalg, $body, true ) ) !== $digest[1] ) { // phpcs:ignore
304 return new WP_Error( 'activitypub_signature', __( 'Invalid Digest header', 'activitypub' ), array( 'status' => 401 ) );
305 }
306 }
307
308 $public_key = self::get_remote_key( $signature_block['keyId'] );
309
310 if ( \is_wp_error( $public_key ) ) {
311 return $public_key;
312 }
313
314 $verified = \openssl_verify( $signed_data, $signature_block['signature'], $public_key, $algorithm ) > 0;
315
316 if ( ! $verified ) {
317 return new WP_Error( 'activitypub_signature', __( 'Invalid signature', 'activitypub' ), array( 'status' => 401 ) );
318 }
319 return $verified;
320 }
321
322 /**
323 * Get public key from key_id
324 *
325 * @param string $key_id The URL to the public key.
326 *
327 * @return WP_Error|string The public key or WP_Error.
328 */
329 public static function get_remote_key( $key_id ) { // phpcs:ignore
330 $actor = get_remote_metadata_by_actor( strip_fragment_from_url( $key_id ) ); // phpcs:ignore
331 if ( \is_wp_error( $actor ) ) {
332 return new WP_Error(
333 'activitypub_no_remote_profile_found',
334 __( 'No Profile found or Profile not accessible', 'activitypub' ),
335 array( 'status' => 401 )
336 );
337 }
338 if ( isset( $actor['publicKey']['publicKeyPem'] ) ) {
339 return \rtrim( $actor['publicKey']['publicKeyPem'] ); // phpcs:ignore
340 }
341 return new WP_Error(
342 'activitypub_no_remote_key_found',
343 __( 'No Public-Key found', 'activitypub' ),
344 array( 'status' => 401 )
345 );
346 }
347
348 /**
349 * Gets the signature algorithm from the signature header
350 *
351 * @param array $signature_block
352 *
353 * @return string The signature algorithm.
354 */
355 public static function get_signature_algorithm( $signature_block ) {
356 if ( $signature_block['algorithm'] ) {
357 switch ( $signature_block['algorithm'] ) {
358 case 'rsa-sha-512':
359 return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
360 default:
361 return 'sha256';
362 }
363 }
364 return false;
365 }
366
367 /**
368 * Parses the Signature header
369 *
370 * @param string $signature The signature header.
371 *
372 * @return array signature parts
373 */
374 public static function parse_signature_header( $signature ) {
375 $parsed_header = array();
376 $matches = array();
377
378 if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
379 $parsed_header['keyId'] = trim( $matches[1] );
380 }
381 if ( \preg_match( '/created=([0-9]*)/ism', $signature, $matches ) ) {
382 $parsed_header['(created)'] = trim( $matches[1] );
383 }
384 if ( \preg_match( '/expires=([0-9]*)/ism', $signature, $matches ) ) {
385 $parsed_header['(expires)'] = trim( $matches[1] );
386 }
387 if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
388 $parsed_header['algorithm'] = trim( $matches[1] );
389 }
390 if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
391 $parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
392 }
393 if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
394 $parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore
395 }
396
397 if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
398 $parsed_header['headers'] = array( 'date' );
399 }
400
401 return $parsed_header;
402 }
403
404 /**
405 * Gets the header data from the included pseudo headers
406 *
407 * @param array $signed_headers The signed headers.
408 * @param array $signature_block (pseudo-headers)
409 * @param array $headers (http headers)
410 *
411 * @return string signed headers for comparison
412 */
413 public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
414 $signed_data = '';
415 // This also verifies time-based values by returning false if any of these are out of range.
416 foreach ( $signed_headers as $header ) {
417 if ( 'host' === $header ) {
418 if ( isset( $headers['x_original_host'] ) ) {
419 $signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
420 continue;
421 }
422 }
423 if ( '(request-target)' === $header ) {
424 $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
425 continue;
426 }
427 if ( str_contains( $header, '-' ) ) {
428 $signed_data .= $header . ': ' . $headers[ str_replace( '-', '_', $header ) ][0] . "\n";
429 continue;
430 }
431 if ( '(created)' === $header ) {
432 if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
433 // created in future
434 return false;
435 }
436 }
437 if ( '(expires)' === $header ) {
438 if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
439 // expired in past
440 return false;
441 }
442 }
443 if ( 'date' === $header ) {
444 // allow a bit of leeway for misconfigured clocks.
445 $d = new DateTime( $headers[ $header ][0] );
446 $d->setTimeZone( new DateTimeZone( 'UTC' ) );
447 $c = $d->format( 'U' );
448
449 $dplus = time() + ( 3 * HOUR_IN_SECONDS );
450 $dminus = time() - ( 3 * HOUR_IN_SECONDS );
451
452 if ( $c > $dplus || $c < $dminus ) {
453 // time out of range
454 return false;
455 }
456 }
457 $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
458 }
459 return \rtrim( $signed_data, "\n" );
460 }
461
462 /**
463 * Generates the digest for a HTTP Request
464 *
465 * @param string $body The body of the request.
466 *
467 * @return string The digest.
468 */
469 public static function generate_digest( $body ) {
470 $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore
471 return "SHA-256=$digest";
472 }
473
474 /**
475 * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
476 * for use with verify_http_signature()
477 *
478 * @param array $_SERVER The $_SERVER array.
479 *
480 * @return array $request The formatted request array.
481 */
482 public static function format_server_request( $server ) {
483 $request = array();
484 foreach ( $server as $param_key => $param_val ) {
485 $req_param = strtolower( $param_key );
486 if ( 'REQUEST_URI' === $req_param ) {
487 $request['headers']['route'][] = $param_val;
488 } else {
489 $header_key = str_replace(
490 'http_',
491 '',
492 $req_param
493 );
494 $request['headers'][ $header_key ][] = \wp_unslash( $param_val );
495 }
496 }
497 return $request;
498 }
499 }
500