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

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