diff options
author | Fabien Potencier <fabien.potencier@gmail.com> | 2012-07-05 12:19:25 +0200 |
---|---|---|
committer | Fabien Potencier <fabien.potencier@gmail.com> | 2012-10-28 08:03:00 +0100 |
commit | 255196983ec0c1dc944057816fbba25b9ff8276c (patch) | |
tree | 9abc7b351b5a5dc0adcbde72f6ad645a652e04f1 | |
parent | e3d359180c41a80803e06a5d277b3b319952c8ee (diff) | |
download | symfony-security-255196983ec0c1dc944057816fbba25b9ff8276c.zip symfony-security-255196983ec0c1dc944057816fbba25b9ff8276c.tar.gz symfony-security-255196983ec0c1dc944057816fbba25b9ff8276c.tar.bz2 |
moved the secure random class from JMSSecurityExtraBundle to Symfony (closes #3595)
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | Core/Encoder/BasePasswordEncoder.php | 13 | ||||
-rw-r--r-- | Core/Util/Prng.php | 104 | ||||
-rw-r--r-- | Core/Util/SeedProviderInterface.php | 37 | ||||
-rw-r--r-- | Core/Util/String.php | 48 | ||||
-rw-r--r-- | Http/RememberMe/PersistentTokenBasedRememberMeServices.php | 35 | ||||
-rwxr-xr-x | Tests/Core/Util/PrngTest.php | 179 | ||||
-rwxr-xr-x | Tests/Core/Util/StringTest.php | 14 | ||||
-rw-r--r-- | Tests/Http/RememberMe/PersistentTokenBasedRememberMeServicesTest.php | 6 |
9 files changed, 401 insertions, 36 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c8192..251666a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG 2.1.0 ----- + * added secure random number generator * [BC BREAK] The signature of ExceptionListener has changed * changed the HttpUtils constructor signature to take a UrlGenerator and a UrlMatcher instead of a Router * EncoderFactoryInterface::getEncoder() can now also take a class name as an argument diff --git a/Core/Encoder/BasePasswordEncoder.php b/Core/Encoder/BasePasswordEncoder.php index ae1c7d4..e73bbbd 100644 --- a/Core/Encoder/BasePasswordEncoder.php +++ b/Core/Encoder/BasePasswordEncoder.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Security\Core\Encoder; +use Symfony\Component\Security\Core\Util\String; + /** * BasePasswordEncoder is the base class for all password encoders. * @@ -77,15 +79,6 @@ abstract class BasePasswordEncoder implements PasswordEncoderInterface */ protected function comparePasswords($password1, $password2) { - if (strlen($password1) !== strlen($password2)) { - return false; - } - - $result = 0; - for ($i = 0; $i < strlen($password1); $i++) { - $result |= ord($password1[$i]) ^ ord($password2[$i]); - } - - return 0 === $result; + return String::equals($password1, $password2); } } diff --git a/Core/Util/Prng.php b/Core/Util/Prng.php new file mode 100644 index 0000000..ab8baa7 --- /dev/null +++ b/Core/Util/Prng.php @@ -0,0 +1,104 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Util; + +use Symfony\Component\HttpKernel\Log\LoggerInterface; + +/** + * A secure random number generator implementation. + * + * @author Johannes M. Schmitt <schmittjoh@gmail.com> + */ +final class Prng +{ + private $logger; + private $useOpenSsl; + private $seed; + private $seedUpdated; + private $seedLastUpdatedAt; + private $seedProvider; + + /** + * Constructor. + * + * Be aware that a guessable seed will severely compromise the PRNG + * algorithm that is employed. + * + * @param SeedProviderInterface $provider + * @param LoggerInterface $logger + */ + public function __construct(SeedProviderInterface $provider = null, LoggerInterface $logger = null) + { + $this->seedProvider = $provider; + $this->logger = $logger; + + // determine whether to use OpenSSL + if (defined('PHP_WINDOWS_VERSION_BUILD') && version_compare(PHP_VERSION, '5.3.4', '<')) { + $this->useOpenSsl = false; + } elseif (!function_exists('openssl_random_pseudo_bytes')) { + if (null !== $this->logger) { + $this->logger->notice('It is recommended that you enable the "openssl" extension for random number generation.'); + } + $this->useOpenSsl = false; + } else { + $this->useOpenSsl = true; + } + } + + /** + * Generates the specified number of secure random bytes. + * + * @param integer $nbBytes + * @return string + */ + public function nextBytes($nbBytes) + { + // try OpenSSL + if ($this->useOpenSsl) { + $bytes = openssl_random_pseudo_bytes($nbBytes, $strong); + + if (false !== $bytes && true === $strong) { + return $bytes; + } + + if (null !== $this->logger) { + $this->logger->info('OpenSSL did not produce a secure random number.'); + } + } + + // initialize seed + if (null === $this->seed) { + if (null === $this->seedProvider) { + throw new \RuntimeException('You need to specify a custom seed provider.'); + } + + list($this->seed, $this->seedLastUpdatedAt) = $this->seedProvider->loadSeed(); + } + + $bytes = ''; + while (strlen($bytes) < $nbBytes) { + static $incr = 1; + $bytes .= hash('sha512', $incr++.$this->seed.uniqid(mt_rand(), true).$nbBytes, true); + $this->seed = base64_encode(hash('sha512', $this->seed.$bytes.$nbBytes, true)); + + if (!$this->seedUpdated && $this->seedLastUpdatedAt->getTimestamp() < time() - mt_rand(1, 10)) { + if (null !== $this->seedProvider) { + $this->seedProvider->updateSeed($this->seed); + } + + $this->seedUpdated = true; + } + } + + return substr($bytes, 0, $nbBytes); + } +} diff --git a/Core/Util/SeedProviderInterface.php b/Core/Util/SeedProviderInterface.php new file mode 100644 index 0000000..dd960b9 --- /dev/null +++ b/Core/Util/SeedProviderInterface.php @@ -0,0 +1,37 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Util; + +/** + * Seed Provider Interface. + * + * @author Johannes M. Schmitt <schmittjoh@gmail.com> + */ +interface SeedProviderInterface +{ + /** + * Loads the initial seed. + * + * Whatever is returned from this method, it should not be guessable. + * + * @return array of the format array(string, DateTime) where string is the + * initial seed, and DateTime is the last time it was updated + */ + function loadSeed(); + + /** + * Updates the seed. + * + * @param string $seed + */ + function updateSeed($seed); +} diff --git a/Core/Util/String.php b/Core/Util/String.php new file mode 100644 index 0000000..096878b --- /dev/null +++ b/Core/Util/String.php @@ -0,0 +1,48 @@ +<?php + +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier <fabien@symfony.com> + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Core\Util; + +/** + * String utility functions. + * + * @author Fabien Potencier <fabien@symfony.com> + */ +final class String +{ + private final function __construct() + { + } + + /** + * Compares two strings. + * + * This method implements a constant-time algorithm to compare strings. + * + * @param string $str1 The first string + * @param string $str2 The second string + * + * @return Boolean true if the two strings are the same, false otherwise + */ + public static function equals($str1, $str2) + { + if (strlen($str1) !== $c = strlen($str2)) { + return false; + } + + $result = 0; + for ($i = 0; $i < $c; $i++) { + $result |= ord($str1[$i]) ^ ord($str2[$i]); + } + + return 0 === $result; + } +} diff --git a/Http/RememberMe/PersistentTokenBasedRememberMeServices.php b/Http/RememberMe/PersistentTokenBasedRememberMeServices.php index 8944672..d36eb01 100644 --- a/Http/RememberMe/PersistentTokenBasedRememberMeServices.php +++ b/Http/RememberMe/PersistentTokenBasedRememberMeServices.php @@ -19,6 +19,7 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\CookieTheftException; use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Util\Prng; /** * Concrete implementation of the RememberMeServicesInterface which needs @@ -30,6 +31,12 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices { private $tokenProvider; + private $prng; + + public function setPrng(Prng $prng) + { + $this->prng = $prng; + } /** * Sets the token provider @@ -79,7 +86,7 @@ class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices } $series = $persistentToken->getSeries(); - $tokenValue = $this->generateRandomValue(); + $tokenValue = $this->prng->nextBytes(64); $this->tokenProvider->updateToken($series, $tokenValue, new \DateTime()); $request->attributes->set(self::COOKIE_ATTR_NAME, new Cookie( @@ -101,8 +108,8 @@ class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices */ protected function onLoginSuccess(Request $request, Response $response, TokenInterface $token) { - $series = $this->generateRandomValue(); - $tokenValue = $this->generateRandomValue(); + $series = $this->prng->nextBytes(64); + $tokenValue = $this->prng->nextBytes(64); $this->tokenProvider->createNewToken( new PersistentToken( @@ -126,26 +133,4 @@ class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices ) ); } - - /** - * Generates a cryptographically strong random value - * - * @return string - */ - protected function generateRandomValue() - { - if (function_exists('openssl_random_pseudo_bytes')) { - $bytes = openssl_random_pseudo_bytes(64, $strong); - - if (true === $strong && false !== $bytes) { - return base64_encode($bytes); - } - } - - if (null !== $this->logger) { - $this->logger->warn('Could not produce a cryptographically strong random value. Please install/update the OpenSSL extension.'); - } - - return base64_encode(hash('sha512', uniqid(mt_rand(), true), true)); - } } diff --git a/Tests/Core/Util/PrngTest.php b/Tests/Core/Util/PrngTest.php new file mode 100755 index 0000000..7c9b2e2 --- /dev/null +++ b/Tests/Core/Util/PrngTest.php @@ -0,0 +1,179 @@ +<?php + +namespace Symfony\Component\Security\Tests\Core\Util; + +use Symfony\Component\Security\Core\Util\NullSeedProvider; +use Symfony\Component\Security\Core\Util\PrngSchema; +use Symfony\Component\Security\Core\Util\Prng; + +class PrngTest extends \PHPUnit_Framework_TestCase +{ + /** + * T1: Monobit test + * + * @dataProvider getPrngs + */ + public function testMonobit($prng) + { + $nbOnBits = substr_count($this->getBitSequence($prng, 20000), '1'); + $this->assertTrue($nbOnBits > 9654 && $nbOnBits < 10346, 'Monobit test failed, number of turned on bits: '.$nbOnBits); + } + + /** + * T2: Chi-square test with 15 degrees of freedom (chi-Quadrat-Anpassungstest) + * + * @dataProvider getPrngs + */ + public function testPoker($prng) + { + $b = $this->getBitSequence($prng, 20000); + $c = array(); + for ($i=0;$i<=15;$i++) { + $c[$i] = 0; + } + + for ($j=1; $j<=5000; $j++) { + $k = 4 * $j - 1; + $c[8 * $b[$k - 3] + 4 * $b[$k - 2] + 2 * $b[$k - 1] + $b[$k]] += 1; + } + + $f = 0; + for ($i=0; $i<= 15; $i++) { + $f += $c[$i] * $c[$i]; + } + + $Y = 16/5000 * $f - 5000; + + $this->assertTrue($Y > 1.03 && $Y < 57.4, 'Poker test failed, Y = '.$Y); + } + + /** + * Run test + * + * @dataProvider getPrngs + */ + public function testRun($prng) + { + $b = $this->getBitSequence($prng, 20000); + + $runs = array(); + for ($i=1; $i<=6; $i++) { + $runs[$i] = 0; + } + + $addRun = function($run) use (&$runs) { + if ($run > 6) { + $run = 6; + } + + $runs[$run] += 1; + }; + + $currentRun = 0; + $lastBit = null; + for ($i=0; $i<20000; $i++) { + if ($lastBit === $b[$i]) { + $currentRun += 1; + } else { + if ($currentRun > 0) { + $addRun($currentRun); + } + + $lastBit = $b[$i]; + $currentRun = 0; + } + } + if ($currentRun > 0) { + $addRun($currentRun); + } + + $this->assertTrue($runs[1] > 2267 && $runs[1] < 2733, 'Runs of length 1 outside of defined interval: '.$runs[1]); + $this->assertTrue($runs[2] > 1079 && $runs[2] < 1421, 'Runs of length 2 outside of defined interval: '.$runs[2]); + $this->assertTrue($runs[3] > 502 && $runs[3] < 748, 'Runs of length 3 outside of defined interval: '.$runs[3]); + $this->assertTrue($runs[4] > 233 && $runs[4] < 402, 'Runs of length 4 outside of defined interval: '.$runs[4]); + $this->assertTrue($runs[5] > 90 && $runs[5] < 223, 'Runs of length 5 outside of defined interval: '.$runs[5]); + $this->assertTrue($runs[6] > 90 && $runs[6] < 233, 'Runs of length 6 outside of defined interval: '.$runs[6]); + } + + /** + * Long-run test + * + * @dataProvider getPrngs + */ + public function testLongRun($prng) + { + $b = $this->getBitSequence($prng, 20000); + + $longestRun = 0; + $currentRun = $lastBit = null; + for ($i=0;$i<20000;$i++) { + if ($lastBit === $b[$i]) { + $currentRun += 1; + } else { + if ($currentRun > $longestRun) { + $longestRun = $currentRun; + } + $lastBit = $b[$i]; + $currentRun = 0; + } + } + if ($currentRun > $longestRun) { + $longestRun = $currentRun; + } + + $this->assertTrue($longestRun < 34, 'Failed longest run test: '.$longestRun); + } + + /** + * Serial Correlation (Autokorrelationstest) + * + * @dataProvider getPrngs + */ + public function testSerialCorrelation($prng) + { + $shift = rand(1, 5000); + $b = $this->getBitSequence($prng, 20000); + + $Z = 0; + for ($i=0; $i<5000; $i++) { + $Z += $b[$i] === $b[$i+$shift] ? 1 : 0; + } + + $this->assertTrue($Z > 2326 && $Z < 2674, 'Failed serial correlation test: '.$Z); + } + + public function getPrngs() + { + $prngs = array(); + + // openssl with fallback + $prng = new Prng(new NullSeedProvider()); + $prngs[] = array($prng); + + // no-openssl with custom seed provider + $prng = new Prng(new NullSeedProvider()); + $this->disableOpenSsl($prng); + $prngs[] = array($prng); + + return $prngs; + } + + protected function disableOpenSsl($prng) + { + $ref = new \ReflectionProperty($prng, 'useOpenSsl'); + $ref->setAccessible(true); + $ref->setValue($prng, false); + } + + private function getBitSequence($prng, $length) + { + $bitSequence = ''; + for ($i=0;$i<$length; $i+=40) { + $value = unpack('H*', $prng->nextBytes(5)); + $value = str_pad(base_convert($value[1], 16, 2), 40, '0', STR_PAD_LEFT); + $bitSequence .= $value; + } + + return substr($bitSequence, 0, $length); + } +} diff --git a/Tests/Core/Util/StringTest.php b/Tests/Core/Util/StringTest.php new file mode 100755 index 0000000..fe4eae4 --- /dev/null +++ b/Tests/Core/Util/StringTest.php @@ -0,0 +1,14 @@ +<?php + +namespace Symfony\Component\Security\Tests\Core\Util; + +use Symfony\Component\Security\Core\Util\String; + +class StringTest extends \PHPUnit_Framework_TestCase +{ + public function testEquals() + { + $this->assertTrue(String::equals('password', 'password')); + $this->assertFalse(String::equals('password', 'foo')); + } +} diff --git a/Tests/Http/RememberMe/PersistentTokenBasedRememberMeServicesTest.php b/Tests/Http/RememberMe/PersistentTokenBasedRememberMeServicesTest.php index 3b3691d..846ee9b 100644 --- a/Tests/Http/RememberMe/PersistentTokenBasedRememberMeServicesTest.php +++ b/Tests/Http/RememberMe/PersistentTokenBasedRememberMeServicesTest.php @@ -22,6 +22,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Security\Http\RememberMe\PersistentTokenBasedRememberMeServices; use Symfony\Component\Security\Core\Exception\TokenNotFoundException; use Symfony\Component\Security\Core\Exception\CookieTheftException; +use Symfony\Component\Security\Core\Util\Prng; class PersistentTokenBasedRememberMeServicesTest extends \PHPUnit_Framework_TestCase { @@ -318,7 +319,10 @@ class PersistentTokenBasedRememberMeServicesTest extends \PHPUnit_Framework_Test $userProvider = $this->getProvider(); } - return new PersistentTokenBasedRememberMeServices(array($userProvider), 'fookey', 'fookey', $options, $logger); + $r = new PersistentTokenBasedRememberMeServices(array($userProvider), 'fookey', 'fookey', $options, $logger); + $r->setPrng(new Prng()); + + return $r; } protected function getProvider() |