Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 311 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace core;
  18  
  19  use advanced_testcase;
  20  
  21  /**
  22   * Test encryption.
  23   *
  24   * @package core
  25   * @copyright 2020 The Open University
  26   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  27   * @covers  \core\encryption
  28   */
  29  class encryption_test extends advanced_testcase {
  30  
  31      /**
  32       * Clear junk created by tests.
  33       */
  34      protected function tearDown(): void {
  35          global $CFG;
  36          $keyfile = encryption::get_key_file(encryption::METHOD_OPENSSL);
  37          if (file_exists($keyfile)) {
  38              chmod($keyfile, 0700);
  39          }
  40          $keyfile = encryption::get_key_file(encryption::METHOD_SODIUM);
  41          if (file_exists($keyfile)) {
  42              chmod($keyfile, 0700);
  43          }
  44          remove_dir($CFG->dataroot . '/secret');
  45          unset($CFG->nokeygeneration);
  46      }
  47  
  48      protected function setUp(): void {
  49          $this->tearDown();
  50  
  51          require_once (__DIR__ . '/fixtures/testable_encryption.php');
  52      }
  53  
  54      /**
  55       * Many of the tests work with both encryption methods.
  56       *
  57       * @return array[] Array of method options for test
  58       */
  59      public function encryption_method_provider(): array {
  60          return [
  61              'Sodium' => [encryption::METHOD_SODIUM],
  62          ];
  63      }
  64  
  65      /**
  66       * Tests the create_keys and get_key functions.
  67       *
  68       * @param string $method Encryption method
  69       * @dataProvider encryption_method_provider
  70       */
  71      public function test_create_key(string $method): void {
  72          encryption::create_key($method);
  73          $key = testable_encryption::get_key($method);
  74          $this->assertEquals(32, strlen($key));
  75  
  76          $this->expectExceptionMessage('Key already exists');
  77          encryption::create_key($method);
  78      }
  79  
  80      /**
  81       * Test that we can create keys for legacy {@see encryption::METHOD_OPENSSL} content
  82       */
  83      public function test_create_key_openssl(): void {
  84          encryption::create_key(encryption::METHOD_OPENSSL);
  85          $key = testable_encryption::get_key(encryption::METHOD_OPENSSL);
  86          $this->assertEquals(32, strlen($key));
  87  
  88          $this->expectExceptionMessage('Key already exists');
  89          encryption::create_key(encryption::METHOD_OPENSSL);
  90      }
  91  
  92      /**
  93       * Tests encryption and decryption with empty strings.
  94       */
  95      public function test_encrypt_and_decrypt_empty(): void {
  96          $this->assertEquals('', encryption::encrypt(''));
  97          $this->assertEquals('', encryption::decrypt(''));
  98      }
  99  
 100      /**
 101       * Tests encryption when the keys weren't created yet.
 102       *
 103       * @param string $method Encryption method
 104       * @dataProvider encryption_method_provider
 105       */
 106      public function test_encrypt_nokeys(string $method): void {
 107          global $CFG;
 108  
 109          // Prevent automatic generation of keys.
 110          $CFG->nokeygeneration = true;
 111          $this->expectExceptionMessage('Key not found');
 112          encryption::encrypt('frogs', $method);
 113      }
 114  
 115      /**
 116       * Test that attempting to encrypt with legacy {@see encryption::METHOD_OPENSSL} method falls back to Sodium
 117       */
 118      public function test_encrypt_openssl(): void {
 119          $encrypted = encryption::encrypt('Frogs', encryption::METHOD_OPENSSL);
 120          $this->assertStringStartsWith(encryption::METHOD_SODIUM . ':', $encrypted);
 121          $this->assertDebuggingCalledCount(1, ['Encryption using legacy OpenSSL is deprecated, reverting to Sodium']);
 122      }
 123  
 124      /**
 125       * Tests decryption when the data has a different encryption method
 126       */
 127      public function test_decrypt_wrongmethod(): void {
 128          $this->expectExceptionMessage('Data does not match a supported encryption method');
 129          encryption::decrypt('FAKE-CIPHER-METHOD:xx');
 130      }
 131  
 132      /**
 133       * Tests decryption when not enough data is supplied to get the IV and some data.
 134       *
 135       * @dataProvider encryption_method_provider
 136       * @param string $method Encryption method
 137       */
 138      public function test_decrypt_tooshort(string $method): void {
 139  
 140          $this->expectExceptionMessage('Insufficient data');
 141          switch ($method) {
 142              case encryption::METHOD_OPENSSL:
 143                  // It needs min 49 bytes (16 bytes IV + 32 bytes HMAC + 1 byte data).
 144                  $justtooshort = '0123456789abcdef0123456789abcdef0123456789abcdef';
 145                  break;
 146              case encryption::METHOD_SODIUM:
 147                  // Sodium needs 25 bytes at least as far as our code is concerned (24 bytes IV + 1
 148                  // byte data); it splits out any authentication hashes itself.
 149                  $justtooshort = '0123456789abcdef01234567';
 150                  break;
 151          }
 152  
 153          encryption::decrypt($method . ':' .base64_encode($justtooshort));
 154      }
 155  
 156      /**
 157       * Tests decryption when data is not valid base64.
 158       *
 159       * @dataProvider encryption_method_provider
 160       * @param string $method Encryption method
 161       */
 162      public function test_decrypt_notbase64(string $method): void {
 163          $this->expectExceptionMessage('Invalid base64 data');
 164          encryption::decrypt($method . ':' . chr(160));
 165      }
 166  
 167      /**
 168       * Tests decryption when the keys weren't created yet.
 169       *
 170       * @dataProvider encryption_method_provider
 171       * @param string $method Encryption method
 172       */
 173      public function test_decrypt_nokeys(string $method): void {
 174          global $CFG;
 175  
 176          // Prevent automatic generation of keys.
 177          $CFG->nokeygeneration = true;
 178          $this->expectExceptionMessage('Key not found');
 179          encryption::decrypt($method . ':' . base64_encode(
 180                  '0123456789abcdef0123456789abcdef0123456789abcdef0'));
 181      }
 182  
 183      /**
 184       * Test that we can decrypt legacy {@see encryption::METHOD_OPENSSL} content
 185       */
 186      public function test_decrypt_openssl(): void {
 187          $key = testable_encryption::get_key(encryption::METHOD_OPENSSL);
 188  
 189          // Construct encrypted string using openssl method/cipher.
 190          $iv = random_bytes(openssl_cipher_iv_length(encryption::OPENSSL_CIPHER));
 191          $encrypted = @openssl_encrypt('Frogs', encryption::OPENSSL_CIPHER, $key, OPENSSL_RAW_DATA, $iv);
 192          $hmac = hash_hmac('sha256', $iv . $encrypted, $key, true);
 193  
 194          $decrypted = encryption::decrypt(encryption::METHOD_OPENSSL . ':' . base64_encode($iv . $encrypted . $hmac));
 195          $this->assertEquals('Frogs', $decrypted);
 196          $this->assertDebuggingCalledCount(1, ['Decryption using legacy OpenSSL is deprecated, please upgrade to Sodium']);
 197      }
 198  
 199      /**
 200       * Test automatic generation of keys when needed.
 201       *
 202       * @dataProvider encryption_method_provider
 203       * @param string $method Encryption method
 204       */
 205      public function test_auto_key_generation(string $method): void {
 206  
 207          // Allow automatic generation (default).
 208          $encrypted = encryption::encrypt('frogs', $method);
 209          $this->assertEquals('frogs', encryption::decrypt($encrypted));
 210      }
 211  
 212      /**
 213       * Checks that invalid key causes failures.
 214       *
 215       * @dataProvider encryption_method_provider
 216       * @param string $method Encryption method
 217       */
 218      public function test_invalid_key(string $method): void {
 219          global $CFG;
 220  
 221          // Set the key to something bogus.
 222          $folder = $CFG->dataroot . '/secret/key';
 223          check_dir_exists($folder);
 224          file_put_contents(encryption::get_key_file($method), 'silly');
 225  
 226          switch ($method) {
 227              case encryption::METHOD_SODIUM:
 228                  $this->expectExceptionMessageMatches('/(should|must) be SODIUM_CRYPTO_SECRETBOX_KEYBYTES bytes/');
 229                  break;
 230  
 231              case encryption::METHOD_OPENSSL:
 232                  $this->expectExceptionMessage('Invalid key');
 233                  break;
 234          }
 235          encryption::encrypt('frogs', $method);
 236      }
 237  
 238      /**
 239       * Checks that modified data causes failures.
 240       *
 241       * @dataProvider encryption_method_provider
 242       * @param string $method Encryption method
 243       */
 244      public function test_modified_data(string $method): void {
 245  
 246          $encrypted = encryption::encrypt('frogs', $method);
 247          $mainbit = base64_decode(substr($encrypted, strlen($method) + 1));
 248          $mainbit = substr($mainbit, 0, 16) . 'X' . substr($mainbit, 16);
 249          $encrypted = $method . ':' . base64_encode($mainbit);
 250          $this->expectExceptionMessage('Integrity check failed');
 251          encryption::decrypt($encrypted);
 252      }
 253  
 254      /**
 255       * Tests encryption and decryption for real.
 256       *
 257       * @dataProvider encryption_method_provider
 258       * @param string $method Encryption method
 259       */
 260      public function test_encrypt_and_decrypt_realdata(string $method): void {
 261  
 262          // Encrypt short string.
 263          $encrypted = encryption::encrypt('frogs', $method);
 264          $this->assertNotEquals('frogs', $encrypted);
 265          $this->assertEquals('frogs', encryption::decrypt($encrypted));
 266  
 267          // Encrypt really long string (1 MB).
 268          $long = str_repeat('X', 1024 * 1024);
 269          $this->assertEquals($long, encryption::decrypt(encryption::encrypt($long, $method)));
 270      }
 271  }