diff --git a/backup/util/plan/restore_step.class.php b/backup/util/plan/restore_step.class.php index 92c840af58a..a0fb15b4886 100644 --- a/backup/util/plan/restore_step.class.php +++ b/backup/util/plan/restore_step.class.php @@ -94,6 +94,66 @@ abstract class restore_step extends base_step { // Return the passed value with cached offset applied. return $value + $cache[$this->get_restoreid()]; } + + /** + * Returns symmetric-key AES-256 decryption of base64 encoded contents. + * + * This method is used in restore operations to decrypt contents encrypted with + * {@link encrypted_final_element} automatically decoding (base64) and decrypting + * contents using the key stored in backup_encryptkey config. + * + * Requires openssl, cipher availability, and key existence (backup + * automatically sets it if missing). Integrity is provided via HMAC. + * + * @param string $value {@link encrypted_final_element} value to decode and decrypt. + * @return string|null decoded and decrypted value or null if the operation can not be performed. + */ + public function decrypt($value) { + + // No openssl available, skip this field completely. + if (!function_exists('openssl_encrypt')) { + return null; + } + + // No hash available, skip this field completely. + if (!function_exists('hash_hmac')) { + return null; + } + + // Cypher not available, skip this field completely. + if (!in_array(backup::CIPHER, openssl_get_cipher_methods())) { + return null; + } + + // Get the decrypt key. Skip if missing. + $key = get_config('backup', 'backup_encryptkey'); + if ($key === false) { + return null; + } + + // And decode it. + $key = base64_decode($key); + + // Arrived here, let's proceed with authentication (provides integrity). + $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($value))); + + // Verify HMAC matches expectations, skip if not (integrity failed). + if (!hash_equals($hmac, hash_hmac('sha256', $iv . $text, $key, true))) { + return null; + } + + // Arrived here, integrity is ok, let's decrypt. + $result = openssl_decrypt($text, backup::CIPHER, $key, OPENSSL_RAW_DATA, $iv); + + // For some reason decrypt failed (strange, HMAC check should have deteted it), skip this field completely. + if ($result === false) { + return null; + } + + return $result; + } } /* diff --git a/backup/util/plan/tests/step_test.php b/backup/util/plan/tests/step_test.php index 0b1ba6db556..0cc854b29bf 100644 --- a/backup/util/plan/tests/step_test.php +++ b/backup/util/plan/tests/step_test.php @@ -91,6 +91,52 @@ class backup_step_testcase extends advanced_testcase { $bc->destroy(); } + /** + * test restore_step class, decrypt method + */ + public function test_restore_step_decrypt() { + + $this->resetAfterTest(true); + + 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); + } + + $bt = new mock_restore_task_basepath('taskname'); + $bs = new mock_restore_structure_step('steptest', null, $bt); + $this->assertTrue(method_exists($bs, 'decrypt')); + + // Let's prepare a string for being decrypted. + $secret = 'This is a secret message that nobody else will be able to read but me 💩 '; + $key = hash('md5', 'Moodle rocks and this is not secure key, who cares, it is a test'); + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length(backup::CIPHER)); + $message = $iv . openssl_encrypt($secret, backup::CIPHER, $key, OPENSSL_RAW_DATA, $iv); + $hmac = hash_hmac('sha256', $message, $key, true); + $crypt = base64_encode($hmac . $message); + + // Running it without a key configured, returns null. + $this->assertNull($bs->decrypt($crypt)); + + // Store the key into config. + set_config('backup_encryptkey', base64_encode($key), 'backup'); + + // Verify decrypt works and returns original. + $this->assertSame($secret, $bs->decrypt($crypt)); + + // Finally, test the integrity failure detection is working. + // (this can be caused by changed hmac, key or message, in + // this case we are just forcing it via changed hmac). + $hmac = md5($message); + $crypt = base64_encode($hmac . $message); + $this->assertNull($bs->decrypt($crypt)); + } + /** * test backup_structure_step class */