PluginProbe
UpdraftCentral Dashboard / 0.8.13
UpdraftCentral Dashboard v0.8.13
0.8.33 0.7.2 0.7.3 0.7.4 0.8.0 0.8.1 0.8.10 0.8.11 0.8.12 0.8.13 0.8.14 0.8.15 0.8.16 0.8.17 0.8.18 0.8.19 0.8.2 0.8.20 0.8.21 0.8.22 0.8.23 0.8.24 0.8.25 0.8.26 0.8.27 All 51 releases
updraftcentral / classes / class-udrpc.php

class-udrpc.php in UpdraftCentral Dashboard 0.8.13, at classes/class-udrpc.php

1,112 lines 40.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.18';
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;
172 // phpseclib 1.x uses deprecated PHP4-style constructors
173 $this->no_deprecation_warnings_on_php7();
174 if (is_a($updraftplus, 'UpdraftPlus')) {
175 $ensure_phpseclib = $updraftplus->ensure_phpseclib(array('Crypt_Rijndael', 'Crypt_RSA', 'Crypt_Hash'), array('Crypt/Rijndael', 'Crypt/RSA', 'Crypt/Hash'));
176 if (is_wp_error($ensure_phpseclib)) return $ensure_phpseclib;
177 } elseif (defined('UPDRAFTPLUS_DIR') && file_exists(UPDRAFTPLUS_DIR.'/vendor/phpseclib/phpseclib/phpseclib')) {
178 $pdir = UPDRAFTPLUS_DIR.'/vendor/phpseclib/phpseclib/phpseclib';
179 if (false === strpos(get_include_path(), $pdir)) set_include_path($pdir.PATH_SEPARATOR.get_include_path());
180 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
181 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
182 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
183 } elseif (file_exists(dirname(dirname(__FILE__)).'/vendor/phpseclib/phpseclib/phpseclib')) {
184 $pdir = dirname(dirname(__FILE__)).'/vendor/phpseclib/phpseclib/phpseclib';
185 if (false === strpos(get_include_path(), $pdir)) set_include_path($pdir.PATH_SEPARATOR.get_include_path());
186 if (!class_exists('Crypt_Rijndael')) include_once 'Crypt/Rijndael.php';
187 if (!class_exists('Crypt_RSA')) include_once 'Crypt/RSA.php';
188 if (!class_exists('Crypt_Hash')) include_once 'Crypt/Hash.php';
189 }
190 }
191 }
192
193 /**
194 * Ugly, but necessary to prevent debug output breaking the conversation when the user has debug turned on
195 */
196 private function no_deprecation_warnings_on_php7() {
197 // PHP_MAJOR_VERSION is defined in PHP 5.2.7+
198 // 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).
199 // @codingStandardsIgnoreLine
200 if (defined('PHP_MAJOR_VERSION') && PHP_MAJOR_VERSION == 7) {
201 $old_level = error_reporting();
202 // @codingStandardsIgnoreLine
203 $new_level = $old_level & ~E_DEPRECATED;
204 if ($old_level != $new_level) error_reporting($new_level);
205 }
206 }
207
208 public function set_destination_url($destination_url) {
209 $this->destination_url = $destination_url;
210 }
211
212 public function get_destination_url() {
213 return $this->destination_url;
214 }
215
216 public function set_option_name($key_option_name) {
217 $this->key_option_name = $key_option_name;
218 }
219
220 /**
221 * Method to get the remote key
222 *
223 * @return string
224 */
225 public function get_key_remote() {
226 if (empty($this->key_remote) && $this->can_generate) {
227 $this->generate_new_keypair();
228 }
229
230 return empty($this->key_remote) ? false : $this->key_remote;
231 }
232
233 /**
234 * Set the remote key
235 *
236 * @param string $key_remote
237 */
238 public function set_key_remote($key_remote) {
239 $this->key_remote = $key_remote;
240 }
241
242 /**
243 * Used for sending - when receiving, the format is part of the message
244 *
245 * @param integer $format
246 */
247 public function set_message_format($format = 2) {
248 $this->format = $format;
249 }
250
251 /**
252 * Used for sending - when receiving, the format is part of the message
253 *
254 * @return integer
255 */
256 public function get_message_format() {
257 return $this->format;
258 }
259
260 /**
261 * Method to get the local key
262 *
263 * @return string
264 */
265 public function get_key_local() {
266 if (empty($this->key_local)) {
267 if ($this->key_option_name) {
268 $key_local = get_site_option($this->key_option_name);
269 if ($key_local) {
270 $this->key_local = $key_local;
271 }
272 }
273 }
274 if (empty($this->key_local) && $this->can_generate) {
275 $this->generate_new_keypair();
276 }
277
278 return empty($this->key_local) ? false : $this->key_local;
279 }
280
281 /**
282 * Tests whether a supplied string (after trimming) is a valid portable bundle
283 *
284 * @param string $bundle [description]
285 * @param string $format same as get_portable_bundle()
286 * @return array (which the consumer is free to use - e.g. convert into internationalised string), with keys 'code' and (perhaps) 'data'
287 */
288 public function decode_portable_bundle($bundle, $format = 'raw') {
289 $bundle = trim($bundle);
290 if ('base64_with_count' == $format) {
291 if (strlen($bundle) < 5) return array('code' => 'invalid_wrong_length', 'data' => 'too_short');
292 $len = substr($bundle, 0, 4);
293 $bundle = substr($bundle, 4);
294 $len = hexdec($len);
295 if (strlen($bundle) != $len) return array('code' => 'invalid_wrong_length', 'data' => "1,$len,".strlen($bundle));
296 if (false === ($bundle = base64_decode($bundle))) return array('code' => 'invalid_corrupt', 'data' => 'not_base64');
297 if (null === ($bundle = json_decode($bundle, true))) return array('code' => 'invalid_corrupt', 'data' => 'not_json');
298 }
299 if (empty($bundle['key'])) return array('code' => 'invalid_corrupt', 'data' => 'no_key');
300 if (empty($bundle['url'])) return array('code' => 'invalid_corrupt', 'data' => 'no_url');
301 if (empty($bundle['name_indicator'])) return array('code' => 'invalid_corrupt', 'data' => 'no_name_indicator');
302
303 return $bundle;
304 }
305
306 /**
307 * 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)
308 *
309 * @param string $format Supported formats: base64_with_count and default)raw
310 * @param array $extra_info needs to be JSON-serialisable, so be careful about what you put into it.
311 * @param array $options [description]
312 * @return array
313 */
314 public function get_portable_bundle($format = 'raw', $extra_info = array(), $options = array()) {
315
316 $bundle = array_merge($extra_info, array(
317 'key' => empty($options['key']) ? $this->get_key_remote() : $options['key'],
318 'name_indicator' => $this->key_name_indicator,
319 'url' => trailingslashit(network_site_url()),
320 'admin_url' => trailingslashit(admin_url()),
321 'network_admin_url' => trailingslashit(network_admin_url()),
322 'format_support' => 2,
323 ));
324
325 if ('base64_with_count' == $format) {
326 $bundle = base64_encode(json_encode($bundle));
327
328 $len = strlen($bundle); // Get the length
329 $len = dechex($len); // The first bytes of the message are the bundle length
330 $len = str_pad($len, 4, '0', STR_PAD_LEFT); // Zero pad
331
332 return $len.$bundle;
333
334 } else {
335 return $bundle;
336 }
337
338 }
339
340 public function set_key_local($key_local) {
341 $this->key_local = $key_local;
342 if ($this->key_option_name) update_site_option($this->key_option_name, $this->key_local);
343 }
344
345 public function generate_new_keypair($key_size = 2048) {
346
347 $this->ensure_crypto_loaded();
348
349 $rsa = new Crypt_RSA();
350 $keys = $rsa->createKey($key_size);
351
352 if (empty($keys['privatekey'])) {
353 $this->set_key_local(false);
354 } else {
355 $this->set_key_local($keys['privatekey']);
356 }
357
358 if (empty($keys['publickey'])) {
359 $this->set_key_remote(false);
360 } else {
361 $this->set_key_remote($keys['publickey']);
362 }
363
364 return empty($keys['publickey']) ? false : true;
365 }
366
367 /**
368 * A base-64 encoded RSA hash (PKCS_1) of the message digest
369 *
370 * @param string $message
371 * @param boolean $use_key
372 * @return array
373 */
374 public function signature_for_message($message, $use_key = false) {
375
376 $hash_algorithm = 'sha256';
377
378 // Sign with the private (local) key
379 if (!$use_key) {
380 if (!$this->key_local) throw new Exception('No signing key has been set');
381 $use_key = $this->key_local;
382 }
383
384 $this->ensure_crypto_loaded();
385
386 $rsa = new Crypt_RSA();
387 $rsa->loadKey($use_key);
388 // 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
389 $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
390
391 // Don't do this: Crypt_RSA::sign() already calculates the digest of the hash
392 // $hash = new Crypt_Hash($hash_algorithm);
393 // $hashed = $hash->hash($message);
394
395 // if ($this->debug) $this->log("Message hash (hash=$hash_algorithm) (hex): ".bin2hex($hashed));
396
397 // phpseclib defaults to SHA1
398 $rsa->setHash($hash_algorithm);
399 $encrypted = $rsa->sign($message);
400
401 if ($this->debug) $this->log('Signed hash (mode='.CRYPT_RSA_SIGNATURE_PKCS1.') (hex): '.bin2hex($encrypted));
402
403 $signature = base64_encode($encrypted);
404
405 if ($this->debug) $this->log("Message signature (base64): $signature");
406
407 return $signature;
408 }
409
410 /**
411 * Log description
412 *
413 * @param string $message
414 * @param string $level $level is not yet used much
415 */
416 private function log($message, $level = 'notice') {
417 // Allow other plugins to do something with the message
418 do_action('udrpc_log', $message, $level, $this->key_name_indicator, $this->debug, $this);
419 if ('info' != $level) error_log('UDRPC ('.$this->key_name_indicator.", $level): $message");
420 }
421
422 /**
423 * Encrypt the message, using the local key (which needs to exist)
424 *
425 * @param string $plaintext
426 * @param boolean $use_key
427 * @param integer $key_length
428 * @return array
429 */
430 public function encrypt_message($plaintext, $use_key = false, $key_length = 32) {
431
432 if (!$use_key) {
433 if (1 == $this->format) {
434 if (!$this->key_local) throw new Exception('No encryption key has been set');
435 $use_key = $this->key_local;
436 } else {
437 if (!$this->key_remote) throw new Exception('No encryption key has been set');
438 $use_key = $this->key_remote;
439 }
440 }
441
442 $this->ensure_crypto_loaded();
443
444 $rsa = new Crypt_RSA();
445
446 if (defined('UDRPC_PHPSECLIB_ENCRYPTION_MODE')) $rsa->setEncryptionMode(UDRPC_PHPSECLIB_ENCRYPTION_MODE);
447
448 $rij = new Crypt_Rijndael();
449
450 // Generate Random Symmetric Key
451 $sym_key = crypt_random_string($key_length);
452
453 if ($this->debug) $this->log('Unencrypted symmetric key (hex): '.bin2hex($sym_key));
454
455 // Encrypt Message with new Symmetric Key
456 $rij->setKey($sym_key);
457 $ciphertext = $rij->encrypt($plaintext);
458
459 if ($this->debug) $this->log('Encrypted ciphertext (hex): '.bin2hex($ciphertext));
460
461 $ciphertext = base64_encode($ciphertext);
462
463 // Encrypt the Symmetric Key with the Asymmetric Key
464 $rsa->loadKey($use_key);
465 $sym_key = $rsa->encrypt($sym_key);
466
467 if ($this->debug) $this->log('Encrypted symmetric key (hex): '.bin2hex($sym_key));
468
469 // Base 64 encode the symmetric key for transport
470 $sym_key = base64_encode($sym_key);
471
472 if ($this->debug) $this->log('Encrypted symmetric key (b64): '.$sym_key);
473
474 $len = str_pad(dechex(strlen($sym_key)), 3, '0', STR_PAD_LEFT); // Zero pad to be sure.
475
476 // 16 characters of hex is enough for the payload to be to 16 exabytes (giga < tera < peta < exa) of data
477 $cipherlen = str_pad(dechex(strlen($ciphertext)), 16, '0', STR_PAD_LEFT);
478
479 // Concatenate the length, the encrypted symmetric key, and the message
480 return $len.$sym_key.$cipherlen.$ciphertext;
481
482 }
483
484 /**
485 * Decrypt the message, using the local key (which needs to exist)
486 *
487 * @param string $message
488 * @return array
489 */
490 public function decrypt_message($message) {
491
492 if (!$this->key_local) throw new Exception('No decryption key has been set');
493
494 $this->ensure_crypto_loaded();
495
496 $rsa = new Crypt_RSA();
497 if (defined('UDRPC_PHPSECLIB_ENCRYPTION_MODE')) $rsa->setEncryptionMode(UDRPC_PHPSECLIB_ENCRYPTION_MODE);
498 // Defaults to CRYPT_AES_MODE_CBC
499 $rij = new Crypt_Rijndael();
500
501 // Extract the Symmetric Key
502 $len = substr($message, 0, 3);
503 $len = hexdec($len);
504 $sym_key = substr($message, 3, $len);
505
506 // Extract the encrypted message
507 $cipherlen = substr($message, ($len + 3), 16);
508 $cipherlen = hexdec($cipherlen);
509
510 $ciphertext = substr($message, ($len + 19), $cipherlen);
511 $ciphertext = base64_decode($ciphertext);
512
513 // Decrypt the encrypted symmetric key
514 $rsa->loadKey($this->key_local);
515 $sym_key = base64_decode($sym_key);
516 $sym_key = $rsa->decrypt($sym_key);
517
518 // Decrypt the message
519 $rij->setKey($sym_key);
520
521 return $rij->decrypt($ciphertext);
522
523 }
524
525 /**
526 * Creates a message
527 *
528 * @param string $command
529 * @param string $data
530 * @param boolean $is_response
531 * @param boolean $use_key_remote
532 * @param boolean $use_key_local
533 * @return array which the caller will then format as required (e.g. use as body in post, or JSON-encode, etc.) [description]
534 */
535 public function create_message($command, $data = null, $is_response = false, $use_key_remote = false, $use_key_local = false) {
536
537 if ($is_response) {
538 $send_array = array('response' => $command);
539 } else {
540 $send_array = array('command' => $command);
541 }
542
543 $send_array['time'] = time();
544 // This goes in the encrypted portion as well to prevent replays with a different unencrypted name indicator
545 $send_array['key_name'] = $this->key_name_indicator;
546
547 // 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
548 // The value of PHP_INT_MAX on a 32-bit platform
549 $this->message_random_number = rand(1, 2147483647);
550 $send_array['rand'] = $this->message_random_number;
551
552 if ($this->next_send_sequence_id) {
553 $send_array['sequence_id'] = $this->next_send_sequence_id;
554 ++$this->next_send_sequence_id;
555 }
556
557 if ($is_response && !empty($this->incoming_message) && isset($this->incoming_message['rand'])) {
558 $send_array['incoming_rand'] = $this->incoming_message['rand'];
559 }
560
561 if (null !== $data) $send_array['data'] = $data;
562 $send_data = $this->encrypt_message(json_encode($send_array), $use_key_remote);
563
564 $message = array(
565 'format' => $this->format,
566 'key_name' => $this->key_name_indicator,
567 'udrpc_message' => $send_data,
568 );
569
570 if ($this->format >= 2) {
571 $signature = $this->signature_for_message($send_data, $use_key_local);
572 $message['signature'] = $signature;
573 }
574
575 return $message;
576
577 }
578
579 /**
580 * N.B. There's already some time-based replay protection. This can be turned on to beef it up.
581 * 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).
582 *
583 * @param boolean $activate
584 */
585 public function activate_replay_protection($activate = true) {
586 $this->extra_replay_protection = (bool) $activate;
587 }
588
589 public function set_next_send_sequence_id($id) {
590 $this->next_send_sequence_id = $id;
591 }
592
593 /**
594 * Set_http_credentials
595 *
596 * @param string $credentials should be an array with entries for 'username' and 'password'
597 */
598 public function set_http_credentials($credentials) {
599 $this->http_credentials = $credentials;
600 }
601
602 /**
603 * 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)
604 * The $post_options array support these keys: timeout, body,
605 * 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)
606 *
607 * @param array $post_options
608 * @return array
609 */
610 public function http_post($post_options) {
611 // @codingStandardsIgnoreLine
612 @include ABSPATH.WPINC.'/version.php';
613 $http_credentials = $this->http_credentials;
614
615 if (is_a($this->http_transport, 'GuzzleHttp\Client')) {
616
617 // https://guzzle.readthedocs.org/en/5.3/clients.html
618
619 $client = $this->http_transport;
620
621 $guzzle_options = array(
622 'body' => $post_options['body'],
623 'headers' => array(
624 'User-Agent' => 'WordPress/'.$wp_version.'; class-udrpc.php-Guzzle/'.$this->version.'; '.get_bloginfo('url'),
625 ),
626 'exceptions' => false,
627 'timeout' => $post_options['timeout'],
628 );
629
630 if (!class_exists('WP_HTTP_Proxy')) include_once ABSPATH.WPINC.'/class-http.php';
631 $proxy = new WP_HTTP_Proxy();
632 if ($proxy->is_enabled()) {
633 $user = $proxy->username();
634 $pass = $proxy->password();
635 $host = $proxy->host();
636 $port = (int) $proxy->port();
637 if (empty($port)) $port = 8080;
638 if (!empty($host) && $proxy->send_through_proxy($this->destination_url)) {
639 $proxy_auth = '';
640 if (!empty($user)) {
641 $proxy_auth = $user;
642 if (!empty($pass)) $proxy_auth .= ':'.$pass;
643 $proxy_auth .= '@';
644 }
645 $guzzle_options['proxy'] = array(
646 'http' => "http://${proxy_auth}$host:$port",
647 'https' => "http://${proxy_auth}$host:$port",
648 );
649 }
650 }
651
652 if (defined('UDRPC_GUZZLE_SSL_VERIFY')) {
653 $verify = UDRPC_GUZZLE_SSL_VERIFY;
654 } elseif (file_exists(ABSPATH.WPINC.'/certificates/ca-bundle.crt')) {
655 $verify = ABSPATH.WPINC.'/certificates/ca-bundle.crt';
656 } else {
657 $verify = true;
658 }
659
660 $guzzle_options['verify'] = apply_filters('udrpc_guzzle_verify', $verify);
661
662 if (!empty($http_credentials['username'])) {
663
664 $authentication_method = empty($http_credentials['authentication_method']) ? 'basic' : $http_credentials['authentication_method'];
665
666 $password = empty($http_credentials['password']) ? '' : $http_credentials['password'];
667
668 $guzzle_options['auth'] = array(
669 $http_credentials['username'],
670 $password,
671 $authentication_method,
672 );
673
674 }
675
676 $response = $client->post($this->destination_url, apply_filters('udrpc_guzzle_options', $guzzle_options, $this));
677
678 $formatted_response = array(
679 'response' => array(
680 'code' => $response->getStatusCode(),
681 ),
682 'body' => $response->getBody(),
683 );
684
685 return $formatted_response;
686
687 } else {
688
689 $post_options['user-agent'] = 'WordPress/'.$wp_version.'; class-udrpc.php/'.$this->version.'; '.get_bloginfo('url');
690
691 if (!empty($http_credentials['username'])) {
692
693 $authentication_type = empty($http_credentials['authentication_type']) ? 'basic' : $http_credentials['authentication_type'];
694
695 if ('basic' != $authentication_type) {
696 return new WP_Error('unsupported_http_authentication_type', 'Only HTTP basic authentication is supported (for other types, use Guzzle)');
697 }
698
699 $password = empty($http_credentials['password']) ? '' : $http_credentials['password'];
700 $post_options['headers'] = array(
701 'Authorization' => 'Basic '.base64_encode($http_credentials['username'].':'.$password),
702 );
703 }
704
705 return wp_remote_post(
706 $this->destination_url,
707 $post_options
708 );
709 }
710 }
711
712 public function send_message($command, $data = null, $timeout = 20) {
713
714 if (empty($this->destination_url)) return new WP_Error('not_initialised', 'RPC error: URL not initialised');
715
716 $message = $this->create_message($command, $data);
717
718 $post_options = array(
719 'timeout' => $timeout,
720 'body' => $message,
721 );
722
723 $post_options = apply_filters('udrpc_post_options', $post_options, $command, $data, $timeout, $this);
724
725 // Make the memory available - may be useful if the message was large
726 unset($data);
727
728 try {
729 $post = $this->http_post($post_options);
730 } catch (Exception $e) {
731 // Curl can return an error code 0, which causes WP_Error to return early, without recording the message. So, we prefix the code.
732 return new WP_Error('http_post_'.$e->getCode(), $e->getMessage());
733 }
734
735 if (is_wp_error($post)) return $post;
736
737 $response_code = wp_remote_retrieve_response_code($post);
738
739 if (empty($response_code)) return new WP_Error('empty_http_code', 'Unexpected HTTP response code');
740
741 if ($response_code < 200 || $response_code >= 300) return new WP_Error('unexpected_http_code', 'Unexpected HTTP response code ('.$response_code.')', $post);
742
743 $response_body = wp_remote_retrieve_body($post);
744
745 if (empty($response_body)) return new WP_Error('empty_response', 'Empty response from remote site');
746
747 $decoded = json_decode($response_body, true);
748
749 if (empty($decoded)) {
750
751 if (false != ($found_at = strpos($response_body, '{"format":'))) {
752 $new_body = substr($response_body, $found_at);
753 $decoded = json_decode($new_body, true);
754 }
755
756 if (empty($decoded)) {
757 $this->log('response from remote site could not be understood: '.substr($response_body, 0, 100).' ... ');
758
759 return new WP_Error('response_not_understood', 'Response from remote site could not be understood', $response_body);
760 }
761 }
762
763 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);
764
765 if ($this->format >= 2) {
766 if (empty($decoded['signature'])) {
767 $this->log('No message signature found');
768 die;
769 }
770 if (!$this->key_remote) {
771 $this->log('No signature verification key has been set');
772 die;
773 }
774 if (!$this->verify_signature($decoded['udrpc_message'], $decoded['signature'], $this->key_remote)) {
775 $this->log('Signature verification failed; discarding');
776 die;
777 }
778 }
779
780 $decoded = $this->decrypt_message($decoded['udrpc_message']);
781
782 if (!is_string($decoded)) return new WP_Error('not_decrypted', 'Response from remote site was not successfully decrypted', $decoded['udrpc_message']);
783
784 $json_decoded = json_decode($decoded, true);
785
786 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);
787
788 // 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)
789 if ($this->extra_replay_protection) {
790 $message_hash = $this->calculate_message_hash((string) $post['body']);
791 if ($this->message_hash_seen($message_hash)) {
792 return new WP_Error('replay_detected', 'Message refused: replay detected', $message_hash);
793 }
794 }
795
796 $time_difference = absint((time() - $json_decoded['time']));
797 if ($time_difference > $this->maximum_replay_time_difference) return new WP_Error('window_error', 'Message refused: maxium replay time difference exceeded', $time_difference);
798
799 if (isset($json_decoded['incoming_rand']) && !empty($this->message_random_number) && $json_decoded['incoming_rand'] != $this->message_random_number) {
800 // @codingStandardsIgnoreLine
801 $this->log('UDRPC: Message mismatch (possibly MITM) (sent_rand=' + $this->message_random_number + ', returned_rand='.$json_decoded['incoming_rand'].'): dropping', 'error');
802
803 return new WP_Error('message_mismatch_error', 'Message refused: message mismatch (possible MITM)');
804
805 }
806
807 // Should be an array with keys including 'response' and (if relevant) 'data'
808 return $json_decoded;
809
810 }
811
812 /**
813 * 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)
814 *
815 * @return boolean
816 */
817 public function create_listener() {
818
819 $http_origin = function_exists('get_http_origin') ? get_http_origin() : (empty($_SERVER['HTTP_ORIGIN']) ? '' : $_SERVER['HTTP_ORIGIN']);
820
821 // 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
822 if ((!empty($_POST) && !empty($_POST['udrpc_message']) && !empty($_POST['format'])) || (!empty($_SERVER['REQUEST_METHOD']) && 'OPTIONS' == $_SERVER['REQUEST_METHOD'] && $http_origin)) {
823 add_action('wp_loaded', array($this, 'wp_loaded'));
824 add_action('wp_loaded', array($this, 'wp_loaded_final'), 10000);
825 return true;
826 }
827
828 return false;
829 }
830
831 public function wp_loaded_final() {
832 if (empty($this->require_message_to_be_understood)) return;
833 $message_for = empty($_POST['key_name']) ? '' : (string) $_POST['key_name'];
834 $this->log("Message was received, but not understood by local site (for: $message_for)");
835 die;
836 }
837
838 public function wp_loaded() {
839
840 /*
841 // What if something else already set some response headers?
842 if (function_exists('apache_response_headers')) {
843 $apache_response_headers = apache_response_headers();
844 // Do something...
845 }
846 */
847
848 // CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
849 // get_http_origin() : since WP 3.4
850 $http_origin = function_exists('get_http_origin') ? get_http_origin() : (empty($_SERVER['HTTP_ORIGIN']) ? '' : $_SERVER['HTTP_ORIGIN']);
851 if (!empty($_SERVER['REQUEST_METHOD']) && 'OPTIONS' == $_SERVER['REQUEST_METHOD'] && $http_origin) {
852 if (in_array($http_origin, $this->allow_cors_from)) {
853 // @codingStandardsIgnoreLine
854 if (!@constant('UDRPC_DO_NOT_SEND_CORS_HEADERS')) {
855 header("Access-Control-Allow-Origin: $http_origin");
856 header('Access-Control-Allow-Credentials: true');
857 if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) header('Access-Control-Allow-Methods: POST, OPTIONS');
858 if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) header('Access-Control-Allow-Headers: '.$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']);
859 }
860 die;
861 } elseif ($this->debug) {
862 $this->log('Non-allowed CORS from: '.$http_origin);
863 }
864 // 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.
865 return;
866 }
867
868 // Silently return, rather than dying, in case another instance is able to handle this
869 if (empty($_POST['format']) || (1 != $_POST['format'] && 2 != $_POST['format'])) return;
870
871 $this->require_message_to_be_understood = true;
872
873 $format = $_POST['format'];
874
875 /*
876 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
877 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).
878 */
879
880 // Is this for us?
881 if (empty($_POST['key_name']) || $_POST['key_name'] != $this->key_name_indicator) {
882 return;
883 }
884
885 // wp_unslash() does not exist until after WP 3.5
886 // $udrpc_message = function_exists('wp_unslash') ? wp_unslash($_POST['udrpc_message']) : stripslashes_deep($_POST['udrpc_message']);
887
888 // Data should not have any slashes - it is base64-encoded
889 $udrpc_message = (string) $_POST['udrpc_message'];
890
891 // Check this now, rather than allow the decrypt method to thrown an Exception
892
893 if (empty($this->key_local)) {
894 $this->log('no local key (format 1): cannot decrypt', 'error');
895 die;
896 }
897
898 if ($format >= 2) {
899 if (empty($_POST['signature'])) {
900 $this->log('No message signature found', 'error');
901 die;
902 }
903 if (!$this->key_remote) {
904 $this->log('No signature verification key has been set', 'error');
905 die;
906 }
907 if (!$this->verify_signature($udrpc_message, $_POST['signature'], $this->key_remote)) {
908 $this->log('Signature verification failed; discarding', 'error');
909 die;
910 }
911 }
912
913 try {
914 $udrpc_message = $this->decrypt_message($udrpc_message);
915 } catch (Exception $e) {
916 $this->log('Exception ('.get_class($e).'): '.$e->getMessage(), 'error');
917 die;
918 }
919
920 $udrpc_message = json_decode($udrpc_message, true);
921
922 if (empty($udrpc_message) || !is_array($udrpc_message) || empty($udrpc_message['command']) || !is_string($udrpc_message['command'])) {
923 $this->log('Could not decode JSON on incoming message', 'error');
924 die;
925 }
926
927 if (empty($udrpc_message['time'])) {
928 $this->log('No time set in incoming message', 'error');
929 die;
930 }
931
932 // Mismatch indicating a replay of the message with a different key name in the unencrypted portion?
933 if (empty($udrpc_message['key_name']) || $_POST['key_name'] != $udrpc_message['key_name']) {
934 $this->log('key_name mismatch between encrypted and unencrypted portions', 'error');
935 die;
936 }
937
938 if ($this->extra_replay_protection) {
939 $message_hash = $this->calculate_message_hash((string) $_POST['udrpc_message']);
940 if ($this->message_hash_seen($message_hash)) {
941 $this->log("Message dropped: apparently a replay (hash: $message_hash)", 'error');
942 die;
943 }
944 }
945
946 // 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.
947 $time_difference = absint($udrpc_message['time'] - time());
948 if ($time_difference > $this->maximum_replay_time_difference) {
949 $this->log("Time in incoming message is outside of allowed window ($time_difference > ".$this->maximum_replay_time_difference.')', 'error');
950 die;
951 }
952
953 // The sequence number should always be larger than any previously-sent sequence number
954 if ($this->sequence_protection_tolerance) {
955
956 if ($this->debug) $this->log('Sequence protection is active; tolerance: '.$this->sequence_protection_tolerance);
957
958 global $wpdb;
959
960 if (!isset($udrpc_message['sequence_id']) || !is_numeric($udrpc_message['sequence_id'])) {
961 $this->log('a numerical sequence number is required, but none was included in the message - dropping', 'error');
962 die;
963 }
964
965 $message_sequence_id = (int) $udrpc_message['sequence_id'];
966 $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));
967
968 if ('' === $recently_seen_sequences_ids) $recently_seen_sequences_ids = '0';
969
970 $recently_seen_sequences_ids_as_array = explode($recently_seen_sequences_ids, ',');
971 sort($recently_seen_sequences_ids_as_array);
972
973 // Seen before?
974 if (in_array($message_sequence_id, $recently_seen_sequences_ids_as_array)) {
975 $this->log("message with duplicate sequence number received - dropping (received=$message_sequence_id, seen=$recently_seen_sequences_ids)");
976 die;
977 }
978
979 // Within the tolerance threshold? That means: a) either bigger than the max, or b) no more than <tolerance> lower than the least
980 if ($message_sequence_id > max($recently_seen_sequences_ids)) {
981 if ($this->debug) $this->log("Sequence id ($message_sequence_id) is greater than any previous (".max($recently_seen_sequences_ids).') - message is thus OK');
982 // All is well
983 $recently_seen_sequences_ids_as_array[] = $message_sequence_id;
984 } elseif ((max($recently_seen_sequences_ids) - $message_sequence_id) <= $this->sequence_protection_tolerance) {
985 // All is well - was one of those 'missing' in the sequence
986 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');
987 $recently_seen_sequences_ids_as_array[] = $message_sequence_id;
988 } else {
989 $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');
990 die;
991 }
992
993 // Remove out-of-bounds seen IDs
994 $max_sequence_id_seen = max($recently_seen_sequences_ids_as_array);
995 foreach ($recently_seen_sequences_ids_as_array as $k => $id) {
996 if ($max_sequence_id_seen - $id > $this->sequence_protection_tolerance) {
997 if ($this->debug) $this->log("Removing no-longer-relevant sequence from list of those recently seen: $id");
998 unset($recently_seen_sequences_ids_as_array[$k]);
999 }
1000 }
1001
1002 // Allow reset
1003 if ($current_sequence_id > PHP_INT_MAX - 10) {
1004 $recently_seen_sequences_ids_as_array = array(0);
1005 }
1006
1007 // Write them back to the database
1008 $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));
1009 if ($this->debug) $this->log("SQL to send recent sequence IDs back to the database: $sql");
1010 $wpdb->query($sql);
1011
1012 }
1013
1014 $this->incoming_message = $udrpc_message;
1015
1016 $command = (string) $udrpc_message['command'];
1017 $data = empty($udrpc_message['data']) ? null : $udrpc_message['data'];
1018
1019 // @codingStandardsIgnoreLine
1020 if ($http_origin && !empty($udrpc_message['cors_headers_wanted']) && (!defined('UDRPC_DO_NOT_SEND_CORS_HEADERS') || !UDRPC_DO_NOT_SEND_CORS_HEADERS)) {
1021 header("Access-Control-Allow-Origin: $http_origin");
1022 header('Access-Control-Allow-Credentials: true');
1023 }
1024
1025 $this->log('Command received: '.$command, 'info');
1026
1027 if ('ping' == $command) {
1028 $response = array('response' => 'pong', 'data' => null);
1029 } else {
1030 if (has_filter('udrpc_command_'.$command)) {
1031 $command_action_hooked = true;
1032 $response = apply_filters('udrpc_command_'.$command, null, $data, $this->key_name_indicator);
1033 } else {
1034 $response = array('response' => 'rpcerror', 'data' => array('code' => 'unknown_rpc_command', 'data' => $command));
1035 }
1036 }
1037
1038 $response = apply_filters('udrpc_action', $response, $command, $data, $this->key_name_indicator, $this);
1039
1040 if (is_array($response)) {
1041
1042 if ($this->debug) {
1043 $this->log('UDRPC response (pre-encoding/encryption): '.serialize($response));
1044 }
1045
1046 $data = isset($response['data']) ? $response['data'] : null;
1047 echo json_encode($this->create_message($response['response'], $data, true));
1048 }
1049
1050 die;
1051
1052 }
1053
1054 /**
1055 * The hash needs to be in a format that phpseclib likes. phpseclib uses lower case.
1056 * Pass in a base64-encoded signature (i.e. just as signature_for_message creates)
1057 *
1058 * @param string $message
1059 * @param string $signature
1060 * @param string $key
1061 * @param string $hash_algorithm
1062 * @return boolean
1063 */
1064 public function verify_signature($message, $signature, $key, $hash_algorithm = 'sha256') {
1065 $this->ensure_crypto_loaded();
1066 $rsa = new Crypt_RSA();
1067 $rsa->setHash(strtolower($hash_algorithm));
1068 // This is not the default, but is what we use
1069 $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1);
1070 $rsa->loadKey($key);
1071
1072 // Don't hash it - Crypt_RSA::verify() already does that
1073 // $hash = new Crypt_Hash($hash_algorithm);
1074 // $hashed = $hash->hash($message);
1075
1076 $verified = $rsa->verify($message, base64_decode($signature));
1077
1078 if ($this->debug) $this->log('Signature verification result: '.serialize($verified));
1079
1080 return $verified;
1081 }
1082
1083 private function calculate_message_hash($message) {
1084 return hash('sha256', $message);
1085 }
1086
1087 private function message_hash_seen($message_hash) {
1088 // 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.
1089 $transient_name = 'udrpch_'.md5($this->key_name_indicator);
1090 $seen_hashes = get_transient($transient_name);
1091 if (!is_array($seen_hashes)) $seen_hashes = array();
1092 $time_now = time();
1093 // $any_changes = false;
1094 // Prune the old hashes
1095 foreach ($seen_hashes as $hash => $last_seen) {
1096 if ($last_seen < ($time_now - $this->maximum_replay_time_difference)) {
1097 // $any_changes = true;
1098 unset($seen_hashes[$hash]);
1099 }
1100 }
1101 if (isset($seen_hashes[$message_hash])) {
1102 return true;
1103 }
1104 $seen_hashes[$message_hash] = $time_now;
1105 set_transient($transient_name, $seen_hashes, $this->maximum_replay_time_difference);
1106
1107 return false;
1108 }
1109 }
1110
1111 endif;
1112