From c1f9b1f7b1b77776192048005dcc66dcf3df2bfb Mon Sep 17 00:00:00 2001 From: Pierre Schmitz Date: Sat, 27 Dec 2014 15:41:37 +0100 Subject: Update to MediaWiki 1.24.1 --- includes/password/BcryptPassword.php | 88 ++++++++++ includes/password/EncryptedPassword.php | 98 +++++++++++ includes/password/InvalidPassword.php | 47 ++++++ includes/password/LayeredParameterizedPassword.php | 140 ++++++++++++++++ includes/password/MWOldPassword.php | 48 ++++++ includes/password/MWSaltedPassword.php | 46 +++++ includes/password/ParameterizedPassword.php | 119 +++++++++++++ includes/password/Password.php | 186 +++++++++++++++++++++ includes/password/PasswordError.php | 28 ++++ includes/password/PasswordFactory.php | 178 ++++++++++++++++++++ includes/password/Pbkdf2Password.php | 85 ++++++++++ 11 files changed, 1063 insertions(+) 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/PasswordError.php create mode 100644 includes/password/PasswordFactory.php create mode 100644 includes/password/Pbkdf2Password.php (limited to 'includes/password') diff --git a/includes/password/BcryptPassword.php b/includes/password/BcryptPassword.php new file mode 100644 index 00000000..dd806e26 --- /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 00000000..39da32d1 --- /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 00000000..e45b7744 --- /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 00000000..afa5cacc --- /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 00000000..6c6895a2 --- /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 00000000..187f8954 --- /dev/null +++ b/includes/password/ParameterizedPassword.php @@ -0,0 +1,119 @@ +:... 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() { + $str = ':' . $this->config['type'] . ':'; + + if ( count( $this->params ) || count( $this->args ) ) { + $str .= implode( $this->getDelimiter(), array_merge( $this->params, $this->args ) ); + $str .= $this->getDelimiter(); + } + + return $str . $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 00000000..4e395b51 --- /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/PasswordError.php b/includes/password/PasswordError.php new file mode 100644 index 00000000..c9707adb --- /dev/null +++ b/includes/password/PasswordError.php @@ -0,0 +1,28 @@ + 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 + * @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 + * @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 + */ + 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 00000000..080e3b0d --- /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] ), + (int)$this->params['rounds'], + (int)$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 ); + } +} -- cgit v1.2.2