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