Verified Commit 375376d5 authored by Alex Pott's avatar Alex Pott
Browse files

Issue #1845004 by znerol, claudiu.cristea, Spokje, neclimdul, VladimirAus,...

Issue #1845004 by znerol, claudiu.cristea, Spokje, neclimdul, VladimirAus, slasher13, tvb, pwolanin, NivethaSubramaniyan, pradhumanjainOSL, Ratan Priya, longwave, catch, Crell, andypost, xjm, dawehner, voleger, alexpott, Berdir, klausi, DamienMcKenna, mfb, borisson_, Jalandhar, mohit1604, mcdruid: Replace custom password hashing library with PHP password_hash()
parent a14a4fbe
Loading
Loading
Loading
Loading
+1 −9
Original line number Diff line number Diff line
@@ -1080,16 +1080,8 @@ services:
    class: Drupal\Core\Path\PathValidator
    arguments: ['@router', '@router.no_access_checks', '@current_user', '@path_processor_manager']
  Drupal\Core\Path\PathValidatorInterface: '@path.validator'

  # The argument to the hashing service defined in services.yml, to the
  # constructor of PhpassHashedPassword is the log2 number of iterations for
  # password stretching.
  # @todo increase by 1 every Drupal version in order to counteract increases in
  # the speed and power of computers available to crack the hashes. The current
  # password hashing method was introduced in Drupal 7 with a log2 count of 15.
  password:
    class: Drupal\Core\Password\PhpassHashedPassword
    arguments: [16]
    class: Drupal\Core\Password\PhpPassword
  Drupal\Core\Password\PasswordInterface: '@password'
  password_generator:
    class: Drupal\Core\Password\DefaultPasswordGenerator
+8 −8
Original line number Diff line number Diff line
@@ -39,19 +39,19 @@ public function check(#[\SensitiveParameter] $password, #[\SensitiveParameter] $
  /**
   * Check whether a hashed password needs to be replaced with a new hash.
   *
   * This is typically called during the login process when the plain text
   * password is available. A new hash is needed when the desired iteration
   * count has changed by a modification of the password-service in the
   * dependency injection container or if the user's password hash was
   * generated in an update like user_update_7000() (see the Drupal 7
   * documentation).
   * This is typically called during the login process in order to trigger the
   * rehashing of the password, as in that stage, the plain text password is
   * available.
   *
   * This method returns TRUE if the password was hashed with an older
   * algorithm.
   *
   * @param string $hash
   *   The existing hash to be checked.
   *   The hash to be checked.
   *
   * @return bool
   *   TRUE if the hash is outdated and needs rehash.
   */
  public function needsRehash($hash);
  public function needsRehash(#[\SensitiveParameter] $hash);

}
+59 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Password;

/**
 * Secure PHP password hashing functions.
 *
 * @see https://www.php.net/manual/en/book.password.php
 */
class PhpPassword implements PasswordInterface {

  /**
   * Constructs a new password hashing instance.
   *
   * @param string $algorithm
   *   The hashing algorithm to use. Defaults to PHP default.
   * @param array $options
   *   List of options. Refer to password_hash() for available options.
   *
   * @see https://www.php.net/password_hash
   */
  public function __construct(
    protected string $algorithm = PASSWORD_DEFAULT,
    protected array $options = []
  ) {
  }

  /**
   * {@inheritdoc}
   */
  public function hash(#[\SensitiveParameter] $password) {
    // Prevent DoS attacks by refusing to hash large passwords.
    if (strlen($password) > static::PASSWORD_MAX_LENGTH) {
      return FALSE;
    }

    return password_hash($password, $this->algorithm, $this->options);
  }

  /**
   * {@inheritdoc}
   */
  public function check(#[\SensitiveParameter] $password, #[\SensitiveParameter] $hash) {
    // Prevent DoS attacks by refusing to check large passwords.
    if (strlen($password) > static::PASSWORD_MAX_LENGTH) {
      return FALSE;
    }

    return password_verify($password, $hash);
  }

  /**
   * {@inheritdoc}
   */
  public function needsRehash(#[\SensitiveParameter] $hash) {
    return password_needs_rehash($hash, $this->algorithm, $this->options);
  }

}
+9 −265
Original line number Diff line number Diff line
@@ -2,271 +2,15 @@

namespace Drupal\Core\Password;

/**
 * Secure hashing functions based on Portable PHP password hashing framework.
 *
 * @see http://www.openwall.com/phpass/
 */
class PhpassHashedPassword implements PasswordInterface {
  /**
   * The minimum allowed log2 number of iterations for password stretching.
   */
  const MIN_HASH_COUNT = 7;

  /**
   * The maximum allowed log2 number of iterations for password stretching.
   */
  const MAX_HASH_COUNT = 30;

  /**
   * The expected (and maximum) number of characters in a hashed password.
   */
  const HASH_LENGTH = 55;

  /**
   * Returns a string for mapping an int to the corresponding base 64 character.
   *
   * @var string
   */
  public static $ITOA64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

  /**
   * Password stretching iteration count.
   *
   * Specifies the number of times the hashing function will be applied when
   * generating new password hashes. The number of times is calculated by
   * raising 2 to the power of the given value.
   *
   * @var int
   */
  protected $countLog2;

  /**
   * Constructs a new password hashing instance.
   *
   * @param int $countLog2
   *   Password stretching iteration count. Specifies the number of times the
   *   hashing function will be applied when generating new password hashes.
   *   The number of times is calculated by raising 2 to the power of the given
   *   value.
   */
  public function __construct($countLog2) {
    // Ensure that $countLog2 is within set bounds.
    $this->countLog2 = $this->enforceLog2Boundaries($countLog2);
  }

  /**
   * Encodes bytes into printable base 64 using the *nix standard from crypt().
   *
   * @param string $input
   *   The string containing bytes to encode.
   * @param int $count
   *   The number of characters (bytes) to encode.
   *
   * @return string
   *   Encoded string.
   */
  protected function base64Encode($input, $count) {
    $output = '';
    $i = 0;
    do {
      $value = ord($input[$i++]);
      $output .= static::$ITOA64[$value & 0x3f];
      if ($i < $count) {
        $value |= ord($input[$i]) << 8;
      }
      $output .= static::$ITOA64[($value >> 6) & 0x3f];
      if ($i++ >= $count) {
        break;
      }
      if ($i < $count) {
        $value |= ord($input[$i]) << 16;
      }
      $output .= static::$ITOA64[($value >> 12) & 0x3f];
      if ($i++ >= $count) {
        break;
      }
      $output .= static::$ITOA64[($value >> 18) & 0x3f];
    } while ($i < $count);

    return $output;
  }

  /**
   * Generates a random base 64-encoded salt prefixed with hash settings.
   *
   * Proper use of salts may defeat a number of attacks, including:
   *  - The ability to try candidate passwords against multiple hashes at once.
   *  - The ability to use pre-hashed lists of candidate passwords.
   *  - The ability to determine whether two users have the same (or different)
   *    password without actually having to guess one of the passwords.
   *
   * @return string
   *   A 12 character string containing the iteration count and a random salt.
   */
  protected function generateSalt() {
    $output = '$S$';
    // We encode the final log2 iteration count in base 64.
    $output .= static::$ITOA64[$this->countLog2];
    // 6 bytes is the standard salt for a portable phpass hash.
    $output .= $this->base64Encode(random_bytes(6), 6);
    return $output;
  }
@trigger_error('\Drupal\Core\Password\PhpassHashedPassword is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. The password compatibility service has been moved to the phpass module. Use \Drupal\phpass\Password\PhpassHashedPassword instead. See https://www.drupal.org/node/3322420', E_USER_DEPRECATED);

/**
   * Ensures that $count_log2 is within set bounds.
   *
   * @param int $count_log2
   *   Integer that determines the number of iterations used in the hashing
   *   process. A larger value is more secure, but takes more time to complete.
   *
   * @return int
   *   Integer within set bounds that is closest to $count_log2.
   */
  protected function enforceLog2Boundaries($count_log2) {
    if ($count_log2 < static::MIN_HASH_COUNT) {
      return static::MIN_HASH_COUNT;
    }
    elseif ($count_log2 > static::MAX_HASH_COUNT) {
      return static::MAX_HASH_COUNT;
    }

    return (int) $count_log2;
  }

  /**
   * Hash a password using a secure stretched hash.
   *
   * By using a salt and repeated hashing the password is "stretched". Its
   * security is increased because it becomes much more computationally costly
   * for an attacker to try to break the hash by brute-force computation of the
   * hashes of a large number of plain-text words or strings to find a match.
 * Deprecated legacy password hashing framework.
 *
   * @param string $algo
   *   The string name of a hashing algorithm usable by hash(), like 'sha256'.
   * @param string $password
   *   Plain-text password up to 512 bytes (128 to 512 UTF-8 characters) to
   *   hash.
   * @param string $setting
   *   An existing hash or the output of $this->generateSalt(). Must be at least
   *   12 characters (the settings and salt).
 * @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. The
 *   password compatibility service has been moved to the phpass module.
 *   Use \Drupal\phpass\Password\PhpassHashedPassword instead.
 *
   * @return string
   *   A string containing the hashed password (and salt) or FALSE on failure.
   *   The return string will be truncated at HASH_LENGTH characters max.
 * @see https://www.drupal.org/node/3322420
 */
  protected function crypt($algo, #[\SensitiveParameter] $password, $setting) {
    // Prevent DoS attacks by refusing to hash large passwords.
    if (strlen($password) > PasswordInterface::PASSWORD_MAX_LENGTH) {
      return FALSE;
    }

    // The first 12 characters of an existing hash are its setting string.
    $setting = substr($setting, 0, 12);

    if ($setting[0] != '$' || $setting[2] != '$') {
      return FALSE;
    }
    $count_log2 = $this->getCountLog2($setting);
    // Stored hashes may have been encrypted with any iteration count. However
    // we do not allow applying the algorithm for unreasonable low and high
    // values respectively.
    if ($count_log2 != $this->enforceLog2Boundaries($count_log2)) {
      return FALSE;
    }
    $salt = substr($setting, 4, 8);
    // Hashes must have an 8 character salt.
    if (strlen($salt) != 8) {
      return FALSE;
    }

    // Convert the base 2 logarithm into an integer.
    $count = 1 << $count_log2;

    $hash = hash($algo, $salt . $password, TRUE);
    do {
      $hash = hash($algo, $hash . $password, TRUE);
    } while (--$count);

    $len = strlen($hash);
    $output = $setting . $this->base64Encode($hash, $len);
    // $this->base64Encode() of a 16 byte MD5 will always be 22 characters.
    // $this->base64Encode() of a 64 byte sha512 will always be 86 characters.
    $expected = 12 + ceil((8 * $len) / 6);
    return (strlen($output) == $expected) ? substr($output, 0, static::HASH_LENGTH) : FALSE;
  }

  /**
   * Parses the log2 iteration count from a stored hash or setting string.
   *
   * @param string $setting
   *   An existing hash or the output of $this->generateSalt(). Must be at least
   *   12 characters (the settings and salt).
   *
   * @return int
   *   The log2 iteration count.
   */
  public function getCountLog2($setting) {
    return strpos(static::$ITOA64, $setting[3]);
  }

  /**
   * {@inheritdoc}
   */
  public function hash(#[\SensitiveParameter] $password) {
    return $this->crypt('sha512', $password, $this->generateSalt());
  }

  /**
   * {@inheritdoc}
   */
  public function check(#[\SensitiveParameter] $password, #[\SensitiveParameter] $hash) {
    if (substr($hash, 0, 2) == 'U$') {
      // This may be an updated password from user_update_7000(). Such hashes
      // have 'U' added as the first character and need an extra md5() (see the
      // Drupal 7 documentation).
      $stored_hash = substr($hash, 1);
      $password = md5($password);
    }
    else {
      $stored_hash = $hash;
    }

    $type = substr($stored_hash, 0, 3);
    switch ($type) {
      case '$S$':
        // A normal Drupal 7 password using sha512.
        $computed_hash = $this->crypt('sha512', $password, $stored_hash);
        break;

      case '$H$':
        // phpBB3 uses "$H$" for the same thing as "$P$".
      case '$P$':
        // A phpass password generated using md5.  This is an
        // imported password or from an earlier Drupal version.
        $computed_hash = $this->crypt('md5', $password, $stored_hash);
        break;

      default:
        return FALSE;
    }

    // Compare using hash_equals() instead of === to mitigate timing attacks.
    return $computed_hash && hash_equals($stored_hash, $computed_hash);
  }

  /**
   * {@inheritdoc}
   */
  public function needsRehash(#[\SensitiveParameter] $hash) {
    // Check whether this was an updated password.
    if ((substr($hash, 0, 3) != '$S$') || (strlen($hash) != static::HASH_LENGTH)) {
      return TRUE;
    }
    // Ensure that $count_log2 is within set bounds.
    $count_log2 = $this->enforceLog2Boundaries($this->countLog2);
    // Check whether the iteration count used differs from the standard number.
    return ($this->getCountLog2($hash) !== $count_log2);
  }

}
class PhpassHashedPassword extends PhpassHashedPasswordBase {}
+301 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Password;

/**
 * Legacy password hashing framework.
 *
 * @internal
 *
 * @see https://www.drupal.org/node/3322420
 */
abstract class PhpassHashedPasswordBase implements PasswordInterface {

  /**
   * The minimum allowed log2 number of iterations for password stretching.
   */
  const MIN_HASH_COUNT = 7;

  /**
   * The maximum allowed log2 number of iterations for password stretching.
   */
  const MAX_HASH_COUNT = 30;

  /**
   * The expected (and maximum) number of characters in a hashed password.
   */
  const HASH_LENGTH = 55;

  /**
   * Returns a string for mapping an int to the corresponding base 64 character.
   *
   * @var string
   */
  public static $ITOA64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

  /**
   * Password stretching iteration count.
   *
   * Specifies the number of times the hashing function will be applied when
   * generating new password hashes. The number of times is calculated by
   * raising 2 to the power of the given value.
   *
   * @var int
   */
  protected $countLog2;

  /**
   * The core PHP password interface.
   */
  protected ?PasswordInterface $corePassword;

  /**
   * Constructs a new password hashing instance.
   *
   * @param \Drupal\Core\Password\PasswordInterface|int $corePassword
   *   The core PHP password interface (or the countLog2 value for BC).
   */
  public function __construct(PasswordInterface|int $corePassword) {
    if ($corePassword instanceof PasswordInterface) {
      // Note: If $corePassword is set, $countLog2 isn't used anywhere in the
      // code path of this class. Still, set it to the default value for BC
      // reasons.
      $this->countLog2 = 16;
      $this->corePassword = $corePassword;
    }
    else {
      $countLog2 = $corePassword;
      @trigger_error('Calling ' . __METHOD__ . '() with numeric $countLog2 as the first parameter is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use PhpassHashedPasswordInterface::__construct() with $corePassword parameter set to an instance of Drupal\Core\Password\PhpPassword instead. See https://www.drupal.org/node/3322420', E_USER_DEPRECATED);
      // Ensure that $countLog2 is within set bounds.
      $this->countLog2 = $this->enforceLog2Boundaries($countLog2);
      $this->corePassword = NULL;
    }
  }

  /**
   * Encodes bytes into printable base 64 using the *nix standard from crypt().
   *
   * @param string $input
   *   The string containing bytes to encode.
   * @param int $count
   *   The number of characters (bytes) to encode.
   *
   * @return string
   *   Encoded string.
   */
  protected function base64Encode($input, $count) {
    $output = '';
    $i = 0;
    do {
      $value = ord($input[$i++]);
      $output .= static::$ITOA64[$value & 0x3f];
      if ($i < $count) {
        $value |= ord($input[$i]) << 8;
      }
      $output .= static::$ITOA64[($value >> 6) & 0x3f];
      if ($i++ >= $count) {
        break;
      }
      if ($i < $count) {
        $value |= ord($input[$i]) << 16;
      }
      $output .= static::$ITOA64[($value >> 12) & 0x3f];
      if ($i++ >= $count) {
        break;
      }
      $output .= static::$ITOA64[($value >> 18) & 0x3f];
    } while ($i < $count);

    return $output;
  }

  /**
   * Generates a random base 64-encoded salt prefixed with hash settings.
   *
   * Proper use of salts may defeat a number of attacks, including:
   *  - The ability to try candidate passwords against multiple hashes at once.
   *  - The ability to use pre-hashed lists of candidate passwords.
   *  - The ability to determine whether two users have the same (or different)
   *    password without actually having to guess one of the passwords.
   *
   * @return string
   *   A 12 character string containing the iteration count and a random salt.
   */
  protected function generateSalt() {
    $output = '$S$';
    // We encode the final log2 iteration count in base 64.
    $output .= static::$ITOA64[$this->countLog2];
    // 6 bytes is the standard salt for a portable phpass hash.
    $output .= $this->base64Encode(random_bytes(6), 6);
    return $output;
  }

  /**
   * Ensures that $count_log2 is within set bounds.
   *
   * @param int $count_log2
   *   Integer that determines the number of iterations used in the hashing
   *   process. A larger value is more secure, but takes more time to complete.
   *
   * @return int
   *   Integer within set bounds that is closest to $count_log2.
   */
  protected function enforceLog2Boundaries($count_log2) {
    if ($count_log2 < static::MIN_HASH_COUNT) {
      return static::MIN_HASH_COUNT;
    }
    elseif ($count_log2 > static::MAX_HASH_COUNT) {
      return static::MAX_HASH_COUNT;
    }

    return (int) $count_log2;
  }

  /**
   * Hash a password using a secure stretched hash.
   *
   * By using a salt and repeated hashing the password is "stretched". Its
   * security is increased because it becomes much more computationally costly
   * for an attacker to try to break the hash by brute-force computation of the
   * hashes of a large number of plain-text words or strings to find a match.
   *
   * @param string $algo
   *   The string name of a hashing algorithm usable by hash(), like 'sha256'.
   * @param string $password
   *   Plain-text password up to 512 bytes (128 to 512 UTF-8 characters) to
   *   hash.
   * @param string $setting
   *   An existing hash or the output of $this->generateSalt(). Must be at least
   *   12 characters (the settings and salt).
   *
   * @return string
   *   A string containing the hashed password (and salt) or FALSE on failure.
   *   The return string will be truncated at HASH_LENGTH characters max.
   */
  protected function crypt($algo, #[\SensitiveParameter] $password, $setting) {
    // Prevent DoS attacks by refusing to hash large passwords.
    if (strlen($password) > PasswordInterface::PASSWORD_MAX_LENGTH) {
      return FALSE;
    }

    // The first 12 characters of an existing hash are its setting string.
    $setting = substr($setting, 0, 12);

    if ($setting[0] != '$' || $setting[2] != '$') {
      return FALSE;
    }
    $count_log2 = $this->getCountLog2($setting);
    // Stored hashes may have been encrypted with any iteration count. However
    // we do not allow applying the algorithm for unreasonable low and high
    // values respectively.
    if ($count_log2 != $this->enforceLog2Boundaries($count_log2)) {
      return FALSE;
    }
    $salt = substr($setting, 4, 8);
    // Hashes must have an 8 character salt.
    if (strlen($salt) != 8) {
      return FALSE;
    }

    // Convert the base 2 logarithm into an integer.
    $count = 1 << $count_log2;

    $hash = hash($algo, $salt . $password, TRUE);
    do {
      $hash = hash($algo, $hash . $password, TRUE);
    } while (--$count);

    $len = strlen($hash);
    $output = $setting . $this->base64Encode($hash, $len);
    // $this->base64Encode() of a 16 byte MD5 will always be 22 characters.
    // $this->base64Encode() of a 64 byte sha512 will always be 86 characters.
    $expected = 12 + ceil((8 * $len) / 6);
    return (strlen($output) == $expected) ? substr($output, 0, static::HASH_LENGTH) : FALSE;
  }

  /**
   * Parses the log2 iteration count from a stored hash or setting string.
   *
   * @param string $setting
   *   An existing hash or the output of $this->generateSalt(). Must be at least
   *   12 characters (the settings and salt).
   *
   * @return int
   *   The log2 iteration count.
   */
  public function getCountLog2($setting) {
    return strpos(static::$ITOA64, $setting[3]);
  }

  /**
   * {@inheritdoc}
   */
  public function hash(#[\SensitiveParameter] $password) {
    if (isset($this->corePassword)) {
      return $this->corePassword->hash($password);
    }

    return $this->crypt('sha512', $password, $this->generateSalt());
  }

  /**
   * {@inheritdoc}
   */
  public function check(#[\SensitiveParameter] $password, #[\SensitiveParameter] $hash) {
    if (substr($hash, 0, 2) == 'U$') {
      // This may be an updated password from user_update_7000(). Such hashes
      // have 'U' added as the first character and need an extra md5() (see the
      // Drupal 7 documentation).
      $stored_hash = substr($hash, 1);
      $password = md5($password);
    }
    else {
      $stored_hash = $hash;
    }

    $type = substr($stored_hash, 0, 3);
    switch ($type) {
      case '$S$':
        // A normal Drupal 7 password using sha512.
        $computed_hash = $this->crypt('sha512', $password, $stored_hash);
        break;

      case '$H$':
        // phpBB3 uses "$H$" for the same thing as "$P$".
      case '$P$':
        // A phpass password generated using md5.  This is an
        // imported password or from an earlier Drupal version.
        $computed_hash = $this->crypt('md5', $password, $stored_hash);
        break;

      default:
        if (isset($this->corePassword)) {
          return $this->corePassword->check($password, $stored_hash);
        }

        return FALSE;
    }

    // Compare using hash_equals() instead of === to mitigate timing attacks.
    return $computed_hash && hash_equals($stored_hash, $computed_hash);
  }

  /**
   * {@inheritdoc}
   */
  public function needsRehash(#[\SensitiveParameter] $hash) {
    if (isset($this->corePassword)) {
      return $this->corePassword->needsRehash($hash);
    }

    // Check whether this was an updated password.
    if ((substr($hash, 0, 3) != '$S$') || (strlen($hash) != static::HASH_LENGTH)) {
      return TRUE;
    }
    // Ensure that $count_log2 is within set bounds.
    $count_log2 = $this->enforceLog2Boundaries($this->countLog2);
    // Check whether the iteration count used differs from the standard number.
    return ($this->getCountLog2($hash) !== $count_log2);
  }

}
Loading