MDL-67774 Authentication: Specify password peppers in config.php

Add a pepper to the users supplied password.
The pepper is stored in CFG and user to add extra security to
the password hash. By effectively breaking the information to
create the hashed password into two and storing it in more
than one place.
This commit is contained in:
Matt Porritt 2023-06-25 18:39:08 +10:00
parent 206c3a66e7
commit 1560be7b7e
3 changed files with 252 additions and 19 deletions

View File

@ -1226,6 +1226,40 @@ $CFG->admin = 'admin';
//
// $CFG->cookiehttponly = false;
//
// 21. SECRET PASSWORD PEPPER
//=========================================================================
// A pepper is a component of the salt, but stored separately.
// By splitting them it means that if the db is compromised the partial hashes are useless.
// Unlike a salt, the pepper is not unique and is shared for all users, and MUST be kept secret.
//
// A pepper needs to have at least 112 bits of entropy,
// so the pepper itself cannot be easily brute forced if you have a known password + hash combo.
//
// Once a pepper is set, existing passwords will be updated on next user login.
// Once set there is no going back without resetting all user passwords.
// To set peppers for your site, the following setting must be set in config.php:
//
// $CFG->passwordpeppers = [
// 1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z'
// ];
//
// The 'passwordpeppers' array must be numerically indexed with a positive number.
// New peppers can be added by adding a new element to the array with a higher numerical index.
// Upon next login a users password will be rehashed with the new pepper:
//
// $CFG->passwordpeppers = [
// 1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
// 2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$'
// ];
//
// Peppers can be progressively removed by setting the latest pepper to an empty string:
//
// $CFG->passwordpeppers = [
// 1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
// 2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$',
// 3 => ''
// ];
//
//=========================================================================
// ALL DONE! To continue installation, visit your main page with a browser
//=========================================================================

View File

@ -397,6 +397,11 @@ define ('PASSWORD_UPPER', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ');
define ('PASSWORD_DIGITS', '0123456789');
define ('PASSWORD_NONALPHANUM', '.,;:!?_-+/*@#&$');
/**
* Required password pepper entropy.
*/
define ('PEPPER_ENTROPY', 112);
// Feature constants.
// Used for plugin_supports() to report features that are, or are not, supported by a module.
@ -4662,6 +4667,68 @@ function password_is_legacy_hash(#[\SensitiveParameter] string $password): bool
return (bool) preg_match('/^\$2y\$[\d]{2}\$[A-Za-z0-9\.\/]{53}$/', $password);
}
/**
* Calculate the Shannon entropy of a string.
*
* @param string $pepper The pepper to calculate the entropy of.
* @return float The Shannon entropy of the string.
*/
function calculate_entropy(#[\SensitiveParameter] string $pepper): float {
// Initialize entropy.
$h = 0;
// Calculate the length of the string.
$size = strlen($pepper);
// For each unique character in the string.
foreach (count_chars($pepper, 1) as $v) {
// Calculate the probability of the character.
$p = $v / $size;
// Add the character's contribution to the total entropy.
// This uses the formula for the entropy of a discrete random variable.
$h -= $p * log($p) / log(2);
}
// Instead of returning the average entropy per symbol (Shannon entropy),
// we multiply by the length of the string to get total entropy.
return $h * $size;
}
/**
* Get the available password peppers.
* The latest pepper is checked for minimum entropy as part of this function.
* We only calculate the entropy of the most recent pepper,
* because passwords are always updated to the latest pepper,
* and in the past we may have enforced a lower minimum entropy.
* Also, we allow the latest pepper to be empty, to allow admins to migrate off peppers.
*
* @return array The password peppers.
* @throws coding_exception If the entropy of the password pepper is less than the recommended minimum.
*/
function get_password_peppers(): array {
global $CFG;
// Get all available peppers.
if (isset($CFG->passwordpeppers) && is_array($CFG->passwordpeppers)) {
// Sort the array in descending order of keys (numerical).
$peppers = $CFG->passwordpeppers;
krsort($peppers, SORT_NUMERIC);
} else {
$peppers = []; // Set an empty array if no peppers are found.
}
// Check if the entropy of the most recent pepper is less than the minimum.
// Also, we allow the most recent pepper to be empty, to allow admins to migrate off peppers.
$lastpepper = reset($peppers);
if (!empty($peppers) && $lastpepper !== '' && calculate_entropy($lastpepper) < PEPPER_ENTROPY) {
throw new coding_exception(
'password pepper below minimum',
'The entropy of the password pepper is less than the recommended minimum.');
}
return $peppers;
}
/**
* Compare password against hash stored in user object to determine if it is valid.
*
@ -4678,21 +4745,34 @@ function validate_internal_user_password(stdClass $user, #[\SensitiveParameter]
return false;
}
// First check if the password is valid, that is the password matches the stored hash.
$validated = password_verify($password, $user->password);
$peppers = get_password_peppers(); // Get the array of available peppers.
$islegacy = password_is_legacy_hash($user->password); // Check if the password is a legacy bcrypt hash.
// If the password is valid, next check if the hash is legacy (bcrypt).
// If it is, we update the hash to the new algorithm.
if ($validated && password_is_legacy_hash($user->password)) {
// If the password is a legacy hash, no peppers were used, so verify and update directly.
if ($islegacy && password_verify($password, $user->password)) {
update_internal_user_password($user, $password);
return true;
} else if ($validated) {
// If the password is valid, but the hash is not legacy, we can just return true.
return true;
} else {
// If the password is not valid, we return false.
return false;
}
// If the password is not a legacy hash, iterate through the peppers.
$latestpepper = reset($peppers);
// Add an empty pepper to the beginning of the array. To make it easier to check if the password matches without any pepper.
$peppers = [-1 => ''] + $peppers;
foreach ($peppers as $pepper) {
$pepperedpassword = $password . $pepper;
// If the peppered password is correct, update (if necessary) and return true.
if (password_verify($pepperedpassword, $user->password)) {
// If the pepper used is not the latest one, update the password.
if ($pepper !== $latestpepper) {
update_internal_user_password($user, $password);
}
return true;
}
}
// If no peppered password was correct, the password is wrong.
return false;
}
/**
@ -4741,6 +4821,8 @@ function hash_internal_user_password(#[\SensitiveParameter] string $password, $f
* 2. The existing hash is using an out-of-date algorithm (or the legacy
* md5 algorithm).
*
* The password is peppered with the latest pepper before hashing,
* if peppers are available.
* Updating the password will modify the $user object and the database
* record to use the current hashing algorithm.
* It will remove Web Services user tokens too.
@ -4760,6 +4842,12 @@ function update_internal_user_password(
): bool {
global $CFG, $DB;
// Add the latest password pepper to the password before further processing.
$peppers = get_password_peppers();
if (!empty($peppers)) {
$password = $password . reset($peppers);
}
// Figure out what the hashed password should be.
if (!isset($user->auth)) {
debugging('User record in update_internal_user_password() must include field auth',

View File

@ -2681,6 +2681,64 @@ EOF;
}
}
/**
* Test function that calculates password pepper entropy.
* @covers ::calculate_entropy
*/
public function test_calculate_entropy() {
// Test that the function returns 0 with an empty string.
$this->assertEquals(0, calculate_entropy(''));
// Test that the function returns the correct entropy.
$this->assertEquals(132.8814, number_format(calculate_entropy('#GV]NLie|x$H9[$rW%94bXZvJHa%z'), 4));
}
/**
* Test function to get password peppers.
* @covers ::get_password_peppers
*/
public function test_get_password_peppers() {
global $CFG;
$this->resetAfterTest();
// First assert that the function returns an empty array,
// when no peppers are set.
$this->assertEquals([], get_password_peppers());
// Now set some peppers and check that they are returned.
$CFG->passwordpeppers = [
1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$'
];
$peppers = get_password_peppers();
$this->assertCount(2, $peppers);
$this->assertEquals($CFG->passwordpeppers, $peppers);
// Check that the peppers are returned in the correct order.
// Highest numerical key first.
$this->assertEquals('#GV]NLie|x$H9[$rW%94bXZvJHa%$', $peppers[2]);
$this->assertEquals('#GV]NLie|x$H9[$rW%94bXZvJHa%z', $peppers[1]);
// Update the latest pepper to be an empty string,
// to test phasing out peppers.
$CFG->passwordpeppers = [
1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$',
3 => ''
];
$peppers = get_password_peppers();
$this->assertCount(3, $peppers);
$this->assertEquals($CFG->passwordpeppers, $peppers);
// Finally, check that low entropy peppers throw an exception.
$CFG->passwordpeppers = [
1 => 'foo',
2 => 'bar'
];
$this->expectException(\coding_exception::class);
get_password_peppers();
}
/**
* Test function validate_internal_user_password.
* @covers ::validate_internal_user_password
@ -2716,19 +2774,44 @@ EOF;
}
/**
* Test function hash_internal_user_password.
* Test function validate_internal_user_password() with a peppered password,
* when the pepper no longer exists.
*
* @covers ::validate_internal_user_password
*/
public function test_validate_internal_user_password_bad_pepper() {
global $CFG;
$this->resetAfterTest();
// Set a pepper.
$CFG->passwordpeppers = [
1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$'
];
$password = 'test';
$user = $this->getDataGenerator()->create_user(['auth' => 'manual', 'password' => $password]);
$this->assertTrue(validate_internal_user_password($user, $password));
$this->assertFalse(validate_internal_user_password($user, 'badpw'));
// Now remove the peppers.
// Things should not work.
unset($CFG->passwordpeppers);
$this->assertFalse(validate_internal_user_password($user, $password));
}
/**
* Helper method to test hashing passwords.
*
* @param array $passwords
* @return void
* @covers ::hash_internal_user_password
*/
public function test_hash_internal_user_password() {
$passwords = array('pw', 'abc123', 'C0mP1eX_&}<?@*&%` |\"', 'ĩńťėŕňăţĩōŋāĹ');
// Check that some passwords that we convert to hashes can
// be validated.
public function validate_hashed_passwords(array $passwords): void {
foreach ($passwords as $password) {
$hash = hash_internal_user_password($password);
$fasthash = hash_internal_user_password($password, true);
$user = new \stdClass();
$user->auth = 'manual';
$user = $this->getDataGenerator()->create_user(['auth' => 'manual']);
$user->password = $hash;
$this->assertTrue(validate_internal_user_password($user, $password));
@ -2745,6 +2828,34 @@ EOF;
* Test function update_internal_user_password.
* @covers ::update_internal_user_password
*/
public function test_hash_internal_user_password() {
global $CFG;
$this->resetAfterTest();
$passwords = ['pw', 'abc123', 'C0mP1eX_&}<?@*&%` |\"', 'ĩńťėŕňăţĩōŋāĹ'];
// Check that some passwords that we convert to hashes can
// be validated.
$this->validate_hashed_passwords($passwords);
// Test again with peppers.
$CFG->passwordpeppers = [
1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$'
];
$this->validate_hashed_passwords($passwords);
// Add a new pepper and check that things still pass.
$CFG->passwordpeppers = [
1 => '#GV]NLie|x$H9[$rW%94bXZvJHa%z',
2 => '#GV]NLie|x$H9[$rW%94bXZvJHa%$',
3 => '#GV]NLie|x$H9[$rW%94bXZvJHQ%$'
];
$this->validate_hashed_passwords($passwords);
}
/**
* Test function update_internal_user_password().
*/
public function test_update_internal_user_password() {
global $DB;
$this->resetAfterTest();