moved the secure random class from JMSSecurityExtraBundle to Symfony (closes #3595)

This commit is contained in:
Fabien Potencier 2012-07-05 12:19:25 +02:00
parent bde2e26b69
commit e5dc7afe90
22 changed files with 673 additions and 37 deletions

View File

@ -0,0 +1,68 @@
<?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\Bridge\Doctrine\Security;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Connection;
use Symfony\Component\Security\Core\Util\SeedProviderInterface;
/**
* Doctrine Seed Provider.
*
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
* @author Fabien Potencier <fabien@symfony.com>
*/
class DoctrineSeedProvider implements SeedProviderInterface
{
private $con;
private $seedTableName;
/**
* Constructor.
*
* @param Connection $con
* @param string $tableName
*/
public function __construct(Connection $con, $tableName)
{
$this->con = $con;
$this->seedTableName = $tableName;
}
/**
* {@inheritdoc}
*/
public function loadSeed()
{
$stmt = $this->con->executeQuery("SELECT seed, updated_at FROM {$this->seedTableName}");
if (false === $seed = $stmt->fetchColumn(0)) {
throw new \RuntimeException('You need to initialize the generator by running the console command "init:prng".');
}
$seedLastUpdatedAt = new \DateTime($stmt->fetchColumn(1));
return array($seed, $seedLastUpdatedAt);
}
/**
* {@inheritdoc}
*/
public function updateSeed($seed)
{
$params = array(':seed' => $seed, ':updatedAt' => new \DateTime());
$types = array(':updatedAt' => Type::DATETIME);
if (!$this->con->executeUpdate("UPDATE {$this->seedTableName} SET seed = :seed, updated_at = :updatedAt", $params, $types)) {
$this->con->executeUpdate("INSERT INTO {$this->seedTableName} VALUES (:seed, :updatedAt)", $params, $types);
}
}
}

View File

@ -0,0 +1,33 @@
<?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\Bridge\Doctrine\Security\EventListener;
use Symfony\Bridge\Doctrine\Security\PrngSchema;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
/**
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
class PrngSchemaListener
{
private $schema;
public function __construct(PrngSchema $schema)
{
$this->schema = $schema;
}
public function postGenerateSchema(GenerateSchemaEventArgs $args)
{
$this->schema->addToSchema($args->getSchema());
}
}

View File

@ -0,0 +1,43 @@
<?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\Bridge\Doctrine\Security;
use Doctrine\DBAL\Schema\Schema;
/**
* The DBAL schema that will be used if you choose the database-based seed provider.
*
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
*/
final class PrngSchema extends Schema
{
public function __construct($tableName)
{
parent::__construct();
$table = $this->createTable($tableName);
$table->addColumn('seed', 'string', array(
'length' => 88,
'not_null' => true,
));
$table->addColumn('updated_at', 'datetime', array(
'not_null' => true,
));
}
public function addToSchema(Schema $schema)
{
foreach ($this->getTables() as $table) {
$schema->_addTable($table);
}
}
}

View File

@ -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\Bridge\Doctrine\Tests\Security;
use Symfony\Bridge\Doctrine\Security\DoctrineSeedProvider;
use Symfony\Bridge\Doctrine\Security\PrngSchema;
use Symfony\Component\Security\Core\Util\Prng;
use Symfony\Component\Security\Tests\Core\Util\PrngTest;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Connection;
class DoctrineSeedProviderTest extends PrngTest
{
public function getPrngs()
{
$con = DriverManager::getConnection(array(
'driver' => 'pdo_sqlite',
'memory' => true
));
$schema = new PrngSchema('seed_table');
foreach ($schema->toSql($con->getDatabasePlatform()) as $sql) {
$con->executeQuery($sql);
}
$con->executeQuery("INSERT INTO seed_table VALUES (:seed, :updatedAt)", array(
':seed' => base64_encode(hash('sha512', uniqid(mt_rand(), true), true)),
':updatedAt' => date('Y-m-d H:i:s'),
));
// no-openssl with database seed provider
$prng = new Prng(new DoctrineSeedProvider($con, 'seed_table'));
$this->disableOpenSsl($prng);
$prngs = parent::getPrngs();
$prngs[] = array($prng);
return $prngs;
}
}

View File

@ -0,0 +1,57 @@
<?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\Bundle\SecurityBundle\Command;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument;
/**
* Initializes a custom PRNG seed provider.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class InitPrngCommand extends ContainerAwareCommand
{
protected function configure()
{
$this
->setName('init:prng');
->addArgument('phrase', InputArgument::REQUIRED, 'A random string');
->setDescription('Initialize a custom PRNG seed provider')
->setHelp(<<<EOF
The <info>%command.name%</info> command initializes a custom PRNG seed provider:
<info>php %command.full_name% ABCDE...</info>
The argument should be a random string, whatever comes to your mind right now.
You do not need to remember it, it does not need to be cryptic, or long, and it
will not be stored in a decipherable way. One restriction however, you should
not let this be generated in an automated fashion.
EOF
)
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
if (!$this->getContainer()->has('security.prng_seed_provider')) {
throw new \RuntimeException('No seed provider has been configured under path "secure.prng".');
}
$this->getContainer()->get('security.prng_seed_provider')->updateSeed(base64_encode(hash('sha512', $input->getArgument('phrase'), true)));
$output->writeln('The CSPRNG has been initialized successfully.');
}
}

View File

@ -87,6 +87,7 @@ class MainConfiguration implements ConfigurationInterface
{
$rootNode
->children()
->scalarNode('prng_seed_provider')->defaultNull()->end()
->arrayNode('acl')
->children()
->scalarNode('connection')

View File

@ -88,6 +88,10 @@ class SecurityExtension extends Extension
$this->aclLoad($config['acl'], $container);
}
if (null !== $config['prng_seed_provider']) {
$container->setAlias('security.prng_seed_provider', $config['prng_seed_provider']);
}
// add some required classes for compilation
$this->addClassesToCompile(array(
'Symfony\\Component\\Security\\Http\\Firewall',

View File

@ -138,5 +138,12 @@
<argument type="service" id="security.context" />
<argument type="service" id="security.encoder_factory" />
</service>
<!-- Pseudorandom Number Generator -->
<service id="security.prng" class="Symfony\Component\Security\Core\Util\Prng">
<tag name="monolog.logger" channel="security" />
<argument type="service" id="security.prng_seed_provider" on-invalid="ignore" />
<argument type="service" id="logger" on-invalid="ignore" />
</service>
</services>
</container>

View File

@ -45,6 +45,7 @@
class="%security.authentication.rememberme.services.persistent.class%"
parent="security.authentication.rememberme.services.abstract"
abstract="true">
<call method="setPrng"><argument type="service" id="security.prng" /></call>
</service>
<service id="security.authentication.rememberme.services.simplehash"

View File

@ -2,6 +2,7 @@
$container->loadFromExtension('security', array(
'acl' => array(),
'prng_seed_provider' => 'custom_seed_provider',
'encoders' => array(
'JMS\FooBundle\Entity\User1' => 'plaintext',
'JMS\FooBundle\Entity\User2' => array(

View File

@ -5,7 +5,7 @@
xmlns:srv="http://symfony.com/schema/dic/services"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<config>
<config prng-seed-provider="custom_seed_provider">
<acl />
<encoder class="JMS\FooBundle\Entity\User1" algorithm="plaintext" />

View File

@ -1,5 +1,6 @@
security:
acl: ~
prng_seed_provider: custom_seed_provider
encoders:
JMS\FooBundle\Entity\User1: plaintext
JMS\FooBundle\Entity\User2:

View File

@ -168,6 +168,13 @@ abstract class SecurityExtensionTest extends \PHPUnit_Framework_TestCase
$this->assertEquals('foo', (string) $container->getAlias('security.acl.provider'));
}
public function testSeedProvider()
{
$container = $this->getContainer('container1');
$this->assertEquals('custom_seed_provider', (string) $container->getAlias('security.prng_seed_provider'));
}
protected function getContainer($file)
{
$container = new ContainerBuilder();

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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'));
}
}

View File

@ -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()