From 4e19107223b64fe6568a62d608ae21bca0896660 Mon Sep 17 00:00:00 2001 From: User Name Date: Thu, 22 Oct 2026 22:02:13 +0900 Subject: [PATCH 1/2] fix(Security\Hasher): Guard PASSWORD_ARGON2 constants with defined() check Guard constant references with defined() checks so that systems without Argon2 support can safely fall back to bcrypt without fatal errors. Follow-up to #64733 Fixes #65780 Signed-off-by: User Name --- lib/private/Security/Hasher.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/private/Security/Hasher.php b/lib/private/Security/Hasher.php index 2bbcd2fa6fa22..8e2950a5ac4dd 100644 --- a/lib/private/Security/Hasher.php +++ b/lib/private/Security/Hasher.php @@ -74,10 +74,11 @@ public function __construct( public function hash(string $message): string { $alg = $this->getPrefferedAlgorithm(); - $version = match ($alg) { - PASSWORD_ARGON2ID => 3, - PASSWORD_ARGON2I => 2, - PASSWORD_BCRYPT => 1, + $version = match (true) { + \defined('PASSWORD_ARGON2ID') && $alg === PASSWORD_ARGON2ID => 3, + \defined('PASSWORD_ARGON2I') && $alg === PASSWORD_ARGON2I => 2, + $alg === PASSWORD_BCRYPT => 1, + default => 1, }; return $version . '|' . password_hash($message, $alg, $this->options); @@ -218,3 +219,4 @@ public function validate(string $prefixedHash): bool { return $algoName !== 'unknown'; } } + From c547aeaf5203ce629e2557557d6037425fa5009b Mon Sep 17 00:00:00 2001 From: hiro-gj <49264814+hiro-gj@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:21:43 +0900 Subject: [PATCH 2/2] fix/Hasher-argon2_before_commit_ee1feb8 Hasher.php SHA-512 Hash: `fcc70008dd3bdd28be13c1e457954b34acefe6796e0cd63a774cb916e7d798caced1842e922bbae106421400686a5ab96612bc35fa8000add77488d1a839e0d2` Signed-off-by: hiro-gj <49264814+hiro-gj@users.noreply.github.com> --- lib/private/Security/Hasher.php | 444 ++++++++++++++++---------------- 1 file changed, 222 insertions(+), 222 deletions(-) diff --git a/lib/private/Security/Hasher.php b/lib/private/Security/Hasher.php index 8e2950a5ac4dd..2aaf4410e6782 100644 --- a/lib/private/Security/Hasher.php +++ b/lib/private/Security/Hasher.php @@ -1,222 +1,222 @@ -get(\OCP\Security\IHasher::class)->hash('MessageToHash'); - * // Verifying a message - $newHash will contain the newly calculated hash - * $newHash = null; - * var_dump(\OC::$server->get(\OCP\Security\IHasher::class)->verify('a', '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', $newHash)); - * var_dump($newHash); - * - * @package OC\Security - */ -class Hasher implements IHasher { - /** Options passed to password_hash and password_needs_rehash */ - private array $options = []; - /** Salt used for legacy passwords */ - private ?string $legacySalt = null; - /** Only used for testing */ - private ?string $forcedAlgorithm = null; - - public function __construct( - private IConfig $config, - ) { - if (\defined('PASSWORD_ARGON2_PROVIDER')) { - // password_hash fails, when the minimum values are undershot or maximum overshot. So apply minimum/maximum. - /** @psalm-suppress TypeDoesNotContainType - The constant defaults to "standard" but when sodium is installed it will be "sodium" */ - if (PASSWORD_ARGON2_PROVIDER === 'sodium') { - $this->options['threads'] = 1; - } else { - // standard (libargon) or openssl - $this->options['threads'] = max($this->config->getSystemValueInt('hashingThreads', PASSWORD_ARGON2_DEFAULT_THREADS), 1); - } - // The minimum memory cost is 8 KiB per thread. - $this->options['memory_cost'] = max($this->config->getSystemValueInt('hashingMemoryCost', PASSWORD_ARGON2_DEFAULT_MEMORY_COST), $this->options['threads'] * 8); - $this->options['time_cost'] = max($this->config->getSystemValueInt('hashingTimeCost', PASSWORD_ARGON2_DEFAULT_TIME_COST), 1); - } - - $hashingCost = $this->config->getSystemValue('hashingCost', null); - if (!\is_null($hashingCost)) { - $this->options['cost'] = $hashingCost; - } - } - - /** - * Hashes a message using PHP's `password_hash` functionality. - * Please note that the size of the returned string is not guaranteed - * and can be up to 255 characters. - * - * @param string $message Message to generate hash from - * @return string Hash of the message with appended version parameter - */ - #[\Override] - public function hash(string $message): string { - $alg = $this->getPrefferedAlgorithm(); - - $version = match (true) { - \defined('PASSWORD_ARGON2ID') && $alg === PASSWORD_ARGON2ID => 3, - \defined('PASSWORD_ARGON2I') && $alg === PASSWORD_ARGON2I => 2, - $alg === PASSWORD_BCRYPT => 1, - default => 1, - }; - - return $version . '|' . password_hash($message, $alg, $this->options); - } - - /** - * Get the version and hash from a prefixedHash - * @param string $prefixedHash - * @return null|array{version: int, hash: string} Null if the hash is not prefixed, otherwise array('version' => 1, 'hash' => 'foo') - */ - protected function splitHash(string $prefixedHash): ?array { - $explodedString = explode('|', $prefixedHash, 2); - if (\count($explodedString) === 2) { - if ((int)$explodedString[0] > 0) { - return ['version' => (int)$explodedString[0], 'hash' => $explodedString[1]]; - } - } - - return null; - } - - /** - * Verify legacy hashes - * @param string $message Message to verify - * @param string $hash Assumed hash of the message - * @param null|string &$newHash Reference will contain the updated hash - * @return bool Whether $hash is a valid hash of $message - */ - protected function legacyHashVerify($message, $hash, &$newHash = null): bool { - if (empty($this->legacySalt)) { - $this->legacySalt = $this->config->getSystemValue('passwordsalt', ''); - } - - // Verify whether it matches a legacy PHPass or SHA1 string - $hashLength = \strlen($hash); - if (($hashLength === 60 && password_verify($message . $this->legacySalt, $hash)) - || ($hashLength === 40 && hash_equals($hash, sha1($message)))) { - $newHash = $this->hash($message); - return true; - } - - // Verify whether it matches a legacy PHPass or SHA1 string - // Retry with empty passwordsalt for cases where it was not set - $hashLength = \strlen($hash); - if (($hashLength === 60 && password_verify($message, $hash)) - || ($hashLength === 40 && hash_equals($hash, sha1($message)))) { - $newHash = $this->hash($message); - return true; - } - - return false; - } - - /** - * Verify V1 (blowfish) hashes - * Verify V2 (argon2i) hashes - * Verify V3 (argon2id) hashes - * @param string $message Message to verify - * @param string $hash Assumed hash of the message - * @param null|string &$newHash Reference will contain the updated hash if necessary. Update the existing hash with this one. - * @return bool Whether $hash is a valid hash of $message - */ - protected function verifyHash(string $message, string $hash, &$newHash = null): bool { - if (password_verify($message, $hash)) { - if ($this->needsRehash($hash)) { - $newHash = $this->hash($message); - } - return true; - } - - return false; - } - - /** - * @param string $message Message to verify - * @param string $hash Assumed hash of the message - * @param null|string &$newHash Reference will contain the updated hash if necessary. Update the existing hash with this one. - * @return bool Whether $hash is a valid hash of $message - */ - #[\Override] - public function verify(string $message, string $hash, &$newHash = null): bool { - $splittedHash = $this->splitHash($hash); - - if (isset($splittedHash['version'])) { - switch ($splittedHash['version']) { - case 3: - case 2: - case 1: - return $this->verifyHash($message, $splittedHash['hash'], $newHash); - } - } else { - return $this->legacyHashVerify($message, $hash, $newHash); - } - - return false; - } - - private function needsRehash(string $hash): bool { - $algorithm = $this->getPrefferedAlgorithm(); - - return password_needs_rehash($hash, $algorithm, $this->options); - } - - private function getPrefferedAlgorithm(): string { - if ($this->forcedAlgorithm !== null) { - return $this->forcedAlgorithm; - } - - $default = PASSWORD_BCRYPT; - if (\defined('PASSWORD_ARGON2I')) { - $default = PASSWORD_ARGON2I; - } - - if (\defined('PASSWORD_ARGON2ID')) { - $default = PASSWORD_ARGON2ID; - } - - // Check if we should use PASSWORD_DEFAULT - if ($this->config->getSystemValueBool('hashing_default_password', false)) { - $default = PASSWORD_DEFAULT; - } - - return $default; - } - - #[\Override] - public function validate(string $prefixedHash): bool { - $splitHash = $this->splitHash($prefixedHash); - if (empty($splitHash)) { - return false; - } - $validVersions = [3, 2, 1]; - $version = $splitHash['version']; - if (!in_array($version, $validVersions, true)) { - return false; - } - $algoName = password_get_info($splitHash['hash'])['algoName']; - return $algoName !== 'unknown'; - } -} - +get(\OCP\Security\IHasher::class)->hash('MessageToHash'); + * // Verifying a message - $newHash will contain the newly calculated hash + * $newHash = null; + * var_dump(\OC::$server->get(\OCP\Security\IHasher::class)->verify('a', '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', $newHash)); + * var_dump($newHash); + * + * @package OC\Security + */ +class Hasher implements IHasher { + /** Options passed to password_hash and password_needs_rehash */ + private array $options = []; + /** Salt used for legacy passwords */ + private ?string $legacySalt = null; + /** Only used for testing */ + private ?string $forcedAlgorithm = null; + + public function __construct( + private IConfig $config, + ) { + if (\defined('PASSWORD_ARGON2_PROVIDER')) { + // password_hash fails, when the minimum values are undershot or maximum overshot. So apply minimum/maximum. + /** @psalm-suppress TypeDoesNotContainType - The constant defaults to "standard" but when sodium is installed it will be "sodium" */ + if (PASSWORD_ARGON2_PROVIDER === 'sodium') { + $this->options['threads'] = 1; + } else { + // standard (libargon) or openssl + $this->options['threads'] = max($this->config->getSystemValueInt('hashingThreads', PASSWORD_ARGON2_DEFAULT_THREADS), 1); + } + // The minimum memory cost is 8 KiB per thread. + $this->options['memory_cost'] = max($this->config->getSystemValueInt('hashingMemoryCost', PASSWORD_ARGON2_DEFAULT_MEMORY_COST), $this->options['threads'] * 8); + $this->options['time_cost'] = max($this->config->getSystemValueInt('hashingTimeCost', PASSWORD_ARGON2_DEFAULT_TIME_COST), 1); + } + + $hashingCost = $this->config->getSystemValue('hashingCost', null); + if (!\is_null($hashingCost)) { + $this->options['cost'] = $hashingCost; + } + } + + /** + * Hashes a message using PHP's `password_hash` functionality. + * Please note that the size of the returned string is not guaranteed + * and can be up to 255 characters. + * + * @param string $message Message to generate hash from + * @return string Hash of the message with appended version parameter + */ + #[\Override] + public function hash(string $message): string { + $alg = $this->getPrefferedAlgorithm(); + + if (\defined('PASSWORD_ARGON2ID') && $alg === PASSWORD_ARGON2ID) { + return 3 . '|' . password_hash($message, PASSWORD_ARGON2ID, $this->options); + } + + if (\defined('PASSWORD_ARGON2I') && $alg === PASSWORD_ARGON2I) { + return 2 . '|' . password_hash($message, PASSWORD_ARGON2I, $this->options); + } + return 1 . '|' . password_hash($message, PASSWORD_BCRYPT, $this->options); + } + + /** + * Get the version and hash from a prefixedHash + * @param string $prefixedHash + * @return null|array{version: int, hash: string} Null if the hash is not prefixed, otherwise array('version' => 1, 'hash' => 'foo') + */ + protected function splitHash(string $prefixedHash): ?array { + $explodedString = explode('|', $prefixedHash, 2); + if (\count($explodedString) === 2) { + if ((int)$explodedString[0] > 0) { + return ['version' => (int)$explodedString[0], 'hash' => $explodedString[1]]; + } + } + + return null; + } + + /** + * Verify legacy hashes + * @param string $message Message to verify + * @param string $hash Assumed hash of the message + * @param null|string &$newHash Reference will contain the updated hash + * @return bool Whether $hash is a valid hash of $message + */ + protected function legacyHashVerify($message, $hash, &$newHash = null): bool { + if (empty($this->legacySalt)) { + $this->legacySalt = $this->config->getSystemValue('passwordsalt', ''); + } + + // Verify whether it matches a legacy PHPass or SHA1 string + $hashLength = \strlen($hash); + if (($hashLength === 60 && password_verify($message . $this->legacySalt, $hash)) + || ($hashLength === 40 && hash_equals($hash, sha1($message)))) { + $newHash = $this->hash($message); + return true; + } + + // Verify whether it matches a legacy PHPass or SHA1 string + // Retry with empty passwordsalt for cases where it was not set + $hashLength = \strlen($hash); + if (($hashLength === 60 && password_verify($message, $hash)) + || ($hashLength === 40 && hash_equals($hash, sha1($message)))) { + $newHash = $this->hash($message); + return true; + } + + return false; + } + + /** + * Verify V1 (blowfish) hashes + * Verify V2 (argon2i) hashes + * Verify V3 (argon2id) hashes + * @param string $message Message to verify + * @param string $hash Assumed hash of the message + * @param null|string &$newHash Reference will contain the updated hash if necessary. Update the existing hash with this one. + * @return bool Whether $hash is a valid hash of $message + */ + protected function verifyHash(string $message, string $hash, &$newHash = null): bool { + if (password_verify($message, $hash)) { + if ($this->needsRehash($hash)) { + $newHash = $this->hash($message); + } + return true; + } + + return false; + } + + /** + * @param string $message Message to verify + * @param string $hash Assumed hash of the message + * @param null|string &$newHash Reference will contain the updated hash if necessary. Update the existing hash with this one. + * @return bool Whether $hash is a valid hash of $message + */ + #[\Override] + public function verify(string $message, string $hash, &$newHash = null): bool { + $splittedHash = $this->splitHash($hash); + + if (isset($splittedHash['version'])) { + switch ($splittedHash['version']) { + case 3: + case 2: + case 1: + return $this->verifyHash($message, $splittedHash['hash'], $newHash); + } + } else { + return $this->legacyHashVerify($message, $hash, $newHash); + } + + return false; + } + + private function needsRehash(string $hash): bool { + $algorithm = $this->getPrefferedAlgorithm(); + + return password_needs_rehash($hash, $algorithm, $this->options); + } + + private function getPrefferedAlgorithm(): string { + if ($this->forcedAlgorithm !== null) { + return $this->forcedAlgorithm; + } + + $default = PASSWORD_BCRYPT; + if (\defined('PASSWORD_ARGON2I')) { + $default = PASSWORD_ARGON2I; + } + + if (\defined('PASSWORD_ARGON2ID')) { + $default = PASSWORD_ARGON2ID; + } + + // Check if we should use PASSWORD_DEFAULT + if ($this->config->getSystemValueBool('hashing_default_password', false)) { + $default = PASSWORD_DEFAULT; + } + + return $default; + } + + #[\Override] + public function validate(string $prefixedHash): bool { + $splitHash = $this->splitHash($prefixedHash); + if (empty($splitHash)) { + return false; + } + $validVersions = [3, 2, 1]; + $version = $splitHash['version']; + if (!in_array($version, $validVersions, true)) { + return false; + } + $algoName = password_get_info($splitHash['hash'])['algoName']; + return $algoName !== 'unknown'; + } +} +