PluginProbe
ActivityPub / 9.2.2
ActivityPub v9.2.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 9.2.2, at includes/class-signature.php

424 lines 13.0 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\Signature\Http_Message_Signature;
11 use Activitypub\Signature\Http_Signature_Draft;
12
13 /**
14 * ActivityPub Signature Class.
15 *
16 * @author Matthias Pfefferle
17 * @author Django Doucet
18 */
19 class Signature {
20
21 /**
22 * Initialize the class.
23 */
24 public static function init() {
25 \add_filter( 'http_request_args', array( self::class, 'sign_request' ), 0, 2 ); // Ahead of all other filters, so signature is set.
26 \add_filter( 'http_response', array( self::class, 'maybe_double_knock' ), 10, 3 );
27 }
28
29 /**
30 * Generate a new RSA key pair for signing HTTP requests.
31 *
32 * Does not persist anything — callers are responsible for storing the keys.
33 *
34 * @since 9.1.0
35 *
36 * @return array The key pair with 'private_key' and 'public_key', both null on failure.
37 */
38 public static function generate_key_pair() {
39 $config = array(
40 'digest_alg' => 'sha512',
41 'private_key_bits' => 2048,
42 'private_key_type' => \OPENSSL_KEYTYPE_RSA,
43 );
44
45 $key = \openssl_pkey_new( $config );
46 $private_key = null;
47 $detail = array();
48 if ( $key ) {
49 \openssl_pkey_export( $key, $private_key );
50 $detail = \openssl_pkey_get_details( $key );
51 }
52
53 // Check if keys are valid.
54 if (
55 empty( $private_key ) || ! \is_string( $private_key ) ||
56 ! isset( $detail['key'] ) || ! \is_string( $detail['key'] )
57 ) {
58 return array(
59 'private_key' => null,
60 'public_key' => null,
61 );
62 }
63
64 return array(
65 'private_key' => $private_key,
66 'public_key' => $detail['key'],
67 );
68 }
69
70 /**
71 * Get the key pair stored in an option, migrating a legacy pair or generating a new one on first use.
72 *
73 * @since 9.1.0
74 *
75 * @param string $option_key The option name the key pair is stored in.
76 * @param callable|null $legacy_callback Optional. Callback that returns a legacy key pair to migrate, or false. Default null.
77 *
78 * @return array The key pair with 'private_key' and 'public_key'.
79 */
80 public static function get_key_pair( $option_key, $legacy_callback = null ) {
81 $key_pair = \get_option( $option_key );
82
83 if ( $key_pair ) {
84 return $key_pair;
85 }
86
87 $key_pair = $legacy_callback ? $legacy_callback() : false;
88
89 if ( ! $key_pair ) {
90 $key_pair = self::generate_key_pair();
91
92 // Only persist valid keys.
93 if ( empty( $key_pair['private_key'] ) ) {
94 return $key_pair;
95 }
96 }
97
98 // `update_option()` also overwrites a corrupted-but-present row, which `add_option()` would silently skip.
99 \update_option( $option_key, $key_pair );
100
101 return $key_pair;
102 }
103
104 /**
105 * Sign an HTTP Request.
106 *
107 * @param array $args An array of HTTP request arguments.
108 * @param string $url The request URL.
109 *
110 * @return array Request arguments with signature headers.
111 */
112 public static function sign_request( $args, $url ) {
113 // Bail if there's nothing to sign with.
114 if ( ! isset( $args['key_id'], $args['private_key'] ) ) {
115 return $args;
116 }
117
118 if ( '1' === \get_option( 'activitypub_rfc9421_signature' ) && self::could_support_rfc9421( $url ) ) {
119 $signature = new Http_Message_Signature();
120 } else {
121 $signature = new Http_Signature_Draft();
122 }
123
124 return $signature->sign( $args, $url );
125 }
126
127 /**
128 * Verifies the http signatures
129 *
130 * On success the verified keyId is returned (a truthy string), so callers can bind it to
131 * the activity actor without re-parsing headers, which cannot tell which signature label
132 * actually validated. Pass/fail callers should branch on {@see is_wp_error()} as before.
133 *
134 * @since 9.0.0 Returns the verified keyId on success instead of `true`.
135 *
136 * @param \WP_REST_Request|array $request The request object or $_SERVER array.
137 *
138 * @return string|\WP_Error The verified keyId on success, WP_Error on failure.
139 */
140 public static function verify_http_signature( $request ) {
141 if ( \is_object( $request ) ) { // REST Request object.
142 $body = $request->get_body();
143 $headers = $request->get_headers();
144 $headers['(request-target)'][0] = \strtolower( $request->get_method() ) . ' ' . self::get_route( $request );
145 } else {
146 $headers = self::format_server_request( $request );
147 $headers['(request-target)'][0] = \strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0];
148 }
149
150 $signature = isset( $headers['signature_input'] ) ? new Http_Message_Signature() : new Http_Signature_Draft();
151
152 return $signature->verify( $headers, $body ?? null );
153 }
154
155 /**
156 * Extract the signing keyId that {@see Signature::verify_http_signature()} would verify against.
157 *
158 * The returned keyId is only trustworthy if it identifies the key the signature is
159 * actually checked with, so this mirrors the verifier's header choice rather than
160 * scanning headers in an arbitrary order:
161 *
162 * - When a `Signature-Input` header is present the RFC 9421 verifier is used, so the
163 * keyId is taken from there and a draft `Signature` header (which the verifier ignores)
164 * is not consulted. The RFC 9421 verifier accepts whichever of several signature labels
165 * validates, so a `Signature-Input` carrying more than one keyId is ambiguous: we cannot
166 * know in advance which key will verify and must not guess, so `null` is returned.
167 * - Otherwise the draft HTTP Signatures form is used, taking the first `keyId` from the
168 * `Signature` header or, failing that, the `Authorization` header — matching the draft
169 * verifier, which reads `signature ?? authorization`.
170 *
171 * @since 9.0.0
172 *
173 * @param \WP_REST_Request $request The request object.
174 *
175 * @return string|null The keyId, or null when none is present or the choice is ambiguous.
176 */
177 public static function get_key_id( $request ) {
178 $signature_input = $request->get_header( 'signature-input' );
179 if ( $signature_input ) {
180 /*
181 * keyid is a `;`-delimited parameter whose value may be quoted or unquoted.
182 * Anchoring on `;` (or string start) avoids matching a `keyid=` substring inside
183 * another parameter's value. Count every label's keyId: more than one is ambiguous.
184 */
185 $count = \preg_match_all( '/(?:^|;)\s*keyid="?([^";,\s]+)/i', $signature_input, $matches );
186
187 return 1 === $count ? $matches[1][0] : null;
188 }
189
190 // A draft signature may arrive in the Signature header or, less commonly, Authorization.
191 $signature = $request->get_header( 'signature' );
192 if ( ! $signature ) {
193 $signature = $request->get_header( 'authorization' );
194 }
195
196 if ( $signature && \preg_match( '/keyId="([^"]+)"/i', $signature, $matches ) ) {
197 return $matches[1];
198 }
199
200 return null;
201 }
202
203 /**
204 * If a request with RFC-9421 signature fails, we try again with the Draft Cavage signature.
205 *
206 * @param array $response HTTP response.
207 * @param array $args HTTP request arguments.
208 * @param string $url The request URL.
209 *
210 * @return array The HTTP response.
211 */
212 public static function maybe_double_knock( $response, $args, $url ) {
213 // Bail if it didn't use an RFC-9421 signature or there's nothing to sign with.
214 if ( ! isset( $args['key_id'], $args['private_key'], $args['headers']['Signature-Input'] ) ) {
215 return $response;
216 }
217
218 $response_code = \wp_remote_retrieve_response_code( $response );
219
220 // Fall back to Draft Cavage signature for any 4xx responses.
221 if ( $response_code >= 400 && $response_code < 500 ) {
222 unset( $args['headers']['Signature'], $args['headers']['Signature-Input'], $args['headers']['Content-Digest'] );
223 self::rfc9421_add_unsupported_host( $url );
224
225 $args = ( new Http_Signature_Draft() )->sign( $args, $url );
226 $response = \wp_safe_remote_request( $url, $args );
227 }
228
229 return $response;
230 }
231
232 /**
233 * Formats the $_SERVER to resemble the WP_REST_REQUEST array,
234 * for use with verify_http_signature().
235 *
236 * @param array $server The $_SERVER array.
237 *
238 * @return array $request The formatted request array.
239 */
240 public static function format_server_request( $server ) {
241 $headers = array();
242
243 foreach ( $server as $key => $value ) {
244 $key = \str_replace( 'http_', '', \strtolower( $key ) );
245 $headers[ $key ][] = \wp_unslash( $value );
246
247 }
248
249 return $headers;
250 }
251
252 /**
253 * Returns route.
254 *
255 * @param \WP_REST_Request $request The request object.
256 *
257 * @return string
258 */
259 private static function get_route( $request ) {
260 // Check if the route starts with "index.php".
261 if ( \str_starts_with( $request->get_route(), '/index.php' ) || ! \rest_get_url_prefix() ) {
262 $route = $request->get_route();
263 } else {
264 $route = '/' . \rest_get_url_prefix() . '/' . \ltrim( $request->get_route(), '/' );
265 }
266
267 // Fix route for subdirectory installations.
268 $path = \wp_parse_url( \get_home_url(), PHP_URL_PATH );
269
270 if ( \is_string( $path ) ) {
271 $path = \trim( $path, '/' );
272 }
273
274 if ( $path ) {
275 $route = '/' . $path . $route;
276 }
277
278 /*
279 * Append the query string. Peers sign the full request-target including
280 * the query (see Http_Signature_Draft::sign()), so the reconstructed
281 * value has to match byte-for-byte. Use the raw REQUEST_URI instead of
282 * re-encoding the parsed query params, re-encoding could change the
283 * percent-encoding or parameter order and break the signature.
284 */
285 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
286 $query = (string) \wp_parse_url( $_SERVER['REQUEST_URI'] ?? '', \PHP_URL_QUERY );
287
288 if ( '' !== $query ) {
289 $route .= '?' . $query;
290 }
291
292 return $route;
293 }
294
295 /**
296 * Check if RFC-9421 signature could be supported.
297 *
298 * @param string $url The URL to check.
299 *
300 * @return bool True, if RFC-9421 signature could be supported, false otherwise.
301 */
302 private static function could_support_rfc9421( $url ) {
303 $host = \wp_parse_url( $url, \PHP_URL_HOST );
304 $list = \get_option( 'activitypub_rfc9421_unsupported', array() );
305
306 if ( isset( $list[ $host ] ) ) {
307 if ( $list[ $host ] > \time() ) {
308 return false;
309 }
310
311 unset( $list[ $host ] );
312 \update_option( 'activitypub_rfc9421_unsupported', $list );
313 }
314
315 return true;
316 }
317
318 /**
319 * Set RFC-9421 signature unsupported for a given host.
320 *
321 * @param string $url The URL to set.
322 */
323 private static function rfc9421_add_unsupported_host( $url ) {
324 $list = \get_option( 'activitypub_rfc9421_unsupported', array() );
325 $host = \wp_parse_url( $url, \PHP_URL_HOST );
326
327 $list[ $host ] = \time() + MONTH_IN_SECONDS;
328 \update_option( 'activitypub_rfc9421_unsupported', $list, false );
329 }
330
331 /**
332 * Compute the collection digest for a specific instance.
333 *
334 * Implements FEP-8fcf: Followers collection synchronization.
335 * The digest is created by XORing together the individual SHA256 digests
336 * of each follower's ID.
337 *
338 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
339 *
340 * @param array $collection The user ID whose followers to compute.
341 *
342 * @return string|false The hex-encoded digest, or false if no followers.
343 */
344 public static function get_collection_digest( $collection ) {
345 if ( empty( $collection ) || ! \is_array( $collection ) ) {
346 return false;
347 }
348
349 // Initialize with zeros (64 hex chars = 32 bytes = 256 bits).
350 $digest = \str_repeat( '0', 64 );
351
352 foreach ( $collection as $item ) {
353 // Compute SHA256 hash of the follower ID.
354 $hash = \hash( 'sha256', $item );
355
356 // XOR the hash with the running digest.
357 $digest = self::xor_hex_strings( $digest, $hash );
358 }
359
360 return $digest;
361 }
362
363 /**
364 * XOR two hexadecimal strings.
365 *
366 * Used for FEP-8fcf digest computation.
367 *
368 * @param string $hex1 First hex string.
369 * @param string $hex2 Second hex string.
370 *
371 * @return string The XORed result as a hex string.
372 */
373 public static function xor_hex_strings( $hex1, $hex2 ) {
374 $result = '';
375
376 // Ensure both strings are the same length (should be 64 chars for SHA256).
377 $length = \max( \strlen( $hex1 ), \strlen( $hex2 ) );
378 $hex1 = \str_pad( $hex1, $length, '0', STR_PAD_LEFT );
379 $hex2 = \str_pad( $hex2, $length, '0', STR_PAD_LEFT );
380
381 // XOR each pair of hex digits.
382 for ( $i = 0; $i < $length; $i += 2 ) {
383 $byte1 = \hexdec( \substr( $hex1, $i, 2 ) );
384 $byte2 = \hexdec( \substr( $hex2, $i, 2 ) );
385 $result .= \str_pad( \dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT );
386 }
387
388 return $result;
389 }
390
391 /**
392 * Parse a Collection-Synchronization header (FEP-8fcf).
393 *
394 * Parses the signature-style format used by the Collection-Synchronization header.
395 *
396 * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md
397 *
398 * @param string $header The header value.
399 *
400 * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure.
401 */
402 public static function parse_collection_sync_header( $header ) {
403 if ( empty( $header ) ) {
404 return false;
405 }
406
407 // Parse the signature-style format: key="value", key="value".
408 $params = array();
409
410 if ( \preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) {
411 foreach ( $matches as $match ) {
412 $params[ $match[1] ] = $match[2];
413 }
414 }
415
416 // Validate required fields for FEP-8fcf.
417 if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) {
418 return false;
419 }
420
421 return $params;
422 }
423 }
424