diff --git a/publish/login.php b/publish/login.php index 9c1253a..ffeacb2 100644 --- a/publish/login.php +++ b/publish/login.php @@ -29,14 +29,13 @@ if (isset($_SESSION['editor'])) { $getid = $db->execute_query($query, array($username)) or db_fail(); $row = $getid->fetch_assoc(); - $stored_password = ''; + $stored_password = NULL; if (isset($row)) - $stored_password = $row['password']; + $stored_password = (string)$row['password']; else $denied = TRUE; $getid->close(); $compare_password = is_string($_POST['password'] ?? NULL) ? $_POST['password'] : ''; - // password_verify() also accepts hashes made by the old crypt() call - if (!password_verify($compare_password, $stored_password)) { + if (!editor_password_valid($compare_password, $stored_password)) { $denied = TRUE; record_login_failure($username); open_db('close'); diff --git a/session.lib.php b/session.lib.php index 0466146..8034689 100644 --- a/session.lib.php +++ b/session.lib.php @@ -211,6 +211,21 @@ function password_hash_is_stored($username, $hash) { return $row && $row['password'] === $hash; } +/* + * TRUE if $password matches $stored_hash. For an unknown editor + * ($stored_hash is NULL), it hashes the password anyway, so that the + * answer takes as long as for a wrong password, and does not show which + * usernames exist. + */ +function editor_password_valid($password, $stored_hash) { + if ($stored_hash === NULL) { + password_hash($password, password_algorithm()); + return FALSE; + } + // password_verify() also accepts hashes made by the old crypt() call + return password_verify($password, $stored_hash); +} + /* * After a successful login, move an old hash (MD5-crypt, bcrypt) to the * current algorithm. If the column is too short and MySQL cut the new diff --git a/tests/php/Unit/PasswordTest.php b/tests/php/Unit/PasswordTest.php index bd29ad7..168bfb3 100644 --- a/tests/php/Unit/PasswordTest.php +++ b/tests/php/Unit/PasswordTest.php @@ -23,6 +23,30 @@ class PasswordTest extends TestCase { $this->assertSame(defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT, password_algorithm()); } + public function testEditorPasswordValid(): void { + $hash = password_hash('secret', password_algorithm()); + $this->assertTrue(editor_password_valid('secret', $hash)); + $this->assertFalse(editor_password_valid('wrong', $hash)); + $this->assertFalse(editor_password_valid('', '')); + $this->assertFalse(editor_password_valid('secret', NULL)); + $this->assertFalse(editor_password_valid('', NULL)); + } + + // A fast answer for an unknown username would show which usernames exist + public function testAnUnknownEditorTakesAsLongAsAWrongPassword(): void { + $hash = password_hash('secret', password_algorithm()); + $time = function ($stored) { + $best = INF; + for ($i = 0; $i < 3; $i++) { + $start = hrtime(TRUE); + editor_password_valid('wrong', $stored); + $best = min($best, hrtime(TRUE) - $start); + } + return $best; + }; + $this->assertGreaterThan($time($hash) / 2, $time(NULL)); + } + public function testHashIsStoredAndChecked(): void { $this->addEditor('old'); $hash = password_hash('secret', password_algorithm());