PluginProbe
ActivityPub / 2.0.0
ActivityPub v2.0.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 2.0.0, at includes/class-signature.php

501 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 $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
325 *
326 * @param string $key_id The URL to the public key.
327 *
328 * @return WP_Error|string The public key or WP_Error.
329 */
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 }
348
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'] ) {
358 switch ( $signature_block['algorithm'] ) {
359 case 'rsa-sha-512':
360 return 'sha512'; //hs2019 https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
361 default:
362 return 'sha256';
363 }
364 }
365 return false;
366 }
367
368 /**
369 * Parses the Signature header
370 *
371 * @param string $signature The signature header.
372 *
373 * @return array signature parts
374 */
375 public static function parse_signature_header( $signature ) {
376 $parsed_header = array();
377 $matches = array();
378
379 if ( \preg_match( '/keyId="(.*?)"/ism', $signature, $matches ) ) {
380 $parsed_header['keyId'] = trim( $matches[1] );
381 }
382 if ( \preg_match( '/created=([0-9]*)/ism', $signature, $matches ) ) {
383 $parsed_header['(created)'] = trim( $matches[1] );
384 }
385 if ( \preg_match( '/expires=([0-9]*)/ism', $signature, $matches ) ) {
386 $parsed_header['(expires)'] = trim( $matches[1] );
387 }
388 if ( \preg_match( '/algorithm="(.*?)"/ism', $signature, $matches ) ) {
389 $parsed_header['algorithm'] = trim( $matches[1] );
390 }
391 if ( \preg_match( '/headers="(.*?)"/ism', $signature, $matches ) ) {
392 $parsed_header['headers'] = \explode( ' ', trim( $matches[1] ) );
393 }
394 if ( \preg_match( '/signature="(.*?)"/ism', $signature, $matches ) ) {
395 $parsed_header['signature'] = \base64_decode( preg_replace( '/\s+/', '', trim( $matches[1] ) ) ); // phpcs:ignore
396 }
397
398 if ( ( $parsed_header['signature'] ) && ( $parsed_header['algorithm'] ) && ( ! $parsed_header['headers'] ) ) {
399 $parsed_header['headers'] = array( 'date' );
400 }
401
402 return $parsed_header;
403 }
404
405 /**
406 * Gets the header data from the included pseudo headers
407 *
408 * @param array $signed_headers The signed headers.
409 * @param array $signature_block (pseudo-headers)
410 * @param array $headers (http headers)
411 *
412 * @return string signed headers for comparison
413 */
414 public static function get_signed_data( $signed_headers, $signature_block, $headers ) {
415 $signed_data = '';
416 // This also verifies time-based values by returning false if any of these are out of range.
417 foreach ( $signed_headers as $header ) {
418 if ( 'host' === $header ) {
419 if ( isset( $headers['x_original_host'] ) ) {
420 $signed_data .= $header . ': ' . $headers['x_original_host'][0] . "\n";
421 continue;
422 }
423 }
424 if ( '(request-target)' === $header ) {
425 $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
426 continue;
427 }
428 if ( str_contains( $header, '-' ) ) {
429 $signed_data .= $header . ': ' . $headers[ str_replace( '-', '_', $header ) ][0] . "\n";
430 continue;
431 }
432 if ( '(created)' === $header ) {
433 if ( ! empty( $signature_block['(created)'] ) && \intval( $signature_block['(created)'] ) > \time() ) {
434 // created in future
435 return false;
436 }
437 }
438 if ( '(expires)' === $header ) {
439 if ( ! empty( $signature_block['(expires)'] ) && \intval( $signature_block['(expires)'] ) < \time() ) {
440 // expired in past
441 return false;
442 }
443 }
444 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' ) );
448 $c = $d->format( 'U' );
449
450 $dplus = time() + ( 3 * HOUR_IN_SECONDS );
451 $dminus = time() - ( 3 * HOUR_IN_SECONDS );
452
453 if ( $c > $dplus || $c < $dminus ) {
454 // time out of range
455 return false;
456 }
457 }
458 $signed_data .= $header . ': ' . $headers[ $header ][0] . "\n";
459 }
460 return \rtrim( $signed_data, "\n" );
461 }
462
463 /**
464 * Generates the digest for a HTTP Request
465 *
466 * @param string $body The body of the request.
467 *
468 * @return string The digest.
469 */
470 public static function generate_digest( $body ) {
471 $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore
472 return "SHA-256=$digest";
473 }
474
475 /**
476 * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
477 * for use with verify_http_signature()
478 *
479 * @param array $_SERVER The $_SERVER array.
480 *
481 * @return array $request The formatted request array.
482 */
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 );
496 }
497 }
498 return $request;
499 }
500 }
501