Commit ddbafce0 authored by sam marshall's avatar sam marshall
Browse files

MDL-65818 Security: Encryption API and admin setting for secure data

parent 7fa836cf
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Generates a secure key for the current server (presuming it does not already exist).
*
* @package core_admin
* @copyright 2020 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use \core\encryption;
define('CLI_SCRIPT', true);
require(__DIR__ . '/../../config.php');
require_once($CFG->libdir . '/clilib.php');
// Get cli options.
[$options, $unrecognized] = cli_get_params(
['help' => false, 'method' => null],
['h' => 'help']);
if ($unrecognized) {
$unrecognized = implode("\n ", $unrecognized);
cli_error(get_string('cliunknowoption', 'admin', $unrecognized));
}
if ($options['help']) {
echo "Generate secure key
This script manually creates a secure key within the secret data root folder (configured in
config.php as \$CFG->secretdataroot). You must run it using an account with access to write
to that folder.
In normal use Moodle automatically creates the key; this script is intended when setting up
a new Moodle system, for cases where the secure folder is not on shared storage and the key
may be manually installed on multiple servers.
Options:
-h, --help Print out this help
--method <method> Generate key for specified encryption method instead of default.
* sodium
* openssl-aes-256-ctr
Example:
php admin/cli/generate_key.php
";
exit;
}
$method = $options['method'];
if (encryption::key_exists($method)) {
echo 'Key already exists: ' . encryption::get_key_file($method) . "\n";
exit;
}
// Creates key with default permissions (no chmod).
echo "Generating key...\n";
encryption::create_key($method, false);
echo "\nKey created: " . encryption::get_key_file($method) . "\n\n";
echo "If the key folder is not shared storage, then key files should be copied to all servers.\n";
{{!
This file is part of Moodle - http://moodle.org/
Moodle is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Moodle is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
@template core_admin/admin_setting_encryptedpassword
Admin encrypted password template.
Context variables required for this template:
* name - form element name
* set - whether it is set or empty
* id - element id
Example context (json):
{
"name": "test",
"id": "test0",
"set": true
}
}}
<div class="core_admin_encryptedpassword" data-encryptedpasswordid="{{ id }}"
{{#novalue}}data-novalue="y"{{/novalue}}>
{{#set}}
<span>{{# str }} encryptedpassword_set, admin {{/ str }}</span>
{{/set}}
{{^set}}
<a href="#" title="{{# str }} encryptedpassword_edit, admin {{/ str }}">
<span>{{# str }} novalueclicktoset, form {{/ str }}</span>
{{# pix }} t/passwordunmask-edit, core, {{# str }} passwordunmaskedithint, form {{/ str }}{{/ pix }}
</a>
{{/set}}
<input style="display: none" type="password" name="{{name}}" disabled>
{{!
Using buttons instead of links here allows them to be connected to the label, so the button
works if you click the label.
}}
{{#set}}
<button type="button" id="{{id}}" title="{{# str }} encryptedpassword_edit, admin {{/ str }}" class="btn btn-link" data-editbutton>
{{# pix }} t/passwordunmask-edit, core, {{/ pix }}
</button>
{{/set}}
<button type="button" style="display: none" title="{{# str }} cancel {{/ str }}" class="btn btn-link" data-cancelbutton>
<i class="icon fa fa-times"></i>
</button>
</div>
{{#js}}
require(['core_form/encryptedpassword'], function(encryptedpassword) {
new encryptedpassword.EncryptedPassword("{{ id }}");
});
{{/js}}
......@@ -89,7 +89,7 @@ class behat_admin extends behat_base {
}
/**
* Sets the specified site settings. A table with | config | value | (optional)plugin | is expected.
* Sets the specified site settings. A table with | config | value | (optional)plugin | (optional)encrypted | is expected.
*
* @Given /^the following config values are set as admin:$/
* @param TableNode $table
......@@ -103,11 +103,20 @@ class behat_admin extends behat_base {
foreach ($data as $config => $value) {
// Default plugin value is null.
$plugin = null;
$encrypted = false;
if (is_array($value)) {
$plugin = $value[1];
if (array_key_exists(2, $value)) {
$encrypted = $value[2] === 'encrypted';
}
$value = $value[0];
}
if ($encrypted) {
$value = \core\encryption::encrypt($value);
}
set_config($config, $value, $plugin);
}
}
......
This files describes API changes in /admin/*.
=== 3.11 ===
* New admin setting admin_setting_encryptedpassword allows passwords in admin settings to be
encrypted (with the new \core\encryption API) so that even the admin cannot read them.
=== 3.9 ===
* The following functions, previously used (exclusively) by upgrade steps are not available anymore because of the upgrade cleanup performed for this version. See MDL-65809 for more info:
......
......@@ -727,6 +727,22 @@ $CFG->admin = 'admin';
//
// $CFG->maxcoursesincategory = 10000;
//
// Admin setting encryption
//
// $CFG->secretdataroot = '/var/www/my_secret_folder';
//
// Location to store encryption keys. By default this is $CFG->dataroot/secret; set this if
// you want to use a different location for increased security (e.g. if too many people have access
// to the main dataroot, or if you want to avoid using shared storage). Your web server user needs
// read access to this location, and write access unless you manually create the keys.
//
// $CFG->nokeygeneration = false;
//
// If you change this to true then the server will give an error if keys don't exist, instead of
// automatically generating them. This is only needed if you want to ensure that keys are consistent
// across a cluster when not using shared storage. If you stop the server generating keys, you will
// need to manually generate them by running 'php admin/cli/generate_key.php'.
//=========================================================================
// 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
//=========================================================================
......
......@@ -575,6 +575,8 @@ $string['enableuserfeedback'] = 'Enable feedback about this software';
$string['enableuserfeedback_desc'] = 'If enabled, a \'Give feedback about this software\' link is displayed in the footer for users to give feedback about the Moodle software to Moodle HQ. If the \'Next feedback reminder\' option is set, the user is also shown a reminder on the Dashboard at the specified interval. Setting \'Next feedback reminder\' to \'Never\' disables the Dashboard reminder, while leaving the \'Give feedback about this software\' link in the footer.';
$string['enablewebservices'] = 'Enable web services';
$string['enablewsdocumentation'] = 'Web services documentation';
$string['encryptedpassword_set'] = '(Set and encrypted)';
$string['encryptedpassword_edit'] = 'Enter new value';
$string['enrolinstancedefaults'] = 'Enrolment instance defaults';
$string['enrolinstancedefaults_desc'] = 'Default enrolment settings in new courses.';
$string['enrolmultipleusers'] = 'Enrol the users';
......
......@@ -236,6 +236,12 @@ $string['duplicaterolename'] = 'There is already a role with this name!';
$string['duplicateroleshortname'] = 'There is already a role with this short name!';
$string['duplicateusername'] = 'Duplicate username - skipping record';
$string['emailfail'] = 'Emailing failed';
$string['encryption_encryptfailed'] = 'Encryption failed';
$string['encryption_decryptfailed'] = 'Decryption failed';
$string['encryption_invalidkey'] = 'Invalid key';
$string['encryption_keyalreadyexists'] = 'Key already exists';
$string['encryption_nokey'] = 'Key not found';
$string['encryption_wrongmethod'] = 'Data does not match a supported encryption method';
$string['enddatebeforestartdate'] = 'The course end date must be after the start date.';
$string['error'] = 'Error occurred';
$string['error_question_answers_missing_in_db'] = 'Failed to find an answer matching "{$a->answer}" in the question_answers database table. This occurred while restoring the question with id {$a->filequestionid} in the backup file, which has been matched to the existing question with id {$a->dbquestionid} in the database.';
......
......@@ -2724,6 +2724,58 @@ class admin_setting_configpasswordunmask_with_advanced extends admin_setting_con
}
}
/**
* Admin setting class for encrypted values using secure encryption.
*
* @copyright 2019 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class admin_setting_encryptedpassword extends admin_setting {
/**
* Constructor. Same as parent except that the default value is always an empty string.
*
* @param string $name Internal name used in config table
* @param string $visiblename Name shown on form
* @param string $description Description that appears below field
*/
public function __construct(string $name, string $visiblename, string $description) {
parent::__construct($name, $visiblename, $description, '');
}
public function get_setting() {
return $this->config_read($this->name);
}
public function write_setting($data) {
$data = trim($data);
if ($data === '') {
// Value can really be set to nothing.
$savedata = '';
} else {
// Encrypt value before saving it.
$savedata = \core\encryption::encrypt($data);
}
return ($this->config_write($this->name, $savedata) ? '' : get_string('errorsetting', 'admin'));
}
public function output_html($data, $query='') {
global $OUTPUT;
$default = $this->get_defaultsetting();
$context = (object) [
'id' => $this->get_id(),
'name' => $this->get_full_name(),
'set' => $data !== '',
'novalue' => $this->get_setting() === null
];
$element = $OUTPUT->render_from_template('core_admin/setting_encryptedpassword', $context);
return format_admin_setting($this, $this->visiblename, $element, $this->description,
true, '', $default, $query);
}
}
/**
* Empty setting used to allow flags (advanced) on settings that can have no sensible default.
* Note: Only advanced makes sense right now - locked does not.
......
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Class used to encrypt or decrypt data.
*
* @package core
* @copyright 2020 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core;
/**
* Class used to encrypt or decrypt data.
*
* @package core
* @copyright 2020 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class encryption {
/** @var string Encryption method: Sodium */
const METHOD_SODIUM = 'sodium';
/** @var string Encryption method: hand-coded OpenSSL (less safe) */
const METHOD_OPENSSL = 'openssl-aes-256-ctr';
/** @var string OpenSSL cipher method */
const OPENSSL_CIPHER = 'AES-256-CTR';
/**
* Checks if Sodium is installed.
*
* @return bool True if the Sodium extension is available
*/
public static function is_sodium_installed(): bool {
return extension_loaded('sodium');
}
/**
* Gets the encryption method to use. We use the Sodium extension if it is installed, or
* otherwise, OpenSSL.
*
* @return string Current encryption method
*/
protected static function get_encryption_method(): string {
if (self::is_sodium_installed()) {
return self::METHOD_SODIUM;
} else {
return self::METHOD_OPENSSL;
}
}
/**
* Creates a key for the server.
*
* @param string|null $method Encryption method (only if you want to create a non-default key)
* @param bool $chmod If true, restricts the file access of the key
* @throws \moodle_exception If the server already has a key, or there is an error
*/
public static function create_key(?string $method = null, bool $chmod = true): void {
if ($method === null) {
$method = self::get_encryption_method();
}
if (self::key_exists($method)) {
throw new \moodle_exception('encryption_keyalreadyexists', 'error');
}
// Don't make it read-only in Behat or it will fail to clear for future runs.
if (defined('BEHAT_SITE_RUNNING')) {
$chmod = false;
}
// Generate the key.
switch ($method) {
case self::METHOD_SODIUM:
$key = sodium_crypto_secretbox_keygen();
break;
case self::METHOD_OPENSSL:
$key = openssl_random_pseudo_bytes(32);
break;
default:
throw new \coding_exception('Unknown method: ' . $method);
}
// Store the key, making it readable only by server.
$folder = self::get_key_folder();
check_dir_exists($folder);
$keyfile = self::get_key_file($method);
file_put_contents($keyfile, $key);
if ($chmod) {
chmod($keyfile, 0400);
}
}
/**
* Gets the folder used to store the secret key.
*
* @return string Folder path
*/
protected static function get_key_folder(): string {
global $CFG;
return ($CFG->secretdataroot ?? $CFG->dataroot . '/secret') . '/key';
}
/**
* Gets the file path used to store the secret key. The filename contains the cipher method,
* so that if necessary to transition in future it would be possible to have multiple.
*
* @param string|null $method Encryption method (only if you want to get a non-default key)
* @return string Full path to file
*/
public static function get_key_file(?string $method = null): string {
if ($method === null) {
$method = self::get_encryption_method();
}
return self::get_key_folder() . '/' . $method . '.key';
}
/**
* Checks if there is a key file.
*
* @param string|null $method Encryption method (only if you want to check a non-default key)
* @return bool True if there is a key file
*/
public static function key_exists(?string $method = null): bool {
if ($method === null) {
$method = self::get_encryption_method();
}
return file_exists(self::get_key_file($method));
}
/**
* Gets the current key, automatically creating it if there isn't one yet.
*
* @param string|null $method Encryption method (only if you want to get a non-default key)
* @return string The key (binary)
* @throws \moodle_exception If there isn't one already (and creation is disabled)
*/
protected static function get_key(?string $method = null): string {
global $CFG;
if ($method === null) {
$method = self::get_encryption_method();
}
$keyfile = self::get_key_file($method);
if (!file_exists($keyfile) && empty($CFG->nokeygeneration)) {
self::create_key($method);
}
$result = @file_get_contents($keyfile);
if ($result === false) {
throw new \moodle_exception('encryption_nokey', 'error');
}
return $result;
}
/**
* Gets the length in bytes of the initial values data required.
*
* @param string $method Crypto method
* @return int Length in bytes
*/
protected static function get_iv_length(string $method): int {
switch ($method) {
case self::METHOD_SODIUM:
return SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
case self::METHOD_OPENSSL:
return openssl_cipher_iv_length(self::OPENSSL_CIPHER);
default:
throw new \coding_exception('Unknown method: ' . $method);
}
}
/**
* Encrypts data using the server's key.
*
* Note there is a special case - the empty string is not encrypted.
*
* @param string $data Data to encrypt, or empty string for no data
* @param string|null $method Encryption method (only if you want to use a non-default method)
* @return string Encrypted data, or empty string for no data
* @throws \moodle_exception If the key doesn't exist, or the string is too long
*/
public static function encrypt(string $data, ?string $method = null): string {
if ($data === '') {
return '';
} else {
if ($method === null) {
$method = self::get_encryption_method();
}
// Create IV.
$iv = random_bytes(self::get_iv_length($method));
// Encrypt data.
switch($method) {
case self::METHOD_SODIUM:
try {
$encrypted = sodium_crypto_secretbox($data, $iv, self::get_key($method));
} catch (\SodiumException $e) {
throw new \moodle_exception('encryption_encryptfailed', 'error', '', null, $e->getMessage());
}
break;
case self::METHOD_OPENSSL:
// This may not be a secure authenticated encryption implementation;
// administrators should enable the Sodium extension.
$key = self::get_key($method);
if (strlen($key) !== 32) {
throw new \moodle_exception('encryption_invalidkey', 'error');
}
$encrypted = @openssl_encrypt($data, self::OPENSSL_CIPHER, $key, OPENSSL_RAW_DATA, $iv);
if ($encrypted === false) {
throw new \moodle_exception('encryption_encryptfailed', 'error',
'', null, openssl_error_string());
}
$hmac = hash_hmac('sha256', $iv . $encrypted, $key, true);
$encrypted .= $hmac;
break;
default:
throw new \coding_exception('Unknown method: ' . $method);
}
// Encrypted data is cipher method plus IV plus encrypted data.
return $method . ':' . base64_encode($iv . $encrypted);
}
}
/**
* Decrypts data using the server's key. The decryption works with either supported method.
*
* @param string $data Data to decrypt
* @return string Decrypted data
*/
public static function decrypt(string $data): string {
if ($data === '') {
return '';
} else {
if (preg_match('~^(' . self::METHOD_OPENSSL . '|' . self::METHOD_SODIUM . '):~', $data, $matches)) {
$method = $matches[1];
} else {
throw new \moodle_exception('encryption_wrongmethod', 'error');
}
$realdata = base64_decode(substr($data, strlen($method) + 1), true);
if ($realdata === false) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, 'Invalid base64 data');
}
$ivlength = self::get_iv_length($method);
if (strlen($realdata) < $ivlength + 1) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, 'Insufficient data');
}
$iv = substr($realdata, 0, $ivlength);
$encrypted = substr($realdata, $ivlength);
switch ($method) {
case self::METHOD_SODIUM:
try {
$decrypted = sodium_crypto_secretbox_open($encrypted, $iv, self::get_key($method));
} catch (\SodiumException $e) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, $e->getMessage());
}
// Sodium returns false if decryption fails because data is invalid.
if ($decrypted === false) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, 'Integrity check failed');
}
break;
case self::METHOD_OPENSSL:
if (strlen($encrypted) < 33) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, 'Insufficient data');
}
$hmac = substr($encrypted, -32);
$encrypted = substr($encrypted, 0, -32);
$key = self::get_key($method);
$expectedhmac = hash_hmac('sha256', $iv . $encrypted, $key, true);
if ($hmac !== $expectedhmac) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, 'Integrity check failed');
}
$decrypted = @openssl_decrypt($encrypted, self::OPENSSL_CIPHER, $key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
throw new \moodle_exception('encryption_decryptfailed', 'error',
'', null, openssl_error_string());
}
break;
default:
throw new \coding_exception('Unknown method: ' . $method);
}
return $decrypted;
}
}
}
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
// This file is part of Moodle - http://moodle.org/