Added password hashing API
authorTyler Anthony Romeo <tylerromeo@gmail.com>
Wed, 31 Jul 2013 05:11:18 +0000 (05:11 +0000)
committerTyler Romeo <tylerromeo@gmail.com>
Sun, 27 Jul 2014 19:51:18 +0000 (15:51 -0400)
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

22 files changed:
RELEASE-NOTES-1.24
docs/hooks.txt
includes/AutoLoader.php
includes/DefaultSettings.php
includes/User.php
includes/password/BcryptPassword.php [new file with mode: 0644]
includes/password/EncryptedPassword.php [new file with mode: 0644]
includes/password/InvalidPassword.php [new file with mode: 0644]
includes/password/LayeredParameterizedPassword.php [new file with mode: 0644]
includes/password/MWOldPassword.php [new file with mode: 0644]
includes/password/MWSaltedPassword.php [new file with mode: 0644]
includes/password/ParameterizedPassword.php [new file with mode: 0644]
includes/password/Password.php [new file with mode: 0644]
includes/password/PasswordFactory.php [new file with mode: 0644]
includes/password/Pbkdf2Password.php [new file with mode: 0644]
maintenance/wrapOldPasswords.php [new file with mode: 0644]
tests/TestsAutoLoader.php
tests/phpunit/MediaWikiPasswordTestCase.php [new file with mode: 0644]
tests/phpunit/includes/PasswordTest.php [new file with mode: 0644]
tests/phpunit/includes/password/BcryptPasswordTest.php [new file with mode: 0644]
tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php [new file with mode: 0644]
tests/phpunit/includes/password/Pbkdf2PasswordTest.php [new file with mode: 0644]

index 119f1a2..a74e76d 100644 (file)
@@ -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
index 3ee1221..dd9f905 100644 (file)
@@ -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
index ab5af1b..1d76e71 100644 (file)
@@ -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',
index 3ef59b3..9df920b 100644 (file)
@@ -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
index fa20ebd..73d4959 100644 (file)
@@ -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 (file)
index 0000000..4e5e878
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Implements the BcryptPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A Bcrypt-hashed password
+ *
+ * This is a computationally complex password hash for use in modern applications.
+ * The number of rounds can be configured by $wgPasswordCost.
+ *
+ * @since 1.24
+ */
+class BcryptPassword extends ParameterizedPassword {
+       protected function getDefaultParams() {
+               return array(
+                       'rounds' => $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 (file)
index 0000000..39da32d
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Implements the EncryptedPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Helper class for passwords that use another password hash underneath it
+ * and encrypts that hash with a configured secret.
+ *
+ * @since 1.24
+ */
+class EncryptedPassword extends ParameterizedPassword {
+       protected function getDelimiter() {
+               return ':';
+       }
+
+       protected function getDefaultParams() {
+               return array(
+                       'cipher' => $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 (file)
index 0000000..e45b774
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Implements the InvalidPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Represents an invalid password hash. It is represented as the empty string (i.e.,
+ * a password hash with no type).
+ *
+ * No two invalid passwords are equal. Comparing anything to an invalid password will
+ * return false.
+ *
+ * @since 1.24
+ */
+class InvalidPassword extends Password {
+       public function crypt( $plaintext ) {
+       }
+
+       public function toString() {
+               return '';
+       }
+
+       public function equals( $other ) {
+               return false;
+       }
+
+       public function needsUpdate() {
+               return false;
+       }
+}
diff --git a/includes/password/LayeredParameterizedPassword.php b/includes/password/LayeredParameterizedPassword.php
new file mode 100644 (file)
index 0000000..5735e28
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Implements the LayeredParameterizedPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * This password hash type layers one or more parameterized password types
+ * on top of each other.
+ *
+ * The underlying types must be parameterized. This wrapping type accumulates
+ * all the parameters and arguments from each hash and then passes the hash of
+ * the last layer as the password for the next layer.
+ *
+ * @since 1.24
+ */
+class LayeredParameterizedPassword extends ParameterizedPassword {
+       protected function getDelimiter() {
+               return '!';
+       }
+
+       protected function getDefaultParams() {
+               $params = array();
+
+               foreach ( $this->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 (file)
index 0000000..0ba407f
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+/**
+ * Implements the MWOldPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * The old style of MediaWiki password hashing. It involves
+ * running MD5 on the password.
+ *
+ * @since 1.24
+ */
+class MWOldPassword extends ParameterizedPassword {
+       protected function getDefaultParams() {
+               return array();
+       }
+
+       protected function getDelimiter() {
+               return ':';
+       }
+
+       public function crypt( $plaintext ) {
+               global $wgPasswordSalt;
+
+               if ( $wgPasswordSalt && count( $this->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 (file)
index 0000000..6c6895a
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+/**
+ * Implements the BcryptPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * The old style of MediaWiki password hashing, with a salt. It involves
+ * running MD5 on the password, and then running MD5 on the salt concatenated
+ * with the first hash.
+ *
+ * @since 1.24
+ */
+class MWSaltedPassword extends ParameterizedPassword {
+       protected function getDefaultParams() {
+               return array();
+       }
+
+       protected function getDelimiter() {
+               return ':';
+       }
+
+       public function crypt( $plaintext ) {
+               if ( count( $this->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 (file)
index 0000000..4d6e415
--- /dev/null
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Implements the ParameterizedPassword class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Helper class for password hash types that have a delimited set of parameters
+ * inside of the hash.
+ *
+ * All passwords are in the form of :<TYPE>:... as explained in the main Password
+ * class. This class is for hashes in the form of :<TYPE>:<PARAM1>:<PARAM2>:... where
+ * <PARAM1>, <PARAM2>, 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 (file)
index 0000000..4e395b5
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Implements the Password class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Represents a password hash for use in authentication
+ *
+ * Note: All password types are transparently prefixed with :<TYPE>:, where <TYPE>
+ * 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 :<TYPE>: 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 (file)
index 0000000..7aa78a7
--- /dev/null
@@ -0,0 +1,178 @@
+<?php
+/**
+ * Implements the Password class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Factory class for creating and checking Password objects
+ *
+ * @since 1.24
+ */
+final class PasswordFactory {
+       /**
+        * The default PasswordHash type
+        *
+*@var string
+        * @see PasswordFactory::setDefaultType
+        */
+       private $default = '';
+
+       /**
+        * Mapping of password types to classes
+        * @var array
+        * @see PasswordFactory::register
+        * @see Setup.php
+        */
+       private $types = array(
+               '' => 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 (file)
index 0000000..417753f
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Implements the Pbkdf2Password class for the MediaWiki software.
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * A PBKDF2-hashed password
+ *
+ * This is a computationally complex password hash for use in modern applications.
+ * The number of rounds can be configured by $wgPasswordConfig['pbkdf2']['cost'].
+ *
+ * @since 1.24
+ */
+class Pbkdf2Password extends ParameterizedPassword {
+       protected function getDefaultParams() {
+               return array(
+                       'algo' => $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 (file)
index 0000000..37272a0
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+/**
+ * Maintenance script to wrap all old-style passwords in a layered type
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Maintenance
+ */
+require_once __DIR__ . '/Maintenance.php';
+
+/**
+ * Maintenance script to wrap all passwords of a certain type in a specified layered
+ * type that wraps around the old type.
+ *
+ * @since 1.24
+ * @ingroup Maintenance
+ */
+class WrapOldPasswords extends Maintenance {
+       public function __construct() {
+               parent::__construct();
+               $this->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;
index ec5cda6..eee34de 100644 (file)
@@ -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 (file)
index 0000000..a4cec09
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+/**
+ * Testing framework for the password hashes
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @since 1.23
+ */
+abstract class MediaWikiPasswordTestCase extends MediaWikiTestCase {
+       /**
+        * @var PasswordFactory
+        */
+       protected $passwordFactory;
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->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 (file)
index 0000000..ceb794b
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Testing framework for the Password infrastructure
+ *
+ * This program 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 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program 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 this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+class PasswordTest extends MediaWikiTestCase {
+       /**
+        * @covers InvalidPassword::equals
+        */
+       public function testInvalidUnequalInvalid() {
+               $invalid1 = User::getPasswordFactory()->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 (file)
index 0000000..b4d5f99
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @group large
+ */
+class BcryptPasswordTestCase extends MediaWikiPasswordTestCase {
+       protected function getTypeConfigs() {
+               return array( 'bcrypt' => 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 (file)
index 0000000..c552253
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+class LayeredParameterizedPasswordTest extends MediaWikiPasswordTestCase {
+       protected function getTypeConfigs() {
+               return array(
+                       'testLargeLayeredTop' => 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 (file)
index 0000000..c1b65d3
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @group large
+ */
+class Pbkdf2PasswordTest extends MediaWikiPasswordTestCase {
+       protected function getTypeConfigs() {
+               return array( 'pbkdf2' => 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" ),
+               );
+       }
+}