From 24ca2d858f9cc020b2ca7310977cd4272a6809fb Mon Sep 17 00:00:00 2001 From: Lukas Reschke Date: Tue, 4 Nov 2014 16:05:31 +0100 Subject: [PATCH] Add OCP\Security\IHasher Public interface for hashing which also works with legacy ownCloud hashes and supports updating the legacy hash via a passed reference. Follow-up of https://github.com/owncloud/core/pull/10219#issuecomment-61624662 Requires https://github.com/owncloud/3rdparty/pull/136 --- 3rdparty | 2 +- config/config.sample.php | 6 ++ lib/private/security/hasher.php | 146 ++++++++++++++++++++++++++++++++ lib/private/server.php | 13 +++ lib/public/iservercontainer.php | 13 +++ lib/public/security/ihasher.php | 48 +++++++++++ tests/lib/security/hasher.php | 115 +++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 lib/private/security/hasher.php create mode 100644 lib/public/security/ihasher.php create mode 100644 tests/lib/security/hasher.php diff --git a/3rdparty b/3rdparty index cb394f1eb0..48fdf111df 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit cb394f1eb0a363268325d181b22df69ad91d6e1b +Subproject commit 48fdf111dfe4728a906002afccb97b8ad88b3f61 diff --git a/config/config.sample.php b/config/config.sample.php index 5d6e3cea27..59f892d133 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -57,6 +57,12 @@ $CONFIG = array( */ 'passwordsalt' => '', +/** + * The hashing cost used by hashes generated by ownCloud + * Using a higher value requires more time and CPU power to calculate the hashes + */ +'hashingCost' => 10, + /** * Your list of trusted domains that users can log into. Specifying trusted * domains prevents host header poisoning. Do not remove this, as it performs diff --git a/lib/private/security/hasher.php b/lib/private/security/hasher.php new file mode 100644 index 0000000000..647e1a307e --- /dev/null +++ b/lib/private/security/hasher.php @@ -0,0 +1,146 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OC\Security; + +use OCP\IConfig; +use OCP\Security\IHasher; + +/** + * Class Hasher provides some basic hashing functions. Furthermore, it supports legacy hashes + * used by previous versions of ownCloud and helps migrating those hashes to newer ones. + * + * The hashes generated by this class are prefixed (version|hash) with a version parameter to allow possible + * updates in the future. + * Possible versions: + * - 1 (Initial version) + * + * Usage: + * // Hashing a message + * $hash = \OC::$server->getHasher()->hash('MessageToHash'); + * // Verifying a message - $newHash will contain the newly calculated hash + * $newHash = null; + * var_dump(\OC::$server->getHasher()->verify('a', '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', $newHash)); + * var_dump($newHash); + * + * @package OC\Security + */ +class Hasher implements IHasher { + /** @var IConfig */ + private $config; + /** @var array Options passed to password_hash and password_needs_rehash */ + private $options = array(); + /** @var string Salt used for legacy passwords */ + private $legacySalt = null; + /** @var int Current version of the generated hash */ + private $currentVersion = 1; + + /** + * @param IConfig $config + */ + function __construct(IConfig $config) { + $this->config = $config; + + $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 + */ + public function hash($message) { + return $this->currentVersion . '|' . password_hash($message, PASSWORD_DEFAULT, $this->options); + } + + /** + * Get the version and hash from a prefixedHash + * @param string $prefixedHash + * @return null|array Null if the hash is not prefixed, otherwise array('version' => 1, 'hash' => 'foo') + */ + protected function splitHash($prefixedHash) { + $explodedString = explode('|', $prefixedHash, 2); + if(sizeof($explodedString) === 2) { + if((int)$explodedString[0] > 0) { + return array('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) { + 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 && StringUtils::equals($hash, sha1($message))) { + $newHash = $this->hash($message); + return true; + } + + return false; + } + + /** + * Verify V1 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 verifyHashV1($message, $hash, &$newHash = null) { + if(password_verify($message, $hash)) { + if(password_needs_rehash($hash, PASSWORD_DEFAULT, $this->options)) { + $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 + */ + public function verify($message, $hash, &$newHash = null) { + $splittedHash = $this->splitHash($hash); + + if(isset($splittedHash['version'])) { + switch ($splittedHash['version']) { + case 1: + return $this->verifyHashV1($message, $splittedHash['hash'], $newHash); + } + } else { + return $this->legacyHashVerify($message, $hash, $newHash); + } + + + return false; + } + +} diff --git a/lib/private/server.php b/lib/private/server.php index 186714740f..f43613e818 100644 --- a/lib/private/server.php +++ b/lib/private/server.php @@ -14,6 +14,7 @@ use OC\DB\ConnectionWrapper; use OC\Files\Node\Root; use OC\Files\View; use OC\Security\Crypto; +use OC\Security\Hasher; use OC\Security\SecureRandom; use OC\Diagnostics\NullEventLogger; use OCP\IServerContainer; @@ -197,6 +198,9 @@ class Server extends SimpleContainer implements IServerContainer { $this->registerService('Crypto', function (Server $c) { return new Crypto($c->getConfig(), $c->getSecureRandom()); }); + $this->registerService('Hasher', function (Server $c) { + return new Hasher($c->getConfig()); + }); $this->registerService('DatabaseConnection', function (Server $c) { $factory = new \OC\DB\ConnectionFactory(); $type = $c->getConfig()->getSystemValue('dbtype', 'sqlite'); @@ -529,6 +533,15 @@ class Server extends SimpleContainer implements IServerContainer { return $this->query('Crypto'); } + /** + * Returns a Hasher instance + * + * @return \OCP\Security\IHasher + */ + function getHasher() { + return $this->query('Hasher'); + } + /** * Returns an instance of the db facade * diff --git a/lib/public/iservercontainer.php b/lib/public/iservercontainer.php index c159255197..2003f44855 100644 --- a/lib/public/iservercontainer.php +++ b/lib/public/iservercontainer.php @@ -128,6 +128,19 @@ interface IServerContainer { */ function getConfig(); + /** + * Returns a Crypto instance + * + * @return \OCP\Security\ICrypto + */ + function getCrypto(); + + /** + * Returns a Hasher instance + * + * @return \OCP\Security\IHasher + */ + function getHasher(); /** * Returns an instance of the db facade diff --git a/lib/public/security/ihasher.php b/lib/public/security/ihasher.php new file mode 100644 index 0000000000..75900fb55a --- /dev/null +++ b/lib/public/security/ihasher.php @@ -0,0 +1,48 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +namespace OCP\Security; + +/** + * Class Hasher provides some basic hashing functions. Furthermore, it supports legacy hashes + * used by previous versions of ownCloud and helps migrating those hashes to newer ones. + * + * The hashes generated by this class are prefixed (version|hash) with a version parameter to allow possible + * updates in the future. + * Possible versions: + * - 1 (Initial version) + * + * Usage: + * // Hashing a message + * $hash = \OC::$server->getHasher()->hash('MessageToHash'); + * // Verifying a message - $newHash will contain the newly calculated hash + * $newHash = null; + * var_dump(\OC::$server->getHasher()->verify('a', '86f7e437faa5a7fce15d1ddcb9eaeaea377667b8', $newHash)); + * var_dump($newHash); + * + * @package OCP\Security + */ +interface IHasher { + /** + * 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 + */ + public function hash($message); + + /** + * @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 + */ + public function verify($message, $hash, &$newHash = null); +} diff --git a/tests/lib/security/hasher.php b/tests/lib/security/hasher.php new file mode 100644 index 0000000000..330789c67a --- /dev/null +++ b/tests/lib/security/hasher.php @@ -0,0 +1,115 @@ + + * This file is licensed under the Affero General Public License version 3 or + * later. + * See the COPYING-README file. + */ + +use OC\Security\Hasher; + +/** + * Class HasherTest + */ +class HasherTest extends \PHPUnit_Framework_TestCase { + + /** + * @return array + */ + public function versionHashProvider() + { + return array( + array('asf32äà$$a.|3', null), + array('asf32äà$$a.|3|5', null), + array('1|2|3|4', array('version' => 1, 'hash' => '2|3|4')), + array('1|我看|这本书。 我看這本書', array('version' => 1, 'hash' => '我看|这本书。 我看這本書')) + ); + } + + /** + * @return array + */ + public function allHashProviders() + { + return array( + // Bogus values + array(null, 'asf32äà$$a.|3', false), + array(null, false, false), + + // Valid SHA1 strings + array('password', '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', true), + array('owncloud.com', '27a4643e43046c3569e33b68c1a4b15d31306d29', true), + + // Invalid SHA1 strings + array('InvalidString', '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8', false), + array('AnotherInvalidOne', '27a4643e43046c3569e33b68c1a4b15d31306d29', false), + + // Valid legacy password string with password salt "6Wow67q1wZQZpUUeI6G2LsWUu4XKx" + array('password', '$2a$08$emCpDEl.V.QwPWt5gPrqrOhdpH6ailBmkj2Hd2vD5U8qIy20HBe7.', true), + array('password', '$2a$08$yjaLO4ev70SaOsWZ9gRS3eRSEpHVsmSWTdTms1949mylxJ279hzo2', true), + array('password', '$2a$08$.jNRG/oB4r7gHJhAyb.mDupNUAqTnBIW/tWBqFobaYflKXiFeG0A6', true), + array('owncloud.com', '$2a$08$YbEsyASX/hXVNMv8hXQo7ezreN17T8Jl6PjecGZvpX.Ayz2aUyaZ2', true), + array('owncloud.com', '$2a$11$cHdDA2IkUP28oNGBwlL7jO/U3dpr8/0LIjTZmE8dMPA7OCUQsSTqS', true), + array('owncloud.com', '$2a$08$GH.UoIfJ1e.qeZ85KPqzQe6NR8XWRgJXWIUeE1o/j1xndvyTA1x96', true), + + // Invalid legacy passwords + array('password', '$2a$08$oKAQY5IhnZocP.61MwP7xu7TNeOb7Ostvk3j6UpacvaNMs.xRj7O2', false), + + // Valid passwords "6Wow67q1wZQZpUUeI6G2LsWUu4XKx" + array('password', '1|$2a$05$ezAE0dkwk57jlfo6z5Pql.gcIK3ReXT15W7ITNxVS0ksfhO/4E4Kq', true), + array('password', '1|$2a$05$4OQmloFW4yTVez2MEWGIleDO9Z5G9tWBXxn1vddogmKBQq/Mq93pe', true), + array('password', '1|$2a$11$yj0hlp6qR32G9exGEXktB.yW2rgt2maRBbPgi3EyxcDwKrD14x/WO', true), + array('owncloud.com', '1|$2a$10$Yiss2WVOqGakxuuqySv5UeOKpF8d8KmNjuAPcBMiRJGizJXjA2bKm', true), + array('owncloud.com', '1|$2a$10$v9mh8/.mF/Ut9jZ7pRnpkuac3bdFCnc4W/gSumheQUi02Sr.xMjPi', true), + array('owncloud.com', '1|$2a$05$ST5E.rplNRfDCzRpzq69leRzsTGtY7k88h9Vy2eWj0Ug/iA9w5kGK', true), + + // Invalid passwords + array('password', '0|$2a$08$oKAQY5IhnZocP.61MwP7xu7TNeOb7Ostvk3j6UpacvaNMs.xRj7O2', false), + array('password', '1|$2a$08$oKAQY5IhnZocP.61MwP7xu7TNeOb7Ostvk3j6UpacvaNMs.xRj7O2', false), + array('password', '2|$2a$08$oKAQY5IhnZocP.61MwP7xu7TNeOb7Ostvk3j6UpacvaNMs.xRj7O2', false), + ); + } + + + + /** @var Hasher */ + protected $hasher; + /** @var \OCP\IConfig */ + protected $config; + + protected function setUp() { + $this->config = $this->getMockBuilder('\OCP\IConfig') + ->disableOriginalConstructor()->getMock(); + + $this->hasher = new Hasher($this->config); + } + + function testHash() { + $hash = $this->hasher->hash('String To Hash'); + $this->assertNotNull($hash); + } + + /** + * @dataProvider versionHashProvider + */ + function testSplitHash($hash, $expected) { + $relativePath = \Test_Helper::invokePrivate($this->hasher, 'splitHash', array($hash)); + $this->assertSame($expected, $relativePath); + } + + + /** + * @dataProvider allHashProviders + */ + function testVerify($password, $hash, $expected) { + $this->config + ->expects($this->any()) + ->method('getSystemValue') + ->with('passwordsalt', null) + ->will($this->returnValue('6Wow67q1wZQZpUUeI6G2LsWUu4XKx')); + + $result = $this->hasher->verify($password, $hash); + $this->assertSame($expected, $result); + } + +}