MDL-36580 backup: General support for decrypted contents in restore
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 17 Oct 2017 22:40:54 +0000 (00:40 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 19 Oct 2017 08:11:47 +0000 (10:11 +0200)
- Uses site generated (on backup) key.
- Can be applied potentially everywhere in the restore process.
- Covered with unit tests.
- Authentication / integrity aware so can be used between any 2 servers
  (just requires matching key).

backup/util/plan/restore_step.class.php
backup/util/plan/tests/step_test.php

index da9a103..18e77ef 100644 (file)
@@ -96,6 +96,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;
+    }
 }
 
 /*
index 0b1ba6d..0cc854b 100644 (file)
@@ -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
      */