From d070c63237038620201c55045ace5ebb55949a4b Mon Sep 17 00:00:00 2001 From: "Eloy Lafuente (stronk7)" Date: Mon, 27 Jun 2016 02:32:09 +0200 Subject: [PATCH] MDL-36580 backup: General support for encrypted contents in backups - Built using standard backup custom fields. - Can be applied potentially everywhere. - Automatically addded 'encrypted' attribute. - Defaults to site generated key. - Enforces key robutness / provides authentication (hmac integrity) - Covered with unit tests. --- backup/backup.class.php | 9 ++ backup/moodle2/backup_custom_fields.php | 132 +++++++++++++++++++++ .../tests/backup_encrypted_content_test.php | 116 ++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 backup/moodle2/tests/backup_encrypted_content_test.php diff --git a/backup/backup.class.php b/backup/backup.class.php index ff2d38c2a75..9e5f2d27c70 100644 --- a/backup/backup.class.php +++ b/backup/backup.class.php @@ -141,6 +141,15 @@ abstract class backup implements checksumable { * Usually same than major release zero version, mainly for informative/historic purposes. */ const RELEASE = '3.3'; + + /** + * Cipher to be used in backup and restore operations. + */ + const CIPHER = 'aes-256-cbc'; + /** + * Bytes enforced for key, using the cypher above. Restrictive? Yes, but better than unsafe lengths + */ + const CIPHERKEYLEN = 32; } /* diff --git a/backup/moodle2/backup_custom_fields.php b/backup/moodle2/backup_custom_fields.php index b2170984735..9de5ce68067 100644 --- a/backup/moodle2/backup_custom_fields.php +++ b/backup/moodle2/backup_custom_fields.php @@ -98,6 +98,138 @@ class base64_encode_final_element extends backup_final_element { } /** + * Implementation of {@link backup_final_element} that provides symmetric-key AES-256 encryption of contents. + * + * This final element transparently encrypts, for secure storage and transport, any content + * that shouldn't be shown normally in plain text. Usually, passwords or keys that cannot use + * hashing algorithms, although potentially can encrypt any content. All information is encoded + * using base64. + * + * Features: + * - requires openssl extension to work. Without it contents are completely omitted. + * - automatically creates an appropriate default key for the site and stores it into backup_encryptkey config (bas64 encoded). + * - uses a different appropriate init vector for every operation, which is transmited with the encrypted contents. + * - all generated data is base64 encoded for safe transmission. + * - automatically adds "encrypted" attribute for easier detection. + * - implements HMAC for providing integrity. + * + * @copyright 2017 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class encrypted_final_element extends backup_final_element { + + /** @var string cypher appropiate raw key for backups in the site. Defaults to backup_encryptkey config. */ + protected $key = null; + + /** + * Constructor - instantiates a encrypted_final_element, specifying its basic info. + * + * Overridden to automatically add the 'encrypted' attribute if missing. + * + * @param string $name name of the element + * @param array $attributes attributes this element will handle (optional, defaults to null) + */ + public function __construct($name, $attributes = null) { + parent::__construct($name, $attributes); + if (! $this->get_attribute('encrypted')) { + $this->add_attributes('encrypted'); + } + } + + /** + * Set the encryption key manually, overriding default backup_encryptkey config. + * + * @param string $key key to be used for encrypting. Required to be 256-bit key. + * Use a safe generation technique. See self::generate_encryption_random_key() below. + */ + protected function set_key($key) { + $bytes = strlen($key); // Get key length in bytes. + + // Only accept keys with the expected (backup::CIPHERKEYLEN) key length. There are a number of hashing, + // random generators to achieve this esasily, like the one shown below to create the default + // site encryption key and ivs. + if ($bytes !== backup::CIPHERKEYLEN) { + $info = (object)array('expected' => backup::CIPHERKEYLEN, 'found' => $bytes); + throw new base_element_struct_exception('encrypted_final_element incorrect key length', $info); + } + // Everything went ok, store the key. + $this->key = $key; + } + + /** + * Set the value of the field. + * + * This method sets the value of the element, encrypted using the specified key for it, + * defaulting to (and generating) backup_encryptkey config. HMAC is used for integrity. + * + * @param string $value plain-text content the will be stored encrypted and encoded. + */ + public function set_value($value) { + + // No openssl available, skip this field completely. + if (!function_exists('openssl_encrypt')) { + return; + } + + // No hmac available, skip this field completely. + if (!function_exists('hash_hmac')) { + return; + } + + // Cypher not available, skip this field completely. + if (!in_array(backup::CIPHER, openssl_get_cipher_methods())) { + return; + } + + // Ensure we have a good key, manual or default. + if (empty($this->key)) { + // The key has not been set manually, look for it at config (base64 encoded there). + $enckey = get_config('backup', 'backup_encryptkey'); + if ($enckey === false) { + // Has not been set, calculate and save an appropiate random key automatically. + $enckey = base64_encode(self::generate_encryption_random_key(backup::CIPHERKEYLEN)); + set_config('backup_encryptkey', $enckey, 'backup'); + } + $this->set_key(base64_decode($enckey)); + } + + // Now we need an iv for this operation. + $iv = self::generate_encryption_random_key(openssl_cipher_iv_length(backup::CIPHER)); + + // Everything is ready, let's encrypt and prepend the 1-shot iv. + $value = $iv . openssl_encrypt($value, backup::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv); + + // Calculate the hmac of the value (iv + encrypted) and prepend it. + $hmac = hash_hmac('sha256', $value, $this->key, true); + $value = $hmac . $value; + + // Ready, set the encoded value. + parent::set_value(base64_encode($value)); + + // Finally, if the field has an "encrypted" attribute, set it to true. + if ($att = $this->get_attribute('encrypted')) { + $att->set_value('true'); + } + } + + /** + * Generate an appropiate random key to be used for encrypting backup information. + * + * Normally used as site default encryption key (backup_encryptkey config) and also + * for calculating the init vectors. + * + * Note that until PHP 5.6.12 openssl_random_pseudo_bytes() did NOT + * use a "cryptographically strong algorithm" {@link https://bugs.php.net/bug.php?id=70014} + * But it's beyond my crypto-knowledge when it's worth finding a *real* better alternative. + * + * @param int $bytes Number of bytes to determine the key length expected. + */ + protected static function generate_encryption_random_key($bytes) { + return openssl_random_pseudo_bytes($bytes); + } +} + +/** * Implementation of backup_nested_element that provides special handling of files * * This class overwrites the standard fill_values() method, so it gets intercepted diff --git a/backup/moodle2/tests/backup_encrypted_content_test.php b/backup/moodle2/tests/backup_encrypted_content_test.php new file mode 100644 index 00000000000..5a609c85d03 --- /dev/null +++ b/backup/moodle2/tests/backup_encrypted_content_test.php @@ -0,0 +1,116 @@ +. + +/** + * Tests for the handling of encrypted contents in backup and restore. + * + * @package core_backup + * @copyright 2016 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->dirroot . '/backup/moodle2/backup_custom_fields.php'); + +class core_backup_encrypted_content_testscase extends advanced_testcase { + + public function setUp() { + if (!function_exists('openssl_encrypt')) { + $this->markTestSkipped('OpenSSL extension is not loaded.'); + + } else if (!function_exists('hash_hmac')) { + $this->markTestSkipped('Hash extension is not loaded.'); + + } else if (!in_array(backup::CIPHER, openssl_get_cipher_methods())) { + $this->markTestSkipped('Expected cipher not available: ' . backup::CIPHER); + } + } + + public function test_encrypted_final_element() { + + $this->resetAfterTest(true); + + // Some basic verifications. + $efe = new encrypted_final_element('test', array('encrypted')); + $this->assertInstanceOf('encrypted_final_element', $efe); + $this->assertSame('test', $efe->get_name()); + $atts = $efe->get_attributes(); + $this->assertCount(1, $atts); + $att = reset($atts); + $this->assertInstanceOf('backup_attribute', $att); + $this->assertSame('encrypted', $att->get_name()); + + // Using a manually defined (incorrect length) key. + $efe = new encrypted_final_element('test', array('encrypted')); + $key = 'this_in_not_correct_32_byte_key'; + try { + set_config('backup_encryptkey', base64_encode($key), 'backup'); + $efe->set_value('tiny_secret'); + $this->fail('Expecting base_element_struct_exception exception, none happened'); + } catch (exception $e) { + $this->assertInstanceOf('base_element_struct_exception', $e); + $this->assertEquals('encrypted_final_element incorrect key length', $e->errorcode); + + } + + // Using a manually defined (correct length) key. + $efe = new encrypted_final_element('test', array('testattr', 'encrypted')); + $key = hash('md5', 'Moodle rocks and this is not secure key, who cares, it is a test'); + set_config('backup_encryptkey', base64_encode($key), 'backup'); + $this->assertEmpty($efe->get_value()); + $secret = 'This is a secret message that nobody else will be able to read but me 💩 '; + $efe->set_value($secret); + $atts = $efe->get_attributes(); + $this->assertCount(2, $atts); + $this->assertArrayHasKey('encrypted', $atts); // We added it explicitly. + $this->assertTrue($atts['encrypted']->is_set()); + $this->assertSame('true', $atts['encrypted']->get_value()); + $this->assertNotEmpty($efe->get_value()); + $this->assertTrue($efe->is_set()); + // Get the crypted content and decrypt it manually. + $ctext = $efe->get_value(); + $hmaclen = 32; // SHA256 is 32 bytes. + $ivlen = openssl_cipher_iv_length(backup::CIPHER); + list($hmac, $iv, $text) = array_values(unpack("a{$hmaclen}hmac/a{$ivlen}iv/a*text", base64_decode($ctext))); + $this->assertSame(hash_hmac('sha256', $iv . $text, $key, true), $hmac); + $this->assertSame($secret, openssl_decrypt($text, backup::CIPHER, $key, OPENSSL_RAW_DATA, $iv)); + + // Using the default site-generated key. + $efe = new encrypted_final_element('test', array('testattr')); + $this->assertEmpty($efe->get_value()); + $secret = 'This is a secret message that nobody else will be able to read but me 💩 '; + $efe->set_value($secret); + $atts = $efe->get_attributes(); + $this->assertCount(2, $atts); + $this->assertArrayHasKey('encrypted', $atts); // Was added automatcally, we did not specify it. + $this->assertTrue($atts['encrypted']->is_set()); + $this->assertSame('true', $atts['encrypted']->get_value()); + $this->assertNotEmpty($efe->get_value()); + $this->assertTrue($efe->is_set()); + // Get the crypted content and decrypt it manually. + $ctext = $efe->get_value(); + $hmaclen = 32; // SHA256 is 32 bytes. + $ivlen = openssl_cipher_iv_length(backup::CIPHER); + list($hmac, $iv, $text) = array_values(unpack("a{$hmaclen}hmac/a{$ivlen}iv/a*text", base64_decode($ctext))); + $key = base64_decode(get_config('backup', 'backup_encryptkey')); + $this->assertSame(hash_hmac('sha256', $iv . $text, $key, true), $hmac); + $this->assertSame($secret, openssl_decrypt($text, backup::CIPHER, $key, OPENSSL_RAW_DATA, $iv)); + } +} -- 2.11.4.GIT