From 95a8974c6bda2c6353612c40b01b9c78527b8956 Mon Sep 17 00:00:00 2001 From: Tyler Anthony Romeo Date: Wed, 31 Jul 2013 05:11:18 +0000 Subject: [PATCH] Added password hashing API Deprecated the old User::crypt, et. al password hashing system and implemented an extensible password hashing API. The new Password class allows registering of child classes and provides factory functions for creating new Password objects. The built-in hash types are the old MediaWiki MD5 types, which are for backwards-compatibility only, and bcrypt. Also included is support for wrapping existing hashes as well as encrypting passwords with a configured encryption key. Bug: 54948 Bug: 28419 Change-Id: I0a9c972931a0eff0cfb2619cef3ddffd03710285 --- RELEASE-NOTES-1.24 | 5 + docs/hooks.txt | 4 +- includes/AutoLoader.php | 12 + includes/DefaultSettings.php | 60 +++++ includes/User.php | 212 +++++++++++------- includes/password/BcryptPassword.php | 88 ++++++++ includes/password/EncryptedPassword.php | 98 ++++++++ includes/password/InvalidPassword.php | 47 ++++ .../password/LayeredParameterizedPassword.php | 140 ++++++++++++ includes/password/MWOldPassword.php | 48 ++++ includes/password/MWSaltedPassword.php | 46 ++++ includes/password/ParameterizedPassword.php | 115 ++++++++++ includes/password/Password.php | 186 +++++++++++++++ includes/password/PasswordFactory.php | 178 +++++++++++++++ includes/password/Pbkdf2Password.php | 85 +++++++ maintenance/wrapOldPasswords.php | 126 +++++++++++ tests/TestsAutoLoader.php | 1 + tests/phpunit/MediaWikiPasswordTestCase.php | 88 ++++++++ tests/phpunit/includes/PasswordTest.php | 33 +++ .../includes/password/BcryptPasswordTest.php | 40 ++++ .../LayeredParameterizedPasswordTest.php | 51 +++++ .../includes/password/Pbkdf2PasswordTest.php | 24 ++ 22 files changed, 1598 insertions(+), 89 deletions(-) create mode 100644 includes/password/BcryptPassword.php create mode 100644 includes/password/EncryptedPassword.php create mode 100644 includes/password/InvalidPassword.php create mode 100644 includes/password/LayeredParameterizedPassword.php create mode 100644 includes/password/MWOldPassword.php create mode 100644 includes/password/MWSaltedPassword.php create mode 100644 includes/password/ParameterizedPassword.php create mode 100644 includes/password/Password.php create mode 100644 includes/password/PasswordFactory.php create mode 100644 includes/password/Pbkdf2Password.php create mode 100644 maintenance/wrapOldPasswords.php create mode 100644 tests/phpunit/MediaWikiPasswordTestCase.php create mode 100644 tests/phpunit/includes/PasswordTest.php create mode 100644 tests/phpunit/includes/password/BcryptPasswordTest.php create mode 100644 tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php create mode 100644 tests/phpunit/includes/password/Pbkdf2PasswordTest.php diff --git a/RELEASE-NOTES-1.24 b/RELEASE-NOTES-1.24 index 119f1a24e4..a74e76d52b 100644 --- a/RELEASE-NOTES-1.24 +++ b/RELEASE-NOTES-1.24 @@ -42,6 +42,8 @@ production. configurations are $wgDeletedDirectory and $wgHashedUploadDirectory. * The deprecated $wgUseCommaCount variable has been removed. * $wgEnableSorbs and $wgSorbsUrl have been removed. +* The UserCryptPassword and UserComparePassword hooks are no longer called. Any extensions + using them must be updated to use the Password Hashing API. === New features in 1.24 === * Added a new hook, "WhatLinksHereProps", to allow extensions to annotate @@ -133,6 +135,9 @@ production. no longer be displayed in the sidebar when $wgInterwikiMagic is true. * New special page, MyLanguages, to redirect users to subpages with localised versions of a page. (Integrated from Extension:Translate) +* MediaWiki now supports multiple password types, including bcrypt and PBKDF2. + The default type can be changed with $wgPasswordDefault and the type + configurations can be changed with $wgPasswordConfig. === Bug fixes in 1.24 === * (bug 49116) Footer copyright notice is now always displayed in user language diff --git a/docs/hooks.txt b/docs/hooks.txt index 3ee122196d..dd9f90586d 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -2789,7 +2789,7 @@ $user: User (object) that will clear the message $oldid: ID of the talk page revision being viewed (0 means the most recent one) 'UserComparePasswords': Called when checking passwords, return false to -override the default password checks. +override the default password checks. REMOVED since 1.23, use Password class instead. &$hash: String of the password hash (from the database) &$password: String of the plaintext password the user entered &$userId: Integer of the user's ID or Boolean false if the user ID was not @@ -2801,7 +2801,7 @@ override the default password checks. $template: SimpleTemplate instance for the form 'UserCryptPassword': Called when hashing a password, return false to implement -your own hashing method. +your own hashing method. REMOVED since 1.23, use Password class instead. &$password: String of the plaintext password to encrypt &$salt: String of the password salt or Boolean false if no salt is provided &$wgPasswordSalt: Boolean of whether the salt is used in the default hashing diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index ab5af1b2d0..1d76e71044 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -828,6 +828,18 @@ $wgAutoloadLocalClasses = array( 'Preprocessor_Hash' => 'includes/parser/Preprocessor_Hash.php', 'StripState' => 'includes/parser/StripState.php', + # includes/password + 'BcryptPassword' => 'includes/password/BcryptPassword.php', + 'InvalidPassword' => 'includes/password/InvalidPassword.php', + 'LayeredParameterizedPassword' => 'includes/password/LayeredParameterizedPassword.php', + 'MWSaltedPassword' => 'includes/password/MWSaltedPassword.php', + 'MWOldPassword' => 'includes/password/MWOldPassword.php', + 'ParameterizedPassword' => 'includes/password/ParameterizedPassword.php', + 'Password' => 'includes/password/Password.php', + 'PasswordFactory' => 'includes/password/PasswordFactory.php', + 'Pbkdf2Password' => 'includes/password/Pbkdf2Password.php', + 'EncryptedPassword' => 'includes/password/EncryptedPassword.php', + # includes/profiler 'Profiler' => 'includes/profiler/Profiler.php', 'ProfilerMwprof' => 'includes/profiler/ProfilerMwprof.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 3ef59b3435..9df920b81a 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -4012,6 +4012,7 @@ $wgActiveUserDays = 30; /** * For compatibility with old installations set to false + * @deprecated since 1.24 will be removed in future */ $wgPasswordSalt = true; @@ -4028,6 +4029,65 @@ $wgMinimalPasswordLength = 1; */ $wgInvalidPasswordReset = true; +/* + * Default password type to use when hashing user passwords + * + * @since 1.24 + */ +$wgPasswordDefault = 'B'; + +/** + * Configuration for built-in password types. Maps the password type + * to an array of options. The 'class' option is the Password class to + * use. All other options are class-dependent. + * + * An advanced example: + * @code + * $wgPasswordConfig['bcrypt-peppered'] = array( + * 'class' => 'EncryptedPassword', + * 'underlying' => 'bcrypt', + * 'secrets' => array(), + * 'cipher' => MCRYPT_RIJNDAEL_256, + * 'mode' => MCRYPT_MODE_CBC, + * 'cost' => 5, + * ); + * @endcode + * + * @since 1.24 + */ +$wgPasswordConfig = array( + 'A' => array( + 'class' => 'MWOldPassword', + ), + 'B' => array( + 'class' => 'MWSaltedPassword', + ), + 'pbkdf2-legacyA' => array( + 'class' => 'LayeredParameterizedPassword', + 'types' => array( + 'A', + 'pbkdf2', + ), + ), + 'pbkdf2-legacyB' => array( + 'class' => 'LayeredParameterizedPassword', + 'types' => array( + 'B', + 'pbkdf2', + ), + ), + 'bcrypt' => array( + 'class' => 'BcryptPassword', + 'cost' => 9, + ), + 'pbkdf2' => array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha256', + 'cost' => '10000', + 'length' => '128', + ), +); + /** * Whether to allow password resets ("enter some identifying data, and we'll send an email * with a temporary password you can use to get back into the account") identified by diff --git a/includes/User.php b/includes/User.php index fa20ebdaa6..73d4959ee5 100644 --- a/includes/User.php +++ b/includes/User.php @@ -30,7 +30,7 @@ define( 'USER_TOKEN_LENGTH', 32 ); * Int Serialized record version. * @ingroup Constants */ -define( 'MW_USER_VERSION', 9 ); +define( 'MW_USER_VERSION', 10 ); /** * String Some punctuation to prevent editing from broken text-mangling proxies. @@ -70,6 +70,11 @@ class User implements IDBAccessObject { */ const MAX_WATCHED_ITEMS_CACHE = 100; + /** + * @var PasswordFactory Lazily loaded factory object for passwords + */ + private static $mPasswordFactory = null; + /** * Array of Strings List of member variables which are saved to the * shared cache (memcached). Any operation which changes the @@ -81,16 +86,12 @@ class User implements IDBAccessObject { 'mId', 'mName', 'mRealName', - 'mPassword', - 'mNewpassword', - 'mNewpassTime', 'mEmail', 'mTouched', 'mToken', 'mEmailAuthenticated', 'mEmailToken', 'mEmailTokenExpires', - 'mPasswordExpires', 'mRegistration', 'mEditCount', // user_groups table @@ -997,10 +998,13 @@ class User implements IDBAccessObject { public function loadDefaults( $name = false ) { wfProfileIn( __METHOD__ ); + $passwordFactory = self::getPasswordFactory(); + $this->mId = 0; $this->mName = $name; $this->mRealName = ''; - $this->mPassword = $this->mNewpassword = ''; + $this->mPassword = $passwordFactory->newFromCiphertext( null ); + $this->mNewpassword = $passwordFactory->newFromCiphertext( null ); $this->mNewpassTime = null; $this->mEmail = ''; $this->mOptionOverrides = null; @@ -1190,6 +1194,7 @@ class User implements IDBAccessObject { */ public function loadFromRow( $row, $data = null ) { $all = true; + $passwordFactory = self::getPasswordFactory(); $this->mGroups = null; // deferred @@ -1223,9 +1228,31 @@ class User implements IDBAccessObject { } if ( isset( $row->user_password ) ) { - $this->mPassword = $row->user_password; - $this->mNewpassword = $row->user_newpassword; + // Check for *really* old password hashes that don't even have a type + // The old hash format was just an md5 hex hash, with no type information + if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { + $row->user_password = ":A:{$this->mId}:{$row->user_password}"; + } + + try { + $this->mPassword = $passwordFactory->newFromCiphertext( $row->user_password ); + } catch ( PasswordError $e ) { + wfDebug( 'Invalid password hash found in database.' ); + $this->mPassword = $passwordFactory->newFromCiphertext( null ); + } + + try { + $this->mNewpassword = $passwordFactory->newFromCiphertext( $row->user_newpassword ); + } catch ( PasswordError $e ) { + wfDebug( 'Invalid password hash found in database.' ); + $this->mNewpassword = $passwordFactory->newFromCiphertext( null ); + } + $this->mNewpassTime = wfTimestampOrNull( TS_MW, $row->user_newpass_time ); + $this->mPasswordExpires = wfTimestampOrNull( TS_MW, $row->user_password_expires ); + } + + if ( isset( $row->user_email ) ) { $this->mEmail = $row->user_email; $this->mTouched = wfTimestamp( TS_MW, $row->user_touched ); $this->mToken = $row->user_token; @@ -1235,7 +1262,6 @@ class User implements IDBAccessObject { $this->mEmailAuthenticated = wfTimestampOrNull( TS_MW, $row->user_email_authenticated ); $this->mEmailToken = $row->user_email_token; $this->mEmailTokenExpires = wfTimestampOrNull( TS_MW, $row->user_email_token_expires ); - $this->mPasswordExpires = wfTimestampOrNull( TS_MW, $row->user_password_expires ); $this->mRegistration = wfTimestampOrNull( TS_MW, $row->user_registration ); } else { $all = false; @@ -1286,6 +1312,26 @@ class User implements IDBAccessObject { } } + /** + * Load the user's password hashes from the database + * + * This is usually called in a scenario where the actual User object was + * loaded from the cache, and then password comparison needs to be performed. + * Password hashes are not stored in memcached. + * + * @since 1.24 + */ + private function loadPasswords() { + if ( $this->getId() !== 0 && ( $this->mPassword === null || $this->mNewpassword === null ) ) { + $this->loadFromRow( wfGetDB( DB_MASTER )->selectRow( + 'user', + array( 'user_password', 'user_newpassword', 'user_newpass_time', 'user_password_expires' ), + array( 'user_id' => $this->getId() ), + __METHOD__ + ) ); + } + } + /** * Add the user to the group if he/she meets given criteria. * @@ -2151,7 +2197,7 @@ class User implements IDBAccessObject { * * Called implicitly from invalidateCache() and saveSettings(). */ - private function clearSharedCache() { + public function clearSharedCache() { $this->load(); if ( $this->mId ) { global $wgMemc; @@ -2267,16 +2313,16 @@ class User implements IDBAccessObject { * through the web interface. */ public function setInternalPassword( $str ) { - $this->load(); $this->setToken(); + $passwordFactory = self::getPasswordFactory(); if ( $str === null ) { - // Save an invalid hash... - $this->mPassword = ''; + $this->mPassword = $passwordFactory->newFromCiphertext( null ); } else { - $this->mPassword = self::crypt( $str ); + $this->mPassword = $passwordFactory->newFromPlaintext( $str ); } - $this->mNewpassword = ''; + + $this->mNewpassword = $passwordFactory->newFromCiphertext( null ); $this->mNewpassTime = null; } @@ -2323,7 +2369,7 @@ class User implements IDBAccessObject { $this->mNewpassword = ''; $this->mNewpassTime = null; } else { - $this->mNewpassword = self::crypt( $str ); + $this->mNewpassword = self::getPasswordFactory()->newFromPlaintext( $str ); if ( $throttle ) { $this->mNewpassTime = wfTimestampNow(); } @@ -3411,6 +3457,7 @@ class User implements IDBAccessObject { global $wgAuth; $this->load(); + $this->loadPasswords(); if ( wfReadOnly() ) { return; } @@ -3420,15 +3467,15 @@ class User implements IDBAccessObject { $this->mTouched = self::newTouchedTimestamp(); if ( !$wgAuth->allowSetLocalPassword() ) { - $this->mPassword = ''; + $this->mPassword = self::getPasswordFactory()->newFromCiphertext( null ); } $dbw = wfGetDB( DB_MASTER ); $dbw->update( 'user', array( /* SET */ 'user_name' => $this->mName, - 'user_password' => $this->mPassword, - 'user_newpassword' => $this->mNewpassword, + 'user_password' => $this->mPassword->toString(), + 'user_newpassword' => $this->mNewpassword->toString(), 'user_newpass_time' => $dbw->timestampOrNull( $this->mNewpassTime ), 'user_real_name' => $this->mRealName, 'user_email' => $this->mEmail, @@ -3490,6 +3537,7 @@ class User implements IDBAccessObject { public static function createNew( $name, $params = array() ) { $user = new User; $user->load(); + $user->loadPasswords(); $user->setToken(); // init token if ( isset( $params['options'] ) ) { $user->mOptions = $params['options'] + (array)$user->mOptions; @@ -3501,8 +3549,8 @@ class User implements IDBAccessObject { $fields = array( 'user_id' => $seqVal, 'user_name' => $name, - 'user_password' => $user->mPassword, - 'user_newpassword' => $user->mNewpassword, + 'user_password' => $user->mPassword->toString(), + 'user_newpassword' => $user->mNewpassword->toString(), 'user_newpass_time' => $dbw->timestampOrNull( $user->mNewpassTime ), 'user_email' => $user->mEmail, 'user_email_authenticated' => $dbw->timestampOrNull( $user->mEmailAuthenticated ), @@ -3552,6 +3600,7 @@ class User implements IDBAccessObject { */ public function addToDatabase() { $this->load(); + $this->loadPasswords(); if ( !$this->mToken ) { $this->setToken(); // init token } @@ -3565,8 +3614,8 @@ class User implements IDBAccessObject { array( 'user_id' => $seqVal, 'user_name' => $this->mName, - 'user_password' => $this->mPassword, - 'user_newpassword' => $this->mNewpassword, + 'user_password' => $this->mPassword->toString(), + 'user_newpassword' => $this->mNewpassword->toString(), 'user_newpass_time' => $dbw->timestampOrNull( $this->mNewpassTime ), 'user_email' => $this->mEmail, 'user_email_authenticated' => $dbw->timestampOrNull( $this->mEmailAuthenticated ), @@ -3722,7 +3771,7 @@ class User implements IDBAccessObject { */ public function checkPassword( $password ) { global $wgAuth, $wgLegacyEncoding; - $this->load(); + $this->loadPasswords(); // Certain authentication plugins do NOT want to save // domain passwords in a mysql database, so we should @@ -3737,19 +3786,27 @@ class User implements IDBAccessObject { // Auth plugin doesn't allow local authentication for this user name return false; } - if ( self::comparePasswords( $this->mPassword, $password, $this->mId ) ) { - return true; - } elseif ( $wgLegacyEncoding ) { - // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted - // Check for this with iconv - $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $password ); - if ( $cp1252Password != $password - && self::comparePasswords( $this->mPassword, $cp1252Password, $this->mId ) - ) { - return true; + + $passwordFactory = self::getPasswordFactory(); + if ( !$this->mPassword->equals( $password ) ) { + if ( $wgLegacyEncoding ) { + // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted + // Check for this with iconv + $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $password ); + if ( $cp1252Password === $password || !$this->mPassword->equals( $cp1252Password ) ) { + return false; + } + } else { + return false; } } - return false; + + if ( $passwordFactory->needsUpdate( $this->mPassword ) ) { + $this->mPassword = $passwordFactory->newFromPlaintext( $password ); + $this->saveSettings(); + } + + return true; } /** @@ -3764,7 +3821,7 @@ class User implements IDBAccessObject { global $wgNewPasswordExpiry; $this->load(); - if ( self::comparePasswords( $this->mNewpassword, $plaintext, $this->getId() ) ) { + if ( $this->mNewpassword->equals( $plaintext ) ) { if ( is_null( $this->mNewpassTime ) ) { return true; } @@ -4550,22 +4607,6 @@ class User implements IDBAccessObject { return $msg->isBlank() ? $right : $msg->text(); } - /** - * Make an old-style password hash - * - * @param string $password Plain-text password - * @param string $userId User ID - * @return string Password hash - */ - public static function oldCrypt( $password, $userId ) { - global $wgPasswordSalt; - if ( $wgPasswordSalt ) { - return md5( $userId . '-' . md5( $password ) ); - } else { - return md5( $password ); - } - } - /** * Make a new-style password hash * @@ -4573,23 +4614,12 @@ class User implements IDBAccessObject { * @param bool|string $salt Optional salt, may be random or the user ID. * If unspecified or false, will generate one automatically * @return string Password hash + * @deprecated since 1.23, use Password class */ public static function crypt( $password, $salt = false ) { - global $wgPasswordSalt; - - $hash = ''; - if ( !wfRunHooks( 'UserCryptPassword', array( &$password, &$salt, &$wgPasswordSalt, &$hash ) ) ) { - return $hash; - } - - if ( $wgPasswordSalt ) { - if ( $salt === false ) { - $salt = MWCryptRand::generateHex( 8 ); - } - return ':B:' . $salt . ':' . md5( $salt . '-' . md5( $password ) ); - } else { - return ':A:' . md5( $password ); - } + wfDeprecated( __METHOD__, '1.23' ); + $hash = self::getPasswordFactory()->newFromPlaintext( $password ); + return $hash->toString(); } /** @@ -4601,26 +4631,24 @@ class User implements IDBAccessObject { * @param string|bool $userId User ID for old-style password salt * * @return bool + * @deprecated since 1.23, use Password class */ public static function comparePasswords( $hash, $password, $userId = false ) { - $type = substr( $hash, 0, 3 ); - - $result = false; - if ( !wfRunHooks( 'UserComparePasswords', array( &$hash, &$password, &$userId, &$result ) ) ) { - return $result; + wfDeprecated( __METHOD__, '1.23' ); + + // Check for *really* old password hashes that don't even have a type + // The old hash format was just an md5 hex hash, with no type information + if ( preg_match( '/^[0-9a-f]{32}$/', $hash ) ) { + global $wgPasswordSalt; + if ( $wgPasswordSalt ) { + $password = ":B:{$userId}:{$hash}"; + } else { + $password = ":A:{$hash}"; + } } - if ( $type == ':A:' ) { - // Unsalted - return md5( $password ) === substr( $hash, 3 ); - } elseif ( $type == ':B:' ) { - // Salted - list( $salt, $realHash ) = explode( ':', substr( $hash, 3 ), 2 ); - return md5( $salt . '-' . md5( $password ) ) === $realHash; - } else { - // Old-style - return self::oldCrypt( $password, $userId ) === $hash; - } + $hash = self::getPasswordFactory()->newFromCiphertext( $hash ); + return $hash->equals( $password ); } /** @@ -4824,6 +4852,20 @@ class User implements IDBAccessObject { $dbw->insert( 'user_properties', $insert_rows, __METHOD__, array( 'IGNORE' ) ); } + /** + * Lazily instantiate and return a factory object for making passwords + * + * @return PasswordFactory + */ + public static function getPasswordFactory() { + if ( self::$mPasswordFactory === null ) { + self::$mPasswordFactory = new PasswordFactory(); + self::$mPasswordFactory->init( RequestContext::getMain()->getConfig() ); + } + + return self::$mPasswordFactory; + } + /** * Provide an array of HTML5 attributes to put on an input element * intended for the user to enter a new password. This may include @@ -4892,16 +4934,12 @@ class User implements IDBAccessObject { 'user_id', 'user_name', 'user_real_name', - 'user_password', - 'user_newpassword', - 'user_newpass_time', 'user_email', 'user_touched', 'user_token', 'user_email_authenticated', 'user_email_token', 'user_email_token_expires', - 'user_password_expires', 'user_registration', 'user_editcount', ); diff --git a/includes/password/BcryptPassword.php b/includes/password/BcryptPassword.php new file mode 100644 index 0000000000..4e5e878b5e --- /dev/null +++ b/includes/password/BcryptPassword.php @@ -0,0 +1,88 @@ + $this->config['cost'], + ); + } + + protected function getDelimiter() { + return '$'; + } + + protected function parseHash( $hash ) { + parent::parseHash( $hash ); + + $this->params['rounds'] = (int)$this->params['rounds']; + } + + /** + * @param string $password Password to encrypt + * + * @throws PasswordError If bcrypt has an unknown error + * @throws MWException If bcrypt is not supported by PHP + */ + public function crypt( $password ) { + if ( !defined( 'CRYPT_BLOWFISH' ) ) { + throw new MWException( 'Bcrypt is not supported.' ); + } + + // Either use existing hash or make a new salt + // Bcrypt expects 22 characters of base64-encoded salt + // Note: bcrypt does not use MIME base64. It uses its own base64 without any '=' padding. + // It expects a 128 bit salt, so it will ignore anything after the first 128 bits + if ( !isset( $this->args[0] ) ) { + $this->args[] = substr( + // Replace + with ., because bcrypt uses a non-MIME base64 format + strtr( + // Random base64 encoded string + base64_encode( MWCryptRand::generate( 16, true ) ), + '+', '.' + ), + 0, 22 + ); + } + + $hash = crypt( $password, + sprintf( '$2y$%02d$%s', (int)$this->params['rounds'], $this->args[0] ) ); + + if ( !is_string( $hash ) || strlen( $hash ) <= 13 ) { + throw new PasswordError( 'Error when hashing password.' ); + } + + // Strip the $2y$ + $parts = explode( $this->getDelimiter(), substr( $hash, 4 ) ); + $this->params['rounds'] = (int)$parts[0]; + $this->args[0] = substr( $parts[1], 0, 22 ); + $this->hash = substr( $parts[1], 22 ); + } +} diff --git a/includes/password/EncryptedPassword.php b/includes/password/EncryptedPassword.php new file mode 100644 index 0000000000..39da32d1aa --- /dev/null +++ b/includes/password/EncryptedPassword.php @@ -0,0 +1,98 @@ + $this->config['cipher'], + 'secret' => count( $this->config['secrets'] ) - 1 + ); + } + + public function crypt( $password ) { + $secret = $this->config['secrets'][$this->params['secret']]; + + if ( $this->hash ) { + $underlyingPassword = $this->factory->newFromCiphertext( openssl_decrypt( + base64_decode( $this->hash ), $this->params['cipher'], + $secret, 0, base64_decode( $this->args[0] ) + ) ); + } else { + $underlyingPassword = $this->factory->newFromType( $this->config['underlying'], $this->config ); + } + + $underlyingPassword->crypt( $password ); + $iv = MWCryptRand::generate( openssl_cipher_iv_length( $this->params['cipher'] ), true ); + + $this->hash = openssl_encrypt( + $underlyingPassword->toString(), $this->params['cipher'], $secret, 0, $iv ); + $this->args = array( base64_encode( $iv ) ); + } + + /** + * Updates the underlying hash by encrypting it with the newest secret. + * + * @throws MWException If the configuration is not valid + * @return bool True if the password was updated + */ + public function update() { + if ( count( $this->args ) != 2 || $this->params == $this->getDefaultParams() ) { + // Hash does not need updating + return false; + } + + // Decrypt the underlying hash + $underlyingHash = openssl_decrypt( + base64_decode( $this->args[1] ), + $this->params['cipher'], + $this->config['secrets'][$this->params['secret']], + 0, + base64_decode( $this->args[0] ) + ); + + // Reset the params + $this->params = $this->getDefaultParams(); + + // Check the key size with the new params + $iv = MWCryptRand::generate( openssl_cipher_iv_length( $this->params['cipher'] ), true ); + $this->hash = base64_encode( openssl_encrypt( + $underlyingHash, + $this->params['cipher'], + $this->config['secrets'][$this->params['secret']], + 0, + $iv + ) ); + $this->args = array( base64_encode( $iv ) ); + + return true; + } +} diff --git a/includes/password/InvalidPassword.php b/includes/password/InvalidPassword.php new file mode 100644 index 0000000000..e45b774441 --- /dev/null +++ b/includes/password/InvalidPassword.php @@ -0,0 +1,47 @@ +config['types'] as $type ) { + $passObj = $this->factory->newFromType( $type ); + + if ( !$passObj instanceof ParameterizedPassword ) { + throw new MWException( 'Underlying type must be a parameterized password.' ); + } elseif ( $passObj->getDelimiter() === $this->getDelimiter() ) { + throw new MWException( 'Underlying type cannot use same delimiter as encapsulating type.' ); + } + + $params[] = implode( $passObj->getDelimiter(), $passObj->getDefaultParams() ); + } + + return $params; + } + + public function crypt( $password ) { + $lastHash = $password; + foreach ( $this->config['types'] as $i => $type ) { + // Construct pseudo-hash based on params and arguments + /** @var ParameterizedPassword $passObj */ + $passObj = $this->factory->newFromType( $type ); + + $params = ''; + $args = ''; + if ( $this->params[$i] !== '' ) { + $params = $this->params[$i] . $passObj->getDelimiter(); + } + if ( isset( $this->args[$i] ) && $this->args[$i] !== '' ) { + $args = $this->args[$i] . $passObj->getDelimiter(); + } + $existingHash = ":$type:" . $params . $args . $this->hash; + + // Hash the last hash with the next type in the layer + $passObj = $this->factory->newFromCiphertext( $existingHash ); + $passObj->crypt( $lastHash ); + + // Move over the params and args + $this->params[$i] = implode( $passObj->getDelimiter(), $passObj->params ); + $this->args[$i] = implode( $passObj->getDelimiter(), $passObj->args ); + $lastHash = $passObj->hash; + } + + $this->hash = $lastHash; + } + + /** + * Finish the hashing of a partially hashed layered hash + * + * Given a password hash that is hashed using the first layer of this object's + * configuration, perform the remaining layers of password hashing in order to + * get an updated hash with all the layers. + * + * @param ParameterizedPassword $passObj Password hash of the first layer + * + * @throws MWException If the first parameter is not of the correct type + */ + public function partialCrypt( ParameterizedPassword $passObj ) { + $type = $passObj->config['type']; + if ( $type !== $this->config['types'][0] ) { + throw new MWException( 'Only a hash in the first layer can be finished.' ); + } + + // Gather info from the existing hash + $this->params[0] = implode( $passObj->getDelimiter(), $passObj->params ); + $this->args[0] = implode( $passObj->getDelimiter(), $passObj->args ); + $lastHash = $passObj->hash; + + // Layer the remaining types + foreach ( $this->config['types'] as $i => $type ) { + if ( $i == 0 ) { + continue; + }; + + // Construct pseudo-hash based on params and arguments + /** @var ParameterizedPassword $passObj */ + $passObj = $this->factory->newFromType( $type ); + + $params = ''; + $args = ''; + if ( $this->params[$i] !== '' ) { + $params = $this->params[$i] . $passObj->getDelimiter(); + } + if ( isset( $this->args[$i] ) && $this->args[$i] !== '' ) { + $args = $this->args[$i] . $passObj->getDelimiter(); + } + $existingHash = ":$type:" . $params . $args . $this->hash; + + // Hash the last hash with the next type in the layer + $passObj = $this->factory->newFromCiphertext( $existingHash ); + $passObj->crypt( $lastHash ); + + // Move over the params and args + $this->params[$i] = implode( $passObj->getDelimiter(), $passObj->params ); + $this->args[$i] = implode( $passObj->getDelimiter(), $passObj->args ); + $lastHash = $passObj->hash; + } + + $this->hash = $lastHash; + } +} diff --git a/includes/password/MWOldPassword.php b/includes/password/MWOldPassword.php new file mode 100644 index 0000000000..0ba407f2f0 --- /dev/null +++ b/includes/password/MWOldPassword.php @@ -0,0 +1,48 @@ +args ) == 1 ) { + $this->hash = md5( $this->args[0] . '-' . md5( $plaintext ) ); + } else { + $this->args = array(); + $this->hash = md5( $plaintext ); + } + } +} diff --git a/includes/password/MWSaltedPassword.php b/includes/password/MWSaltedPassword.php new file mode 100644 index 0000000000..6c6895a2bd --- /dev/null +++ b/includes/password/MWSaltedPassword.php @@ -0,0 +1,46 @@ +args ) == 0 ) { + $this->args[] = MWCryptRand::generateHex( 8 ); + } + + $this->hash = md5( $this->args[0] . '-' . md5( $plaintext ) ); + } +} diff --git a/includes/password/ParameterizedPassword.php b/includes/password/ParameterizedPassword.php new file mode 100644 index 0000000000..4d6e415526 --- /dev/null +++ b/includes/password/ParameterizedPassword.php @@ -0,0 +1,115 @@ +:... as explained in the main Password + * class. This class is for hashes in the form of ::::... where + * , , etc. are parameters that determine how the password was hashed. + * Of course, the internal delimiter (which is : by convention and default), can be + * changed by overriding the ParameterizedPassword::getDelimiter() function. + * + * This class requires overriding an additional function: ParameterizedPassword::getDefaultParams(). + * See the function description for more details on the implementation. + * + * @since 1.24 + */ +abstract class ParameterizedPassword extends Password { + /** + * Named parameters that have default values for this password type + * @var array + */ + protected $params = array(); + + /** + * Extra arguments that were found in the hash. This may or may not make + * the hash invalid. + * @var array + */ + protected $args = array(); + + protected function parseHash( $hash ) { + parent::parseHash( $hash ); + + if ( $hash === null ) { + $this->params = $this->getDefaultParams(); + return; + } + + $parts = explode( $this->getDelimiter(), $hash ); + $paramKeys = array_keys( $this->getDefaultParams() ); + + if ( count( $parts ) < count( $paramKeys ) ) { + throw new PasswordError( 'Hash is missing required parameters.' ); + } + + if ( $paramKeys ) { + $this->args = array_splice( $parts, count( $paramKeys ) ); + $this->params = array_combine( $paramKeys, $parts ); + } else { + $this->args = $parts; + } + + if ( $this->args ) { + $this->hash = array_pop( $this->args ); + } else { + $this->hash = null; + } + } + + public function needsUpdate() { + return parent::needsUpdate() || $this->params !== $this->getDefaultParams(); + } + + public function toString() { + return + ':' . $this->config['type'] . ':' . + implode( $this->getDelimiter(), array_merge( $this->params, $this->args ) ) . + $this->getDelimiter() . $this->hash; + } + + /** + * Returns the delimiter for the parameters inside the hash + * + * @return string + */ + abstract protected function getDelimiter(); + + /** + * Return an ordered array of default parameters for this password hash + * + * The keys should be the parameter names and the values should be the default + * values. Additionally, the order of the array should be the order in which they + * appear in the hash. + * + * When parsing a password hash, the constructor will split the hash based on + * the delimiter, and consume as many parts as it can, matching each to a parameter + * in this list. Once all the parameters have been filled, all remaining parts will + * be considered extra arguments, except, of course, for the very last part, which + * is the hash itself. + * + * @return array + */ + abstract protected function getDefaultParams(); +} diff --git a/includes/password/Password.php b/includes/password/Password.php new file mode 100644 index 0000000000..4e395b5182 --- /dev/null +++ b/includes/password/Password.php @@ -0,0 +1,186 @@ +:, where + * is the registered type of the hash. This prefix is stripped in the constructor + * and is added back in the toString() function. + * + * When inheriting this class, there are a couple of expectations + * to be fulfilled: + * * If Password::toString() is called on an object, and the result is passed back in + * to PasswordFactory::newFromCiphertext(), the result will be identical to the original. + * * The string representations of two Password objects are equal only if + * the original plaintext passwords match. In other words, if the toString() result of + * two objects match, the passwords are the same, and the user will be logged in. + * Since the string representation of a hash includes its type name (@see Password::toString), + * this property is preserved across all classes that inherit Password. + * If a hashing scheme does not fulfill this expectation, it must make sure to override the + * Password::equals() function and use custom comparison logic. However, this is not + * recommended unless absolutely required by the hashing mechanism. + * With these two points in mind, when creating a new Password sub-class, there are some functions + * you have to override (because they are abstract) and others that you may want to override. + * + * The abstract functions that must be overridden are: + * * Password::crypt(), which takes a plaintext password and hashes it into a string hash suitable + * for being passed to the constructor of that class, and then stores that hash (and whatever + * other data) into the internal state of the object. + * The functions that can optionally be overridden are: + * * Password::parseHash(), which can be useful to override if you need to extract values from or + * otherwise parse a password hash when it's passed to the constructor. + * * Password::needsUpdate(), which can be useful if a specific password hash has different + * logic for when the hash needs to be updated. + * * Password::toString(), which can be useful if the hash was changed in the constructor and + * needs to be re-assembled before being returned as a string. This function is expected to add + * the type back on to the hash, so make sure to do that if you override the function. + * * Password::equals() - This function compares two Password objects to see if they are equal. + * The default is to just do a timing-safe string comparison on the $this->hash values. + * + * After creating a new password hash type, it can be registered using the static + * Password::register() method. The default type is set using the Password::setDefaultType() type. + * Types must be registered before they can be set as the default. + * + * @since 1.24 + */ +abstract class Password { + /** + * @var PasswordFactory Factory that created the object + */ + protected $factory; + + /** + * String representation of the hash without the type + * @var string + */ + protected $hash; + + /** + * Array of configuration variables injected from the constructor + * @var array + */ + protected $config; + + /** + * Construct the Password object using a string hash + * + * It is strongly recommended not to call this function directly unless you + * have a reason to. Use the PasswordFactory class instead. + * + * @throws MWException If $config does not contain required parameters + * + * @param PasswordFactory $factory Factory object that created the password + * @param array $config Array of engine configuration options for hashing + * @param string|null $hash The raw hash, including the type + */ + final public function __construct( PasswordFactory $factory, array $config, $hash = null ) { + if ( !isset( $config['type'] ) ) { + throw new MWException( 'Password configuration must contain a type name.' ); + } + $this->config = $config; + $this->factory = $factory; + + if ( $hash !== null && strlen( $hash ) >= 3 ) { + // Strip the type from the hash for parsing + $hash = substr( $hash, strpos( $hash, ':', 1 ) + 1 ); + } + + $this->hash = $hash; + $this->parseHash( $hash ); + } + + /** + * Get the type name of the password + * + * @return string Password type + */ + final public function getType() { + return $this->config['type']; + } + + /** + * Perform any parsing necessary on the hash to see if the hash is valid + * and/or to perform logic for seeing if the hash needs updating. + * + * @param string $hash The hash, with the :: prefix stripped + * @throws PasswordError If there is an error in parsing the hash + */ + protected function parseHash( $hash ) { + } + + /** + * Determine if the hash needs to be updated + * + * @return bool True if needs update, false otherwise + */ + public function needsUpdate() { + } + + /** + * Compare one Password object to this object + * + * By default, do a timing-safe string comparison on the result of + * Password::toString() for each object. This can be overridden to do + * custom comparison, but it is not recommended unless necessary. + * + * @param Password|string $other The other password + * @return bool True if equal, false otherwise + */ + public function equals( $other ) { + if ( !$other instanceof self ) { + // No need to use the factory because we're definitely making + // an object of the same type. + $obj = clone $this; + $obj->crypt( $other ); + $other = $obj; + } + + return hash_equals( $this->toString(), $other->toString() ); + } + + /** + * Convert this hash to a string that can be stored in the database + * + * The resulting string should be considered the seralized representation + * of this hash, i.e., if the return value were recycled back into + * PasswordFactory::newFromCiphertext, the returned object would be equivalent to + * this; also, if two objects return the same value from this function, they + * are considered equivalent. + * + * @return string + */ + public function toString() { + return ':' . $this->config['type'] . ':' . $this->hash; + } + + /** + * Hash a password and store the result in this object + * + * The result of the password hash should be put into the internal + * state of the hash object. + * + * @param string $password Password to hash + * @throws PasswordError If an internal error occurs in hashing + */ + abstract public function crypt( $password ); +} diff --git a/includes/password/PasswordFactory.php b/includes/password/PasswordFactory.php new file mode 100644 index 0000000000..7aa78a7e01 --- /dev/null +++ b/includes/password/PasswordFactory.php @@ -0,0 +1,178 @@ + array( 'type' => '', 'class' => 'InvalidPassword' ), + ); + + /** + * Register a new type of password hash + * + * @param string $type Unique type name for the hash + * @param array $config Array of configuration options + */ + public function register( $type, array $config ) { + $config['type'] = $type; + $this->types[$type] = $config; + } + + /** + * Set the default password type + * + * @throws InvalidArgumentException If the type is not registered + * @param string $type Password hash type + */ + public function setDefaultType( $type ) { + if ( !isset( $this->types[$type] ) ) { + throw new InvalidArgumentException( "Invalid password type $type." ); + } + $this->default = $type; + } + + /** + * Initialize the internal static variables using the global variables + * + * @param Config $config Configuration object to load data from + */ + public function init( Config $config ) { + foreach ( $config->get( 'PasswordConfig' ) as $type => $options ) { + $this->register( $type, $options ); + } + + $this->setDefaultType( $config->get( 'PasswordDefault' ) ); + } + + /** + * Get the list of types of passwords + * + * @return array + */ + public function getTypes() { + return $this->types; + } + + /** + * Create a new Hash object from an existing string hash + * + * Parse the type of a hash and create a new hash object based on the parsed type. + * Pass the raw hash to the constructor of the new object. Use InvalidPassword type + * if a null hash is given. + * + * @param string|null $hash Existing hash or null for an invalid password + * @return Password object + * @throws PasswordError if hash is invalid or type is not recognized + */ + public function newFromCiphertext( $hash ) { + if ( $hash === null || $hash === false || $hash === '' ) { + return new InvalidPassword( $this, array( 'type' => '' ), null ); + } elseif ( $hash[0] !== ':' ) { + throw new PasswordError( 'Invalid hash given' ); + } + + $type = substr( $hash, 1, strpos( $hash, ':', 1 ) - 1 ); + if ( !isset( $this->types[$type] ) ) { + throw new PasswordError( "Unrecognized password hash type $type." ); + } + + $config = $this->types[$type]; + + return new $config['class']( $this, $config, $hash ); + } + + /** + * Make a new default password of the given type. + * + * @param string $type Existing type + * @return Password object + * @throws PasswordError if hash is invalid or type is not recognized + */ + public function newFromType( $type ) { + if ( !isset( $this->types[$type] ) ) { + throw new PasswordError( "Unrecognized password hash type $type." ); + } + + $config = $this->types[$type]; + + return new $config['class']( $this, $config ); + } + + /** + * Create a new Hash object from a plaintext password + * + * If no existing object is given, make a new default object. If one is given, clone that + * object. Then pass the plaintext to Password::crypt(). + * + * @param string $password Plaintext password + * @param Password|null $existing Optional existing hash to get options from + * @return Password object + */ + public function newFromPlaintext( $password, Password $existing = null ) { + if ( $existing === null ) { + $config = $this->types[$this->default]; + $obj = new $config['class']( $this, $config ); + } else { + $obj = clone $existing; + } + + $obj->crypt( $password ); + + return $obj; + } + + /** + * Determine whether a password object needs updating + * + * Check whether the given password is of the default type. If it is, + * pass off further needsUpdate checks to Password::needsUpdate. + * + * @param Password $password + * + * @return bool True if needs update, false otherwise + */ + public function needsUpdate( Password $password ) { + if ( $password->getType() !== $this->default ) { + return true; + } else { + return $password->needsUpdate(); + } + } +} diff --git a/includes/password/Pbkdf2Password.php b/includes/password/Pbkdf2Password.php new file mode 100644 index 0000000000..417753f656 --- /dev/null +++ b/includes/password/Pbkdf2Password.php @@ -0,0 +1,85 @@ + $this->config['algo'], + 'rounds' => $this->config['cost'], + 'length' => $this->config['length'] + ); + } + + protected function getDelimiter() { + return ':'; + } + + public function crypt( $password ) { + if ( count( $this->args ) == 0 ) { + $this->args[] = base64_encode( MWCryptRand::generate( 16, true ) ); + } + + if ( function_exists( 'hash_pbkdf2' ) ) { + $hash = hash_pbkdf2( + $this->params['algo'], + $password, + base64_decode( $this->args[0] ), + $this->params['rounds'], + $this->params['length'], + true + ); + } else { + $hashLen = strlen( hash( $this->params['algo'], '', true ) ); + $blockCount = ceil( $this->params['length'] / $hashLen ); + + $hash = ''; + $salt = base64_decode( $this->args[0] ); + for ( $i = 1; $i <= $blockCount; ++$i ) { + $roundTotal = $lastRound = hash_hmac( + $this->params['algo'], + $salt . pack( 'N', $i ), + $password, + true + ); + + for ( $j = 1; $j < $this->params['rounds']; ++$j ) { + $lastRound = hash_hmac( $this->params['algo'], $lastRound, $password, true ); + $roundTotal ^= $lastRound; + } + + $hash .= $roundTotal; + } + + $hash = substr( $hash, 0, $this->params['length'] ); + } + + $this->hash = base64_encode( $hash ); + } +} diff --git a/maintenance/wrapOldPasswords.php b/maintenance/wrapOldPasswords.php new file mode 100644 index 0000000000..37272a018f --- /dev/null +++ b/maintenance/wrapOldPasswords.php @@ -0,0 +1,126 @@ +mDescription = "Wrap all passwords of a certain type in a new layered type"; + $this->addOption( 'type', + 'Password type to wrap passwords in (must inherit LayeredParameterizedPassword)', true, true ); + $this->addOption( 'verbose', 'Enables verbose output', false, false, 'v' ); + $this->setBatchSize( 100 ); + } + + public function execute() { + global $wgAuth; + + if ( !$wgAuth->allowSetLocalPassword() ) { + $this->error( '$wgAuth does not allow local passwords. Aborting.', true ); + } + + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + + $typeInfo = $passwordFactory->getTypes(); + $layeredType = $this->getOption( 'type' ); + + // Check that type exists and is a layered type + if ( !isset( $typeInfo[$layeredType] ) ) { + $this->error( 'Undefined password type', true ); + } + + $passObj = $passwordFactory->newFromType( $layeredType ); + if ( !$passObj instanceof LayeredParameterizedPassword ) { + $this->error( 'Layered parameterized password type must be used.', true ); + } + + // Extract the first layer type + $typeConfig = $typeInfo[$layeredType]; + $firstType = $typeConfig['types'][0]; + + // Get a list of password types that are applicable + $dbw = $this->getDB( DB_MASTER ); + $typeCond = 'user_password' . $dbw->buildLike( ":$firstType:", $dbw->anyString() ); + + $minUserId = 0; + do { + $dbw->begin(); + + $res = $dbw->select( 'user', + array( 'user_id', 'user_name', 'user_password' ), + array( + 'user_id > ' . $dbw->addQuotes( $minUserId ), + $typeCond + ), + __METHOD__, + array( + 'ORDER BY' => 'user_id', + 'LIMIT' => $this->mBatchSize, + 'LOCK IN SHARE MODE', + ) + ); + + /** @var User[] $updateUsers */ + $updateUsers = array(); + foreach ( $res as $row ) { + if ( $this->hasOption( 'verbose' ) ) { + $this->output( "Updating password for user {$row->user_name} ({$row->user_id}).\n" ); + } + + $user = User::newFromId( $row->user_id ); + /** @var ParameterizedPassword $password */ + $password = $passwordFactory->newFromCiphertext( $row->user_password ); + /** @var LayeredParameterizedPassword $layeredPassword */ + $layeredPassword = $passwordFactory->newFromType( $layeredType ); + $layeredPassword->partialCrypt( $password ); + + $updateUsers[] = $user; + $dbw->update( 'user', + array( 'user_password' => $layeredPassword->toString() ), + array( 'user_id' => $row->user_id ), + __METHOD__ + ); + + $minUserId = $row->user_id; + } + + $dbw->commit(); + + // Clear memcached so old passwords are wiped out + foreach ( $updateUsers as $user ) { + $user->clearSharedCache(); + } + } while ( $res->numRows() ); + } +} + +$maintClass = "WrapOldPasswords"; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/tests/TestsAutoLoader.php b/tests/TestsAutoLoader.php index ec5cda6493..eee34de4ba 100644 --- a/tests/TestsAutoLoader.php +++ b/tests/TestsAutoLoader.php @@ -41,6 +41,7 @@ $wgAutoloadClasses += array( 'MediaWikiPHPUnitCommand' => "$testDir/phpunit/MediaWikiPHPUnitCommand.php", 'MediaWikiPHPUnitTestListener' => "$testDir/phpunit/MediaWikiPHPUnitTestListener.php", 'MediaWikiLangTestCase' => "$testDir/phpunit/MediaWikiLangTestCase.php", + 'MediaWikiPasswordTestCase' => "$testDir/phpunit/MediaWikiPasswordTestCase.php", 'ResourceLoaderTestCase' => "$testDir/phpunit/ResourceLoaderTestCase.php", 'ResourceLoaderTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php", 'ResourceLoaderFileModuleTestModule' => "$testDir/phpunit/ResourceLoaderTestCase.php", diff --git a/tests/phpunit/MediaWikiPasswordTestCase.php b/tests/phpunit/MediaWikiPasswordTestCase.php new file mode 100644 index 0000000000..a4cec09938 --- /dev/null +++ b/tests/phpunit/MediaWikiPasswordTestCase.php @@ -0,0 +1,88 @@ +passwordFactory = new PasswordFactory(); + foreach ( $this->getTypeConfigs() as $type => $config ) { + $this->passwordFactory->register( $type, $config ); + } + } + + /** + * Return an array of configs to be used for this class's password type. + * + * @return array[] + */ + abstract protected function getTypeConfigs(); + + /** + * An array of tests in the form of (bool, string, string), where the first + * element is whether the second parameter (a password hash) and the third + * parameter (a password) should match. + * + * @return array + */ + abstract public function providePasswordTests(); + + /** + * @dataProvider providePasswordTests + */ + public function testHashing( $shouldMatch, $hash, $password ) { + $hash = $this->passwordFactory->newFromCiphertext( $hash ); + $password = $this->passwordFactory->newFromPlaintext( $password, $hash ); + $this->assertSame( $shouldMatch, $hash->equals( $password ) ); + } + + /** + * @dataProvider providePasswordTests + */ + public function testStringSerialization( $shouldMatch, $hash, $password ) { + $hashObj = $this->passwordFactory->newFromCiphertext( $hash ); + $serialized = $hashObj->toString(); + $unserialized = $this->passwordFactory->newFromCiphertext( $serialized ); + $this->assertTrue( $hashObj->equals( $unserialized ) ); + } + + /** + * @dataProvider providePasswordTests + * @covers InvalidPassword::equals + * @covers InvalidPassword::toString + */ + public function testInvalidUnequalNormal( $shouldMatch, $hash, $password ) { + $invalid = $this->passwordFactory->newFromCiphertext( null ); + $normal = $this->passwordFactory->newFromCiphertext( $hash ); + + $this->assertFalse( $invalid->equals( $normal ) ); + $this->assertFalse( $normal->equals( $invalid ) ); + } +} diff --git a/tests/phpunit/includes/PasswordTest.php b/tests/phpunit/includes/PasswordTest.php new file mode 100644 index 0000000000..ceb794b592 --- /dev/null +++ b/tests/phpunit/includes/PasswordTest.php @@ -0,0 +1,33 @@ +newFromCiphertext( null ); + $invalid2 = User::getPasswordFactory()->newFromCiphertext( null ); + + $this->assertFalse( $invalid1->equals( $invalid2 ) ); + } +} diff --git a/tests/phpunit/includes/password/BcryptPasswordTest.php b/tests/phpunit/includes/password/BcryptPasswordTest.php new file mode 100644 index 0000000000..b4d5f99691 --- /dev/null +++ b/tests/phpunit/includes/password/BcryptPasswordTest.php @@ -0,0 +1,40 @@ + array( + 'class' => 'BcryptPassword', + 'cost' => 9, + ) ); + } + + public function providePasswordTests() { + /** @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong */ + return array( + // Tests from glibc bcrypt implementation + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "U*U" ), + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$VGOzA784oUp/Z0DY336zx7pLYAy0lwK', "U*U*" ), + array( true, ':bcrypt:5$XXXXXXXXXXXXXXXXXXXXXO$AcXxm9kjPGEMsLznoKqmqw7tc8WCx4a', "U*U*U" ), + array( true, ':bcrypt:5$abcdefghijklmnopqrstuu$5s2v8.iXieOjg/.AySBTTZIIVFJeBui', "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789chars after 72 are ignored" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$CE5elHaaO4EbggVDjb8P19RukzXSM3e', "\xff\xff\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$o./n25XVfn6oAPaUvHe.Csk4zRfsYPi', "\xff\xa334\xff\xff\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$swQOIzjOiJ9GHEPuhEkvqrUyvWhEMx6', "\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaachars after 72 are ignored as usual" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$R9xrDjiycxMbQE2bp.vgqlYpW5wx2yy', "\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$9tQZzcJfm3uj2NvJ/n5xkhpqLrMpWCe', "\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff" ), + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$7uG0VCzI2bS7j6ymqJi9CdcdxiRTWNy', "" ), + // One or two false sanity tests + array( false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "UXU" ), + array( false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "" ), + ); + /** @codingStandardsIgnoreEnd */ + } +} diff --git a/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php b/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php new file mode 100644 index 0000000000..c5522533a6 --- /dev/null +++ b/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php @@ -0,0 +1,51 @@ + array( + 'class' => 'LayeredParameterizedPassword', + 'types' => array( + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredFinal', + ), + ), + 'testLargeLayeredBottom' => array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha512', + 'cost' => 1024, + 'length' => 512, + ), + 'testLargeLayeredFinal' => array( + 'class' => 'BcryptPassword', + 'cost' => 5, + ) + ); + } + + public function providePasswordTests() { + /** @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong */ + return array( + array( true, ':testLargeLayeredTop:sha512:1024:512!sha512:1024:512!sha512:1024:512!sha512:1024:512!5!vnRy+2SrSA0fHt3dwhTP5g==!AVnwfZsAQjn+gULv7FSGjA==!xvHUX3WcpkeSn1lvjWcvBg==!It+OC/N9tu+d3ByHhuB0BQ==!Tb.gqUOiD.aWktVwHM.Q/O!7CcyMfXUPky5ptyATJsR2nq3vUqtnBC', 'testPassword123' ), + ); + /** @codingStandardsIgnoreEnd */ + } + + /** + * @covers LayeredParameterizedPassword::partialCrypt + */ + public function testLargeLayeredPartialUpdate() { + /** @var ParameterizedPassword $partialPassword */ + $partialPassword = $this->passwordFactory->newFromType( 'testLargeLayeredBottom' ); + $partialPassword->crypt( 'testPassword123' ); + + /** @var LayeredParameterizedPassword $totalPassword */ + $totalPassword = $this->passwordFactory->newFromType( 'testLargeLayeredTop' ); + $totalPassword->partialCrypt( $partialPassword ); + + $this->assertTrue( $totalPassword->equals( 'testPassword123' ) ); + } +} diff --git a/tests/phpunit/includes/password/Pbkdf2PasswordTest.php b/tests/phpunit/includes/password/Pbkdf2PasswordTest.php new file mode 100644 index 0000000000..c1b65d3c26 --- /dev/null +++ b/tests/phpunit/includes/password/Pbkdf2PasswordTest.php @@ -0,0 +1,24 @@ + array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha256', + 'cost' => '10000', + 'length' => '128', + ) ); + } + + public function providePasswordTests() { + return array( + array( true, ":pbkdf2:sha1:1:20:c2FsdA==:DGDID5YfDnHzqbUkr2ASBi/gN6Y=", 'password' ), + array( true, ":pbkdf2:sha1:2:20:c2FsdA==:6mwBTcctb4zNHtkqzh1B8NjeiVc=", 'password' ), + array( true, ":pbkdf2:sha1:4096:20:c2FsdA==:SwB5AbdlSJq+rUnZJvch0GWkKcE=", 'password' ), + array( true, ":pbkdf2:sha1:4096:16:c2EAbHQ=:Vvpqp1VICZ3MN9fwNCXgww==", "pass\x00word" ), + ); + } +} -- 2.20.1