PluginProbe
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance / 4.5.4
WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance v4.5.4
4.7.0 4.6.1 4.6.0 4.5.5 4.5.4 4.5.3 4.5.2 3.2.20 3.2.21 3.2.22 3.2.3 3.2.5 3.2.6 3.2.7 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.7.0 3.7.1 All 111 releases
wp-optimize / vendor / team-updraft / common-libs / src / updraft-rpc / class-udrpc.php

class-udrpc.php in WP-Optimize – Cache, Compress images, Minify & Clean database to boost page speed & performance 4.5.4, at vendor/team-updraft/common-libs/src/updraft-rpc/class-udrpc.php

1,128 lines 41.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreStart
3 /*
4 This class provides methods for encrypting, sending, receiving and decrypting messages of arbitrary length, using standard encryption methods and including protection against replay attacks.
5
6 Example:
7
8 // Set a key and encrypt with it
9 $ud_rpc = new UpdraftPlus_Remote_Communications($name_indicator); // $name_indicator is a key indicator - indicating which key is being used.
10 $ud_rpc->set_key_local($our_private_key);
11 $ud_rpc->set_key_remote($their_public_key);
12 $encrypted = $ud_rpc->encrypt_message('blah blah');
13
14 // Use the saved WP site option
15 $ud_rpc = new UpdraftPlus_Remote_Communications($name_indicator); // $name_indicator is a key indicator - indicating which key is being used.
16 $ud_rpc->set_option_name('udrpc_remotekey');
17 if (!$ud_rpc->get_key_remote()) throw new Exception('...');
18 $encrypted = $ud_rpc->encrypt_message('blah blah');
19
20 // Generate a new key
21 $ud_rpc = new UpdraftPlus_Remote_Communications('myindicator.example.com');
22 $ud_rpc->set_option_name('udrpc_localkey'); // Save as a WP site option
23 $new_pair = $ud_rpc->generate_new_keypair();
24 if ($new_pair) {
25 $local_private_key = $ud_rpc->get_key_local();
26 $remote_public_key = $ud_rpc->get_key_remote();
27 // ...
28 } else {
29 throw new Exception('...');
30 }
31
32 // Send a message
33 $ud_rpc->activate_replay_protection();
34 $ud_rpc->set_destination_url('https://example.com/path/to/wp');
35 $ud_rpc->send_message('ping');
36 $ud_rpc->send_message('somecommand', array('param1' => 'data', 'param2' => 'moredata'));
37
38 // N.B. The data sent needs to be something that will pass json_encode(). So, it may be desirable to base64-encode it first.
39
40 // Create a listener for incoming messages
41
42 add_filter('udrpc_command_somecommand', 'my_function', 10, 3);
43 // function my_function($response, $data, $name_indicator) { ... ; return array('response' => 'my_reply', 'data' => 'any mixed data'); }
44 // Or:
45 // add_filter('udrpc_action', 'some_function', 10, 4); // Function must return something other than false to indicate that it handled the specific command. Any returned value will be sent as the reply.
46 // function some_function($response, $command, $data, $name_indicator) { ...; return array('response' => 'my_reply', 'data' => 'any mixed data'); }
47 $ud_rpc->set_option_name('udrpc_local_private_key');
48 $ud_rpc->activate_replay_protection();
49 if ($ud_rpc->get_key_local()) {
50 // Make sure you call this before the wp_loaded action is fired (e.g. at init)
51 $ud_rpc->create_listener();
52 }
53
54 // Instead of using activate_replay_protection(), you can use activate_sequence_protection() (receiving side) and set_next_send_sequence_id(). They are very similar; but, the sequence number code isn't tested, and is problematic if you may have multiple clients that don't share storage (you can use the current time as a sequence number, but if two clients send at the same millisecond (or whatever granularity you use), you may have problems); whereas the replay protection code relies on database storage on the sending side (not just the receiving).
55
56 */
57 // @codingStandardsIgnoreEnd
58 if (!class_exists('UpdraftPlus_Remote_Communications')) :
59 class UpdraftPlus_Remote_Communications {
60
61 // Version numbers relate to versions of this PHP library only (i.e. it's not a protocol support number, and version numbers of other compatible libraries (e.g. JavaScript) are not comparable)
62 public $version = '1.4.24';
63
64 private $key_name_indicator;
65
66 private $key_option_name = false;
67
68 private $key_remote = false;
69
70 private $key_local = false;
71
72 private $can_generate = false;
73
74 private $destination_url = false;
75
76 private $maximum_replay_time_difference = 300;
77
78 private $extra_replay_protection = false;
79
80 private $sequence_protection_tolerance;
81
82 private $sequence_protection_table;
83
84 private $sequence_protection_column;
85
86 private $sequence_protection_where_sql;
87
88 // Debug may log confidential data using $this->log() - so only use when you are in a secure environment
89 private $debug = false;
90
91 private $next_send_sequence_id;
92
93 private $allow_cors_from = array();
94
95 private $http_transport = null;
96
97 // Default protocol version - this can be over-ridden with set_message_format
98 // Protocol version 1 (which uses only one RSA key-pair, instead of two) is legacy/deprecated
99 private $format = 2;
100
101 private $http_credentials = array();
102
103 private $incoming_message = null;
104
105 private $message_random_number = null;
106
107 private $require_message_to_be_understood = false;
108
109 public function __construct($key_name_indicator = 'default') {
110 $this->set_key_name_indicator($key_name_indicator);
111 }
112
113 public function set_key_name_indicator($key_name_indicator) {
114 $this->key_name_indicator = $key_name_indicator;
115 }
116
117 public function set_can_generate($can_generate = true) {
118 $this->can_generate = $can_generate;
119 }
120
121 /**
122 * Which sites to allow CORS requests from
123 *
124 * @param string $allow_cors_from
125 */
126 public function set_allow_cors_from($allow_cors_from) {
127 $this->allow_cors_from = $allow_cors_from;
128 }
129
130 public function set_maximum_replay_time_difference($replay_time_difference) {
131 $this->maximum_replay_time_difference = (int) $replay_time_difference;
132 }
133
134 /**
135 * This will cause more things to be sent to $this->log()
136 *
137 * @param boolean $debug
138 */
139 public function set_debug($debug = true) {
140 $this->debug = (bool) $debug;
141 }
142
143 /**
144 * Supported values: a Guzzle object, or, if not, then WP's HTTP API function siwll be used
145 *
146 * @param string $transport
147 */
148 public function set_http_transport($transport) {
149 $this->http_transport = $transport;
150 }
151
152 /**
153 * Sequence protection and replay protection perform similar functions, and using both is often over-kill; the distinction is that sequence protection can be used without needing to do database writes on the sending side (e.g. use the value of time() as the sequence number).
154 * The only rule of sequences is that the receiving side will reject any sequence number that is less than the last previously seen one, within the bounds of the tolerance (but it may also reject those if they are repeats).
155 * The given table/column will record a comma-separated list of recently seen sequences numbers within the tolerance threshold.
156 *
157 * @param string $table
158 * @param string $column
159 * @param string $where_sql
160 * @param integer $tolerance
161 */
162 public function activate_sequence_protection($table, $column, $where_sql, $tolerance = 5) {
163 $this->sequence_protection_tolerance = (int) $tolerance;
164 $this->sequence_protection_table = (string) $table;
165 $this->sequence_protection_column = (string) $column;
166 $this->sequence_protection_where_sql = (string) $where_sql;
167 }
168
169 private function ensure_crypto_loaded() {
170 if (!class_exists('Crypt_Rijndael') || !class_exists('Crypt_RSA') || !class_exists('Crypt_Hash')) {
171 global $updraftplus, $updraftcentral_host_plugin;
172
173 $base_dir = '';
174 if (is_a($updraftcentral_host_plugin, 'UpdraftCentral_Host') && is_callable(array($updraftcentral_host_plugin, 'get_host_dir'))) {
175 $base_dir = trailingslashit($updraftcentral_host_plugin->get_host_dir());
176 }
177
178 // phpseclib 1.x uses deprecated PHP4-style constructors
179 $this->no_deprecation_warnings_on_php7();
180 if (is_a($updraftplus, 'UpdraftPlus')) {
181 // Since May 2019, the second parameter is unused; but, since we don't know the version, we send it.
182 $ensure_phpseclib = $updraftplus->ensure_phpseclib(array('Crypt_Rijndael', 'Crypt_RSA', 'Crypt_Hash'), array('Crypt/Rijndael', 'Crypt/RSA', 'Crypt/Hash'));
183 if (is_wp_error($ensure_phpseclib)) return $ensure_phpseclib;
184 } elseif (defined('UPDRAFTPLUS_DIR') && file_exists(UPDRAFTPLUS_DIR.'/vendor/phpseclib/phpseclib/phpseclib')) {
185 $pdir = UPDRAFTPLUS_DIR.'/vendor/phpseclib/phpseclib/phpseclib';
186 if (false === strpos(get_include_path(), $pdir)) set_include_path($pdir.PATH_SEPARATOR.get_include_path());
187 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
188 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
189 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
190 } elseif (file_exists(dirname(dirname(__FILE__)).'/vendor/phpseclib/phpseclib/phpseclib')) {
191 $pdir = dirname(dirname(__FILE__)).'/vendor/phpseclib/phpseclib/phpseclib';
192 if (false === strpos(get_include_path(), $pdir)) set_include_path($pdir.PATH_SEPARATOR.get_include_path());
193 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
194 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
195 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
196 } elseif ('' !== $base_dir && file_exists($base_dir.'vendor/phpseclib/phpseclib/phpseclib')) {
197 $phpseclib_dir = $base_dir.'vendor/phpseclib/phpseclib/phpseclib';
198 if (false === strpos(get_include_path(), $phpseclib_dir)) set_include_path($phpseclib_dir.PATH_SEPARATOR.get_include_path());
199 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
200 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
201 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
202 }
203 }
204 }
205
206 /**
207 * Ugly, but necessary to prevent debug output breaking the conversation when the user has debug turned on
208 */
209 private function no_deprecation_warnings_on_php7() {
210 // PHP_MAJOR_VERSION is defined in PHP 5.2.7+
211 // We don't test for PHP > 7 because the specific deprecated element will be removed in PHP 8 - and so no warning should come anyway (and we shouldn't suppress other stuff until we know we need to).
212 // @codingStandardsIgnoreLine
213 if (defined('PHP_MAJOR_VERSION') && PHP_MAJOR_VERSION == 7) {
214 $old_level = error_reporting();
215 // @codingStandardsIgnoreLine
216 $new_level = $old_level & ~E_DEPRECATED;
217 if ($old_level != $new_level) error_reporting($new_level);
218 }
219 }
220
221 public function set_destination_url($destination_url) {
222 $this->destination_url = $destination_url;
223 }
224
225 public function get_destination_url() {
226 return $this->destination_url;
227 }
228
229 public function set_option_name($key_option_name) {
230 $this->key_option_name = $key_option_name;
231 }
232
233 /**
234 * Method to get the remote key
235 *
236 * @return string
237 */
238 public function get_key_remote() {
239 if (empty($this->key_remote) && $this->can_generate) {
240 $this->generate_new_keypair();
241 }
242
243 return empty($this->key_remote) ? false : $this->key_remote;
244 }
245
246 /**
247 * Set the remote key
248 *
249 * @param string $key_remote
250 */
251 public function set_key_remote($key_remote) {
252 $this->key_remote = $key_remote;
253 }
254
255 /**
256 * Used for sending - when receiving, the format is part of the message
257 *
258 * @param integer $format
259 */
260 public function set_message_format($format = 2) {
261 $this->format = $format;
262 }
263
264 /**
265 * Used for sending - when receiving, the format is part of the message
266 *
267 * @return integer
268 */
269 public function get_message_format() {
270 return $this->format;
271 }
272
273 /**
274 * Method to get the local key
275 *
276 * @return string
277 */
278 public function get_key_local() {
279 if (empty($this->key_local)) {
280 if ($this->key_option_name) {
281 $key_local = get_site_option($this->key_option_name);
282 if ($key_local) {
283 $this->key_local = $key_local;
284 }
285 }
286 }
287 if (empty($this->key_local) && $this->can_generate) {
288 $this->generate_new_keypair();
289 }
290
291 return empty($this->key_local) ? false : $this->key_local;
292 }
293
294 /**
295 * Tests whether a supplied string (after trimming) is a valid portable bundle
296 *
297 * @param string $bundle [description]
298 * @param string $format same as get_portable_bundle()
299 * @return array (which the consumer is free to use - e.g. convert into internationalised string), with keys 'code' and (perhaps) 'data'
300 */
301 public function decode_portable_bundle($bundle, $format = 'raw') {
302 $bundle = trim($bundle);
303 if ('base64_with_count' == $format) {
304 if (strlen($bundle) < 5) return array('code' => 'invalid_wrong_length', 'data' => 'too_short');
305 $len = substr($bundle, 0, 4);
306 $bundle = substr($bundle, 4);
307 $len = hexdec($len);
308 if (strlen($bundle) != $len) return array('code' => 'invalid_wrong_length', 'data' => "1,$len,".strlen($bundle));
309 if (false === ($bundle = base64_decode($bundle))) return array('code' => 'invalid_corrupt', 'data' => 'not_base64');
310 if (null === ($bundle = json_decode($bundle, true))) return array('code' => 'invalid_corrupt', 'data' => 'not_json');
311 }
312 if (empty($bundle['key'])) return array('code' => 'invalid_corrupt', 'data' => 'no_key');
313 if (empty($bundle['url'])) return array('code' => 'invalid_corrupt', 'data' => 'no_url');
314 if (empty($bundle['name_indicator'])) return array('code' => 'invalid_corrupt', 'data' => 'no_name_indicator');
315
316 return $bundle;
317 }
318
319 /**
320 * Method to get a portable bundle sufficient to contact this site (i.e. remote site - so you need to have generated a key-pair, or stored the remote key somewhere and restored it)
321 *
322 * @param string $format Supported formats: base64_with_count and default)raw
323 * @param array $extra_info needs to be JSON-serialisable, so be careful about what you put into it.
324 * @param array $options [description]
325 * @return array
326 */
327 public function get_portable_bundle($format = 'raw', $extra_info = array(), $options = array()) {
328
329 $bundle = array_merge($extra_info, array(
330 'key' => empty($options['key']) ? $this->get_key_remote() : $options['key'],
331 'name_indicator' => $this->key_name_indicator,
332 'url' => trailingslashit(network_site_url()),
333 'admin_url' => trailingslashit(admin_url()),
334 'network_admin_url' => trailingslashit(network_admin_url()),
335 'format_support' => 2,
336 ));
337
338 if ('base64_with_count' == $format) {
339 $bundle = base64_encode(json_encode($bundle));
340
341 $len = strlen($bundle); // Get the length
342 $len = dechex($len); // The first bytes of the message are the bundle length
343 $len = str_pad($len, 4, '0', STR_PAD_LEFT); // Zero pad
344
345 return $len.$bundle;
346
347 } else {
348 return $bundle;
349 }
350
351 }
352
353 public function set_key_local($key_local) {
354 $this->key_local = $key_local;
355 if ($this->key_option_name) update_site_option($this->key_option_name, $this->key_local);
356 }
357
358 public function generate_new_keypair($key_size = 2048) {
359
360 $this->ensure_crypto_loaded();
361
362 $rsa = new Crypt_RSA();
363 $keys = $rsa->createKey($key_size);
364
365 if (empty($keys['privatekey'])) {
366 $this->set_key_local(false);
367 } else {
368 $this->set_key_local($keys['privatekey']);
369 }
370
371 if (empty($keys['publickey'])) {
372 $this->set_key_remote(false);
373 } else {
374 $this->set_key_remote($keys['publickey']);
375 }
376
377 return empty($keys['publickey']) ? false : true;
378 }
379
380 /**
381 * A base-64 encoded RSA hash (PKCS_1) of the message digest
382 *
383 * @param string $message
384 * @param boolean $use_key
385 * @return array
386 */
387 public function signature_for_message($message, $use_key = false) {
388
389 $hash_algorithm = 'sha256';
390
391 // Sign with the private (local) key
392 if (!$use_key) {
393 if (!$this->key_local) throw new Exception('No signing key has been set');
394 $use_key = $this->key_local;
395 }
396
397 $this->ensure_crypto_loaded();
398
399 $rsa = new Crypt_RSA();
400 $rsa->loadKey($use_key);
401 // This is the older signature mode; phpseclib's default is the preferred CRYPT_RSA_SIGNATURE_PSS; however, Forge JS doesn't yet support this. More info: https://en.wikipedia.org/wiki/PKCS_1
402 $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
403
404 // Don't do this: Crypt_RSA::sign() already calculates the digest of the hash
405 // $hash = new Crypt_Hash($hash_algorithm);
406 // $hashed = $hash->hash($message);
407
408 // if ($this->debug) $this->log("Message hash (hash=$hash_algorithm) (hex): ".bin2hex($hashed));
409
410 // phpseclib defaults to SHA1
411 $rsa->setHash($hash_algorithm);
412 $encrypted = $rsa->sign($message);
413
414 if ($this->debug) $this->log('Signed hash (mode='.CRYPT_RSA_SIGNATURE_PKCS1.') (hex): '.bin2hex($encrypted));
415
416 $signature = base64_encode($encrypted);
417
418 if ($this->debug) $this->log("Message signature (base64): $signature");
419
420 return $signature;
421 }
422
423 /**
424 * Log description
425 *
426 * @param string $message
427 * @param string $level $level is not yet used much
428 */
429 private function log($message, $level = 'notice') {
430 // Allow other plugins to do something with the message
431 do_action('udrpc_log', $message, $level, $this->key_name_indicator, $this->debug, $this);
432 if ('info' != $level) error_log('UDRPC ('.$this->key_name_indicator.", $level): $message");
433 }
434
435 /**
436 * Encrypt the message, using the local key (which needs to exist)
437 *
438 * @param string $plaintext
439 * @param boolean $use_key
440 * @param integer $key_length
441 * @return array
442 */
443 public function encrypt_message($plaintext, $use_key = false, $key_length = 32) {
444
445 if (!$use_key) {
446 if (1 == $this->format) {
447 if (!$this->key_local) throw new Exception('No encryption key has been set');
448 $use_key = $this->key_local;
449 } else {
450 if (!$this->key_remote) throw new Exception('No encryption key has been set');
451 $use_key = $this->key_remote;
452 }
453 }
454
455 $this->ensure_crypto_loaded();
456
457 $rsa = new Crypt_RSA();
458
459 if (defined('UDRPC_PHPSECLIB_ENCRYPTION_MODE')) $rsa->setEncryptionMode(UDRPC_PHPSECLIB_ENCRYPTION_MODE);
460
461 $rij = new Crypt_Rijndael();
462
463 // Generate Random Symmetric Key
464 $sym_key = crypt_random_string($key_length);
465
466 if ($this->debug) $this->log('Unencrypted symmetric key (hex): '.bin2hex($sym_key));
467
468 // Encrypt Message with new Symmetric Key
469 $rij->setKey($sym_key);
470 $ciphertext = $rij->encrypt($plaintext);
471
472 if ($this->debug) $this->log('Encrypted ciphertext (hex): '.bin2hex($ciphertext));
473
474 $ciphertext = base64_encode($ciphertext);
475
476 // Encrypt the Symmetric Key with the Asymmetric Key
477 $rsa->loadKey($use_key);
478 $sym_key = $rsa->encrypt($sym_key);
479
480 if ($this->debug) $this->log('Encrypted symmetric key (hex): '.bin2hex($sym_key));
481
482 // Base 64 encode the symmetric key for transport
483 $sym_key = base64_encode($sym_key);
484
485 if ($this->debug) $this->log('Encrypted symmetric key (b64): '.$sym_key);
486
487 $len = str_pad(dechex(strlen($sym_key)), 3, '0', STR_PAD_LEFT); // Zero pad to be sure.
488
489 // 16 characters of hex is enough for the payload to be to 16 exabytes (giga < tera < peta < exa) of data
490 $cipherlen = str_pad(dechex(strlen($ciphertext)), 16, '0', STR_PAD_LEFT);
491
492 // Concatenate the length, the encrypted symmetric key, and the message
493 return $len.$sym_key.$cipherlen.$ciphertext;
494
495 }
496
497 /**
498 * Decrypt the message, using the local key (which needs to exist)
499 *
500 * @param string $message
501 * @return array
502 */
503 public function decrypt_message($message) {
504
505 if (!$this->key_local) throw new Exception('No decryption key has been set');
506
507 $this->ensure_crypto_loaded();
508
509 $rsa = new Crypt_RSA();
510 if (defined('UDRPC_PHPSECLIB_ENCRYPTION_MODE')) $rsa->setEncryptionMode(UDRPC_PHPSECLIB_ENCRYPTION_MODE);
511 // Defaults to CRYPT_AES_MODE_CBC
512 $rij = new Crypt_Rijndael();
513
514 // Extract the Symmetric Key
515 $len = substr($message, 0, 3);
516 $len = hexdec($len);
517 $sym_key = substr($message, 3, $len);
518
519 // Extract the encrypted message
520 $cipherlen = substr($message, ($len + 3), 16);
521 $cipherlen = hexdec($cipherlen);
522
523 $ciphertext = substr($message, ($len + 19), $cipherlen);
524 $ciphertext = base64_decode($ciphertext);
525
526 // Decrypt the encrypted symmetric key
527 $rsa->loadKey($this->key_local);
528 $sym_key = base64_decode($sym_key);
529 $sym_key = $rsa->decrypt($sym_key);
530
531 // Decrypt the message
532 $rij->setKey($sym_key);
533
534 return $rij->decrypt($ciphertext);
535
536 }
537
538 /**
539 * Creates a message
540 *
541 * @param string $command
542 * @param string $data
543 * @param boolean $is_response
544 * @param boolean $use_key_remote
545 * @param boolean $use_key_local
546 * @return array which the caller will then format as required (e.g. use as body in post, or JSON-encode, etc.) [description]
547 */
548 public function create_message($command, $data = null, $is_response = false, $use_key_remote = false, $use_key_local = false) {
549
550 if ($is_response) {
551 $send_array = array('response' => $command);
552 } else {
553 $send_array = array('command' => $command);
554 }
555
556 $send_array['time'] = time();
557 // This goes in the encrypted portion as well to prevent replays with a different unencrypted name indicator
558 $send_array['key_name'] = $this->key_name_indicator;
559
560 // This random element means that if the site needs to send two identical commands or responses in the same second, then it can, and still use replay protection
561 // The value of PHP_INT_MAX on a 32-bit platform
562 $this->message_random_number = rand(1, 2147483647);
563 $send_array['rand'] = $this->message_random_number;
564
565 if ($this->next_send_sequence_id) {
566 $send_array['sequence_id'] = $this->next_send_sequence_id;
567 ++$this->next_send_sequence_id;
568 }
569
570 if ($is_response && !empty($this->incoming_message) && isset($this->incoming_message['rand'])) {
571 $send_array['incoming_rand'] = $this->incoming_message['rand'];
572 }
573
574 if (null !== $data) $send_array['data'] = $data;
575 $send_data = $this->encrypt_message(json_encode($send_array), $use_key_remote);
576
577 $message = array(
578 'format' => $this->format,
579 'key_name' => $this->key_name_indicator,
580 'udrpc_message' => $send_data,
581 );
582
583 if ($this->format >= 2) {
584 $signature = $this->signature_for_message($send_data, $use_key_local);
585 $message['signature'] = $signature;
586 }
587
588 return $message;
589
590 }
591
592 /**
593 * N.B. There's already some time-based replay protection. This can be turned on to beef it up.
594 * This is only for listeners. Replays can only be detection if transients are working on the WP site (which by default only means that the option table is working).
595 *
596 * @param boolean $activate
597 */
598 public function activate_replay_protection($activate = true) {
599 $this->extra_replay_protection = (bool) $activate;
600 }
601
602 public function set_next_send_sequence_id($id) {
603 $this->next_send_sequence_id = $id;
604 }
605
606 /**
607 * Set_http_credentials
608 *
609 * @param string $credentials should be an array with entries for 'username' and 'password'
610 */
611 public function set_http_credentials($credentials) {
612 $this->http_credentials = $credentials;
613 }
614
615 /**
616 * This needs only to return an array with keys body and response - where response is also an array, with key 'code' (the HTTP status code)
617 * The $post_options array support these keys: timeout, body,
618 * Public, to allow short-circuiting of the library's own encoding/decoding (e.g. for acting as a proxy for a message already encrypted elsewhere)
619 *
620 * @param array $post_options
621 * @return array
622 */
623 public function http_post($post_options) {
624 global $wp_version;
625 include ABSPATH.WPINC.'/version.php';
626 $http_credentials = $this->http_credentials;
627
628 if (is_a($this->http_transport, 'GuzzleHttp\Client')) {
629
630 // https://guzzle.readthedocs.org/en/5.3/clients.html
631
632 $client = $this->http_transport;
633
634 $guzzle_options = array(
635 'form_params' => $post_options['body'],
636 'headers' => array(
637 'User-Agent' => 'WordPress/'.$wp_version.'; class-udrpc.php-Guzzle/'.$this->version.'; '.get_bloginfo('url'),
638 ),
639 'exceptions' => false,
640 'timeout' => $post_options['timeout'],
641 );
642
643 if (!class_exists('WP_HTTP_Proxy')) include_once ABSPATH.WPINC.'/class-http.php';
644 $proxy = new WP_HTTP_Proxy();
645 if ($proxy->is_enabled()) {
646 $user = $proxy->username();
647 $pass = $proxy->password();
648 $host = $proxy->host();
649 $port = (int) $proxy->port();
650 if (empty($port)) $port = 8080;
651 if (!empty($host) && $proxy->send_through_proxy($this->destination_url)) {
652 $proxy_auth = '';
653 if (!empty($user)) {
654 $proxy_auth = $user;
655 if (!empty($pass)) $proxy_auth .= ':'.$pass;
656 $proxy_auth .= '@';
657 }
658 $guzzle_options['proxy'] = array(
659 'http' => "http://{$proxy_auth}$host:$port",
660 'https' => "http://{$proxy_auth}$host:$port",
661 );
662 }
663 }
664
665 if (defined('UDRPC_GUZZLE_SSL_VERIFY')) {
666 $verify = UDRPC_GUZZLE_SSL_VERIFY;
667 } elseif (file_exists(ABSPATH.WPINC.'/certificates/ca-bundle.crt')) {
668 $verify = ABSPATH.WPINC.'/certificates/ca-bundle.crt';
669 } else {
670 $verify = true;
671 }
672
673 $guzzle_options['verify'] = apply_filters('udrpc_guzzle_verify', $verify);
674
675 if (!empty($http_credentials['username'])) {
676
677 $authentication_method = empty($http_credentials['authentication_method']) ? 'basic' : $http_credentials['authentication_method'];
678
679 $password = empty($http_credentials['password']) ? '' : $http_credentials['password'];
680
681 $guzzle_options['auth'] = array(
682 $http_credentials['username'],
683 $password,
684 $authentication_method,
685 );
686
687 }
688
689 $response = $client->post($this->destination_url, apply_filters('udrpc_guzzle_options', $guzzle_options, $this));
690
691 $formatted_response = array(
692 'response' => array(
693 'code' => $response->getStatusCode(),
694 ),
695 'body' => $response->getBody(),
696 );
697
698 return $formatted_response;
699
700 } else {
701
702 $post_options['user-agent'] = 'WordPress/'.$wp_version.'; class-udrpc.php/'.$this->version.'; '.get_bloginfo('url');
703
704 if (!empty($http_credentials['username'])) {
705
706 $authentication_type = empty($http_credentials['authentication_type']) ? 'basic' : $http_credentials['authentication_type'];
707
708 if ('basic' != $authentication_type) {
709 return new WP_Error('unsupported_http_authentication_type', 'Only HTTP basic authentication is supported (for other types, use Guzzle)');
710 }
711
712 $password = empty($http_credentials['password']) ? '' : $http_credentials['password'];
713 $post_options['headers'] = array(
714 'Authorization' => 'Basic '.base64_encode($http_credentials['username'].':'.$password),
715 );
716 }
717
718 return wp_remote_post(
719 $this->destination_url,
720 $post_options
721 );
722 }
723 }
724
725 public function send_message($command, $data = null, $timeout = 20) {
726
727 if (empty($this->destination_url)) return new WP_Error('not_initialised', 'RPC error: URL not initialised');
728
729 $message = $this->create_message($command, $data);
730
731 $post_options = array(
732 'timeout' => $timeout,
733 'body' => $message,
734 );
735
736 $post_options = apply_filters('udrpc_post_options', $post_options, $command, $data, $timeout, $this);
737
738 // Make the memory available - may be useful if the message was large
739 unset($data);
740
741 try {
742 $post = $this->http_post($post_options);
743 } catch (Exception $e) {
744 // Curl can return an error code 0, which causes WP_Error to return early, without recording the message. So, we prefix the code.
745 return new WP_Error('http_post_'.$e->getCode(), $e->getMessage());
746 }
747
748 if (is_wp_error($post)) return $post;
749
750 $response_code = wp_remote_retrieve_response_code($post);
751
752 if (empty($response_code)) return new WP_Error('empty_http_code', 'Unexpected HTTP response code');
753
754 if ($response_code < 200 || $response_code >= 300) return new WP_Error('unexpected_http_code', 'Unexpected HTTP response code ('.$response_code.')', $post);
755
756 $response_body = wp_remote_retrieve_body($post);
757
758 if (empty($response_body)) return new WP_Error('empty_response', 'Empty response from remote site');
759
760 $decoded = json_decode($response_body, true);
761
762 if (empty($decoded)) {
763
764 if (false != ($found_at = strpos($response_body, '{"format":'))) {
765 $new_body = substr($response_body, $found_at);
766 $decoded = json_decode($new_body, true);
767 }
768
769 if (empty($decoded)) {
770 $this->log('response from remote site ('.$this->destination_url.') could not be understood: '.substr($response_body, 0, 100).' ... ', 'info');
771 return new WP_Error('response_not_understood', 'Response from remote site could not be understood', $response_body);
772 }
773 }
774
775 if (!is_array($decoded) || empty($decoded['udrpc_message'])) return new WP_Error('response_not_understood', 'Response from remote site was not in the expected format ('.$post['body'].')', $decoded);
776
777 if ($this->format >= 2) {
778 if (empty($decoded['signature'])) {
779 $this->log('No message signature found');
780 die;
781 }
782 if (!$this->key_remote) {
783 $this->log('No signature verification key has been set');
784 die;
785 }
786 if (!$this->verify_signature($decoded['udrpc_message'], $decoded['signature'], $this->key_remote)) {
787 $this->log('Signature verification failed; discarding');
788 die;
789 }
790 }
791
792 $decoded = $this->decrypt_message($decoded['udrpc_message']);
793
794 if (!is_string($decoded)) return new WP_Error('not_decrypted', 'Response from remote site was not successfully decrypted', $decoded['udrpc_message']);
795
796 $json_decoded = json_decode($decoded, true);
797
798 if (!is_array($json_decoded) || empty($json_decoded['response']) || empty($json_decoded['time']) || !is_numeric($json_decoded['time'])) return new WP_Error('response_corrupt', 'Response from remote site was not in the expected format', $decoded);
799
800 // Don't do the reply detection until now, because $post['body'] may not be a message that originated from the remote component at all (e.g. an HTTP error)
801 if ($this->extra_replay_protection) {
802 $message_hash = $this->calculate_message_hash((string) $post['body']);
803 if ($this->message_hash_seen($message_hash)) {
804 return new WP_Error('replay_detected', 'Message refused: replay detected', $message_hash);
805 }
806 }
807
808 $time_difference = absint((time() - $json_decoded['time']));
809 if ($time_difference > $this->maximum_replay_time_difference) return new WP_Error('window_error', 'Message refused: maxium replay time difference exceeded', $time_difference);
810
811 if (isset($json_decoded['incoming_rand']) && !empty($this->message_random_number) && $json_decoded['incoming_rand'] != $this->message_random_number) {
812 // @codingStandardsIgnoreLine
813 $this->log('UDRPC: Message mismatch (possibly MITM) (sent_rand=' + $this->message_random_number + ', returned_rand='.$json_decoded['incoming_rand'].'): dropping', 'error');
814
815 return new WP_Error('message_mismatch_error', 'Message refused: message mismatch (possible MITM)');
816
817 }
818
819 // Should be an array with keys including 'response' and (if relevant) 'data'
820 return $json_decoded;
821
822 }
823
824 /**
825 * Returns a boolean indicating whether a listener was created - which depends on whether one was needed (so, false does not necessarily indicate an error condition)
826 *
827 * @return boolean
828 */
829 public function create_listener() {
830
831 $http_origin = function_exists('get_http_origin') ? get_http_origin() : (empty($_SERVER['HTTP_ORIGIN']) ? '' : $_SERVER['HTTP_ORIGIN']);
832
833 // Create the WP actions to handle incoming commands, handle built-in commands (e.g. ping, create_keys (authenticate with admin creds)), dispatch them to the right place, and die
834 if ((!empty($_POST) && !empty($_POST['udrpc_message']) && !empty($_POST['format'])) || (!empty($_SERVER['REQUEST_METHOD']) && 'OPTIONS' == $_SERVER['REQUEST_METHOD'] && $http_origin)) {
835 add_action('wp_loaded', array($this, 'wp_loaded'));
836 add_action('wp_loaded', array($this, 'wp_loaded_final'), 10000);
837 return true;
838 }
839
840 return false;
841 }
842
843 public function wp_loaded_final() {
844 if (empty($this->require_message_to_be_understood)) return;
845 $message_for = empty($_POST['key_name']) ? '' : (string) $_POST['key_name'];
846 $this->log("Message was received, but not understood by local site (for: $message_for)");
847 die;
848 }
849
850 public function wp_loaded() {
851
852 /*
853 // What if something else already set some response headers?
854 if (function_exists('apache_response_headers')) {
855 $apache_response_headers = apache_response_headers();
856 // Do something...
857 }
858 */
859
860 // CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
861 // get_http_origin() : since WP 3.4
862 $http_origin = function_exists('get_http_origin') ? get_http_origin() : (empty($_SERVER['HTTP_ORIGIN']) ? '' : $_SERVER['HTTP_ORIGIN']);
863 if (!empty($_SERVER['REQUEST_METHOD']) && 'OPTIONS' == $_SERVER['REQUEST_METHOD'] && $http_origin) {
864 if (in_array($http_origin, $this->allow_cors_from)) {
865 // @codingStandardsIgnoreLine
866 if (!defined('UDRPC_DO_NOT_SEND_CORS_HEADERS') || !UDRPC_DO_NOT_SEND_CORS_HEADERS) {
867 header("Access-Control-Allow-Origin: $http_origin");
868 header('Access-Control-Allow-Credentials: true');
869 if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) header('Access-Control-Allow-Methods: POST, OPTIONS');
870 if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) header('Access-Control-Allow-Headers: '.$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']);
871 }
872 die;
873 } elseif ($this->debug) {
874 $this->log('Non-allowed CORS from: '.$http_origin);
875 }
876 // Having detected that this is a CORS request, there's nothing more to do. We return, because a different listener might pick it up, even though we didn't.
877 return;
878 }
879
880 // Silently return, rather than dying, in case another instance is able to handle this
881 if (empty($_POST['format']) || (1 != $_POST['format'] && 2 != $_POST['format'])) return;
882
883 $this->require_message_to_be_understood = true;
884
885 $format = $_POST['format'];
886
887 /*
888 In format 1 (legacy/obsolete), the one encrypts (the shared AES key) using one half of the key-pair, and decrypts with the other; whereas the other side of the conversation does the reverse when replying (and uses a different shared AES key). Though this is possible in RSA, this is the wrong thing to do - see https://crypto.stackexchange.com/questions/2123/rsa-encryption-with-private-key-and-decryption-with-a-public-key
889 In format 2, both sides have their own private and public key. The sender encrypts using the other side's public key, and decrypts using its own private key. Messages are signed (the message digest is SHA-256).
890 */
891
892 // Is this for us?
893 if (empty($_POST['key_name']) || $_POST['key_name'] != $this->key_name_indicator) {
894 return;
895 }
896
897 // wp_unslash() does not exist until after WP 3.5
898 // $udrpc_message = function_exists('wp_unslash') ? wp_unslash($_POST['udrpc_message']) : stripslashes_deep($_POST['udrpc_message']);
899
900 // Data should not have any slashes - it is base64-encoded
901 $udrpc_message = (string) $_POST['udrpc_message'];
902
903 // Check this now, rather than allow the decrypt method to thrown an Exception
904
905 if (empty($this->key_local)) {
906 $this->log('no local key (format 1): cannot decrypt', 'error');
907 die;
908 }
909
910 if ($format >= 2) {
911 if (empty($_POST['signature'])) {
912 $this->log('No message signature found', 'error');
913 die;
914 }
915 if (!$this->key_remote) {
916 $this->log('No signature verification key has been set', 'error');
917 die;
918 }
919 if (!$this->verify_signature($udrpc_message, $_POST['signature'], $this->key_remote)) {
920 $this->log('Signature verification failed; discarding', 'error');
921 die;
922 }
923 }
924
925 try {
926 $udrpc_message = $this->decrypt_message($udrpc_message);
927 } catch (Exception $e) {
928 $this->log('Exception ('.get_class($e).'): '.$e->getMessage(), 'error');
929 die;
930 }
931
932 $udrpc_message = json_decode($udrpc_message, true);
933
934 if (empty($udrpc_message) || !is_array($udrpc_message) || empty($udrpc_message['command']) || !is_string($udrpc_message['command'])) {
935 $this->log('Could not decode JSON on incoming message', 'error');
936 die;
937 }
938
939 if (empty($udrpc_message['time'])) {
940 $this->log('No time set in incoming message', 'error');
941 die;
942 }
943
944 // Mismatch indicating a replay of the message with a different key name in the unencrypted portion?
945 if (empty($udrpc_message['key_name']) || $_POST['key_name'] != $udrpc_message['key_name']) {
946 $this->log('key_name mismatch between encrypted and unencrypted portions', 'error');
947 die;
948 }
949
950 if ($this->extra_replay_protection) {
951 $message_hash = $this->calculate_message_hash((string) $_POST['udrpc_message']);
952 if ($this->message_hash_seen($message_hash)) {
953 $this->log("Message dropped: apparently a replay (hash: $message_hash)", 'error');
954 die;
955 }
956 }
957
958 // Do this after the extra replay protection, as that checks hashes within the maximum time window - so don't check the maximum time window until afterwards, to avoid a tiny window (race) in between.
959 $time_difference = absint($udrpc_message['time'] - time());
960 if ($time_difference > $this->maximum_replay_time_difference) {
961 $this->log("Time in incoming message is outside of allowed window ($time_difference > ".$this->maximum_replay_time_difference.')', 'error');
962 die;
963 }
964
965 // The sequence number should always be larger than any previously-sent sequence number
966 if ($this->sequence_protection_tolerance) {
967
968 if ($this->debug) $this->log('Sequence protection is active; tolerance: '.$this->sequence_protection_tolerance);
969
970 global $wpdb;
971
972 if (!isset($udrpc_message['sequence_id']) || !is_numeric($udrpc_message['sequence_id'])) {
973 $this->log('a numerical sequence number is required, but none was included in the message - dropping', 'error');
974 die;
975 }
976
977 $message_sequence_id = (int) $udrpc_message['sequence_id'];
978 $recently_seen_sequences_ids = $wpdb->get_var($wpdb->prepare('SELECT %s FROM %s LIMIT 1 WHERE '.$this->sequence_protection_where_sql, $this->sequence_protection_column, $this->sequence_protection_table));
979
980 if ('' === $recently_seen_sequences_ids) $recently_seen_sequences_ids = '0';
981
982 $recently_seen_sequences_ids_as_array = explode($recently_seen_sequences_ids, ',');
983 sort($recently_seen_sequences_ids_as_array);
984
985 // Seen before?
986 if (in_array($message_sequence_id, $recently_seen_sequences_ids_as_array)) {
987 $this->log("message with duplicate sequence number received - dropping (received=$message_sequence_id, seen=$recently_seen_sequences_ids)");
988 die;
989 }
990
991 // Within the tolerance threshold? That means: a) either bigger than the max, or b) no more than <tolerance> lower than the least
992 if ($message_sequence_id > max($recently_seen_sequences_ids)) {
993 if ($this->debug) $this->log("Sequence id ($message_sequence_id) is greater than any previous (".max($recently_seen_sequences_ids).') - message is thus OK');
994 // All is well
995 $recently_seen_sequences_ids_as_array[] = $message_sequence_id;
996 } elseif ((max($recently_seen_sequences_ids) - $message_sequence_id) <= $this->sequence_protection_tolerance) {
997 // All is well - was one of those 'missing' in the sequence
998 if ($this->debug) $this->log("Sequence id ($message_sequence_id) is within tolerance range of previous maximum (".max($recently_seen_sequences_ids).') - message is thus OK');
999 $recently_seen_sequences_ids_as_array[] = $message_sequence_id;
1000 } else {
1001 $this->log("message received outside of allowed sequence window - dropping (received=$message_sequence_id, seen=$recently_seen_sequences_ids, tolerance=".$this->sequence_protection_tolerance.')', 'error');
1002 die;
1003 }
1004
1005 // Remove out-of-bounds seen IDs
1006 $max_sequence_id_seen = max($recently_seen_sequences_ids_as_array);
1007 foreach ($recently_seen_sequences_ids_as_array as $k => $id) {
1008 if ($max_sequence_id_seen - $id > $this->sequence_protection_tolerance) {
1009 if ($this->debug) $this->log("Removing no-longer-relevant sequence from list of those recently seen: $id");
1010 unset($recently_seen_sequences_ids_as_array[$k]);
1011 }
1012 }
1013
1014 // Allow reset
1015 if ($message_sequence_id > PHP_INT_MAX - 10) {
1016 $recently_seen_sequences_ids_as_array = array(0);
1017 }
1018
1019 // Write them back to the database
1020 $sql = $wpdb->prepare('UPDATE %s SET %s=%s WHERE '.$this->sequence_protection_where_sql, $this->sequence_protection_table, $this->sequence_protection_column, implode(',', $recently_seen_sequences_ids_as_array));
1021 if ($this->debug) $this->log("SQL to send recent sequence IDs back to the database: $sql");
1022 $wpdb->query($sql);
1023
1024 }
1025
1026 $this->incoming_message = $udrpc_message;
1027
1028 $command = (string) $udrpc_message['command'];
1029 $data = empty($udrpc_message['data']) ? null : $udrpc_message['data'];
1030
1031 // @codingStandardsIgnoreLine
1032 if ($http_origin && !empty($udrpc_message['cors_headers_wanted']) && (!defined('UDRPC_DO_NOT_SEND_CORS_HEADERS') || !UDRPC_DO_NOT_SEND_CORS_HEADERS)) {
1033 header("Access-Control-Allow-Origin: $http_origin");
1034 header('Access-Control-Allow-Credentials: true');
1035 }
1036
1037 $this->log('Command received: '.$command, 'info');
1038
1039 if ('ping' == $command) {
1040 $response = array('response' => 'pong', 'data' => null);
1041 } else {
1042 if (has_filter('udrpc_command_'.$command)) {
1043 $response = apply_filters('udrpc_command_'.$command, null, $data, $this->key_name_indicator);
1044 } else {
1045 $response = array('response' => 'rpcerror', 'data' => array('code' => 'unknown_rpc_command', 'data' => $command));
1046 }
1047 }
1048
1049 $response = apply_filters('udrpc_action', $response, $command, $data, $this->key_name_indicator, $this);
1050
1051 if (is_array($response)) {
1052
1053 if ($this->debug) {
1054 $this->log('UDRPC response (pre-encoding/encryption): '.serialize($response));
1055 }
1056
1057 $data = isset($response['data']) ? $response['data'] : null;
1058
1059 $final_response = json_encode($this->create_message($response['response'], $data, true));
1060
1061 do_action('udrpc_action_send_response', $final_response, $command);
1062
1063 echo $final_response;
1064 }
1065
1066 die;
1067
1068 }
1069
1070 /**
1071 * The hash needs to be in a format that phpseclib likes. phpseclib uses lower case.
1072 * Pass in a base64-encoded signature (i.e. just as signature_for_message creates)
1073 *
1074 * @param string $message
1075 * @param string $signature
1076 * @param string $key
1077 * @param string $hash_algorithm
1078 * @return boolean
1079 */
1080 public function verify_signature($message, $signature, $key, $hash_algorithm = 'sha256') {
1081 $this->ensure_crypto_loaded();
1082 $rsa = new Crypt_RSA();
1083 $rsa->setHash(strtolower($hash_algorithm));
1084 // This is not the default, but is what we use
1085 $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
1086 $rsa->loadKey($key);
1087
1088 // Don't hash it - Crypt_RSA::verify() already does that
1089 // $hash = new Crypt_Hash($hash_algorithm);
1090 // $hashed = $hash->hash($message);
1091
1092 $verified = $rsa->verify($message, base64_decode($signature));
1093
1094 if ($this->debug) $this->log('Signature verification result: '.serialize($verified));
1095
1096 return $verified;
1097 }
1098
1099 private function calculate_message_hash($message) {
1100 return hash('sha256', $message);
1101 }
1102
1103 private function message_hash_seen($message_hash) {
1104 // 39 characters - less than the WP site transient name limit (40). Though, we use a normal transient, as these don't auto-load at all times.
1105 $transient_name = 'udrpch_'.md5($this->key_name_indicator);
1106 $seen_hashes = get_transient($transient_name);
1107 if (!is_array($seen_hashes)) $seen_hashes = array();
1108 $time_now = time();
1109 // $any_changes = false;
1110 // Prune the old hashes
1111 foreach ($seen_hashes as $hash => $last_seen) {
1112 if ($last_seen < ($time_now - $this->maximum_replay_time_difference)) {
1113 // $any_changes = true;
1114 unset($seen_hashes[$hash]);
1115 }
1116 }
1117 if (isset($seen_hashes[$message_hash])) {
1118 return true;
1119 }
1120 $seen_hashes[$message_hash] = $time_now;
1121 set_transient($transient_name, $seen_hashes, $this->maximum_replay_time_difference);
1122
1123 return false;
1124 }
1125 }
1126
1127 endif;
1128