Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 400 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  /**
  18   * Class used to encrypt or decrypt data.
  19   *
  20   * @package core
  21   * @copyright 2020 The Open University
  22   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core;
  26  
  27  /**
  28   * Class used to encrypt or decrypt data.
  29   *
  30   * @package core
  31   * @copyright 2020 The Open University
  32   * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  33   * @deprecated since Moodle 3.11 MDL-71420 - the openssl part of the class only.
  34   * @todo MDL-71421 Remove the openssl part in Moodle 4.2.
  35   */
  36  class encryption {
  37      /** @var string Encryption method: Sodium */
  38      const METHOD_SODIUM = 'sodium';
  39  
  40      // TODO: MDL-71421 - Remove the following openssl constants and all uses once sodium becomes a requirement in Moodle 4.2.
  41  
  42      /** @var string Encryption method: hand-coded OpenSSL (less safe) */
  43      const METHOD_OPENSSL = 'openssl-aes-256-ctr';
  44  
  45      /** @var string OpenSSL cipher method */
  46      const OPENSSL_CIPHER = 'AES-256-CTR';
  47  
  48      /**
  49       * Checks if Sodium is installed.
  50       *
  51       * @return bool True if the Sodium extension is available
  52       */
  53      public static function is_sodium_installed(): bool {
  54          return extension_loaded('sodium');
  55      }
  56  
  57      /**
  58       * Gets the encryption method to use. We use the Sodium extension if it is installed, or
  59       * otherwise, OpenSSL.
  60       *
  61       * @return string Current encryption method
  62       */
  63      protected static function get_encryption_method(): string {
  64          if (self::is_sodium_installed()) {
  65              return self::METHOD_SODIUM;
  66          } else {
  67              return self::METHOD_OPENSSL;
  68          }
  69      }
  70  
  71      /**
  72       * Creates a key for the server.
  73       *
  74       * @param string|null $method Encryption method (only if you want to create a non-default key)
  75       * @param bool $chmod If true, restricts the file access of the key
  76       * @throws \moodle_exception If the server already has a key, or there is an error
  77       */
  78      public static function create_key(?string $method = null, bool $chmod = true): void {
  79          if ($method === null) {
  80              $method = self::get_encryption_method();
  81          }
  82  
  83          if (self::key_exists($method)) {
  84              throw new \moodle_exception('encryption_keyalreadyexists', 'error');
  85          }
  86  
  87          // Don't make it read-only in Behat or it will fail to clear for future runs.
  88          if (defined('BEHAT_SITE_RUNNING')) {
  89              $chmod = false;
  90          }
  91  
  92          // Generate the key.
  93          switch ($method) {
  94              case self::METHOD_SODIUM:
  95                  $key = sodium_crypto_secretbox_keygen();
  96                  break;
  97              case self::METHOD_OPENSSL:
  98                  $key = openssl_random_pseudo_bytes(32);
  99                  break;
 100              default:
 101                  throw new \coding_exception('Unknown method: ' . $method);
 102          }
 103  
 104          // Store the key, making it readable only by server.
 105          $folder = self::get_key_folder();
 106          check_dir_exists($folder);
 107          $keyfile = self::get_key_file($method);
 108          file_put_contents($keyfile, $key);
 109          if ($chmod) {
 110              chmod($keyfile, 0400);
 111          }
 112      }
 113  
 114      /**
 115       * Gets the folder used to store the secret key.
 116       *
 117       * @return string Folder path
 118       */
 119      protected static function get_key_folder(): string {
 120          global $CFG;
 121          return ($CFG->secretdataroot ?? $CFG->dataroot . '/secret') . '/key';
 122      }
 123  
 124      /**
 125       * Gets the file path used to store the secret key. The filename contains the cipher method,
 126       * so that if necessary to transition in future it would be possible to have multiple.
 127       *
 128       * @param string|null $method Encryption method (only if you want to get a non-default key)
 129       * @return string Full path to file
 130       */
 131      public static function get_key_file(?string $method = null): string {
 132          if ($method === null) {
 133              $method = self::get_encryption_method();
 134          }
 135  
 136          return self::get_key_folder() . '/' . $method . '.key';
 137      }
 138  
 139      /**
 140       * Checks if there is a key file.
 141       *
 142       * @param string|null $method Encryption method (only if you want to check a non-default key)
 143       * @return bool True if there is a key file
 144       */
 145      public static function key_exists(?string $method = null): bool {
 146          if ($method === null) {
 147              $method = self::get_encryption_method();
 148          }
 149  
 150          return file_exists(self::get_key_file($method));
 151      }
 152  
 153      /**
 154       * Gets the current key, automatically creating it if there isn't one yet.
 155       *
 156       * @param string|null $method Encryption method (only if you want to get a non-default key)
 157       * @return string The key (binary)
 158       * @throws \moodle_exception If there isn't one already (and creation is disabled)
 159       */
 160      protected static function get_key(?string $method = null): string {
 161          global $CFG;
 162  
 163          if ($method === null) {
 164              $method = self::get_encryption_method();
 165          }
 166  
 167          $keyfile = self::get_key_file($method);
 168          if (!file_exists($keyfile) && empty($CFG->nokeygeneration)) {
 169              self::create_key($method);
 170          }
 171          $result = @file_get_contents($keyfile);
 172          if ($result === false) {
 173              throw new \moodle_exception('encryption_nokey', 'error');
 174          }
 175          return $result;
 176      }
 177  
 178      /**
 179       * Gets the length in bytes of the initial values data required.
 180       *
 181       * @param string $method Crypto method
 182       * @return int Length in bytes
 183       */
 184      protected static function get_iv_length(string $method): int {
 185          switch ($method) {
 186              case self::METHOD_SODIUM:
 187                  return SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
 188              case self::METHOD_OPENSSL:
 189                  return openssl_cipher_iv_length(self::OPENSSL_CIPHER);
 190              default:
 191                  throw new \coding_exception('Unknown method: ' . $method);
 192          }
 193      }
 194  
 195      /**
 196       * Encrypts data using the server's key.
 197       *
 198       * Note there is a special case - the empty string is not encrypted.
 199       *
 200       * @param string $data Data to encrypt, or empty string for no data
 201       * @param string|null $method Encryption method (only if you want to use a non-default method)
 202       * @return string Encrypted data, or empty string for no data
 203       * @throws \moodle_exception If the key doesn't exist, or the string is too long
 204       */
 205      public static function encrypt(string $data, ?string $method = null): string {
 206          if ($data === '') {
 207              return '';
 208          } else {
 209              if ($method === null) {
 210                  $method = self::get_encryption_method();
 211              }
 212  
 213              // Create IV.
 214              $iv = random_bytes(self::get_iv_length($method));
 215  
 216              // Encrypt data.
 217              switch($method) {
 218                  case self::METHOD_SODIUM:
 219                      try {
 220                          $encrypted = sodium_crypto_secretbox($data, $iv, self::get_key($method));
 221                      } catch (\SodiumException $e) {
 222                          throw new \moodle_exception('encryption_encryptfailed', 'error', '', null, $e->getMessage());
 223                      }
 224                      break;
 225  
 226                  case self::METHOD_OPENSSL:
 227                      // This may not be a secure authenticated encryption implementation;
 228                      // administrators should enable the Sodium extension.
 229                      $key = self::get_key($method);
 230                      if (strlen($key) !== 32) {
 231                              throw new \moodle_exception('encryption_invalidkey', 'error');
 232                      }
 233                      $encrypted = @openssl_encrypt($data, self::OPENSSL_CIPHER, $key, OPENSSL_RAW_DATA, $iv);
 234                      if ($encrypted === false) {
 235                          throw new \moodle_exception('encryption_encryptfailed', 'error',
 236                                  '', null, openssl_error_string());
 237                      }
 238                      $hmac = hash_hmac('sha256', $iv . $encrypted, $key, true);
 239                      $encrypted .= $hmac;
 240                      break;
 241  
 242                  default:
 243                      throw new \coding_exception('Unknown method: ' . $method);
 244              }
 245  
 246              // Encrypted data is cipher method plus IV plus encrypted data.
 247              return $method . ':' . base64_encode($iv . $encrypted);
 248          }
 249      }
 250  
 251      /**
 252       * Decrypts data using the server's key. The decryption works with either supported method.
 253       *
 254       * @param string $data Data to decrypt
 255       * @return string Decrypted data
 256       */
 257      public static function decrypt(string $data): string {
 258          if ($data === '') {
 259              return '';
 260          } else {
 261              if (preg_match('~^(' . self::METHOD_OPENSSL . '|' . self::METHOD_SODIUM . '):~', $data, $matches)) {
 262                  $method = $matches[1];
 263              } else {
 264                  throw new \moodle_exception('encryption_wrongmethod', 'error');
 265              }
 266              $realdata = base64_decode(substr($data, strlen($method) + 1), true);
 267              if ($realdata === false) {
 268                  throw new \moodle_exception('encryption_decryptfailed', 'error',
 269                          '', null, 'Invalid base64 data');
 270              }
 271  
 272              $ivlength = self::get_iv_length($method);
 273              if (strlen($realdata) < $ivlength + 1) {
 274                  throw new \moodle_exception('encryption_decryptfailed', 'error',
 275                          '', null, 'Insufficient data');
 276              }
 277              $iv = substr($realdata, 0, $ivlength);
 278              $encrypted = substr($realdata, $ivlength);
 279  
 280              switch ($method) {
 281                  case self::METHOD_SODIUM:
 282                      try {
 283                          $decrypted = sodium_crypto_secretbox_open($encrypted, $iv, self::get_key($method));
 284                      } catch (\SodiumException $e) {
 285                          throw new \moodle_exception('encryption_decryptfailed', 'error',
 286                                  '', null, $e->getMessage());
 287                      }
 288                      // Sodium returns false if decryption fails because data is invalid.
 289                      if ($decrypted === false) {
 290                          throw new \moodle_exception('encryption_decryptfailed', 'error',
 291                                  '', null, 'Integrity check failed');
 292                      }
 293                      break;
 294  
 295                  case self::METHOD_OPENSSL:
 296                      if (strlen($encrypted) < 33) {
 297                          throw new \moodle_exception('encryption_decryptfailed', 'error',
 298                                  '', null, 'Insufficient data');
 299                      }
 300                      $hmac = substr($encrypted, -32);
 301                      $encrypted = substr($encrypted, 0, -32);
 302                      $key = self::get_key($method);
 303                      $expectedhmac = hash_hmac('sha256', $iv . $encrypted, $key, true);
 304                      if ($hmac !== $expectedhmac) {
 305                          throw new \moodle_exception('encryption_decryptfailed', 'error',
 306                                  '', null, 'Integrity check failed');
 307                      }
 308  
 309                      $decrypted = @openssl_decrypt($encrypted, self::OPENSSL_CIPHER, $key, OPENSSL_RAW_DATA, $iv);
 310                      if ($decrypted === false) {
 311                          throw new \moodle_exception('encryption_decryptfailed', 'error',
 312                                  '', null, openssl_error_string());
 313                      }
 314                      break;
 315  
 316                  default:
 317                      throw new \coding_exception('Unknown method: ' . $method);
 318              }
 319  
 320              return $decrypted;
 321          }
 322      }
 323  }