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