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

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