<?php
declare(strict_types=1);
namespace ParagonIE\Argon2Refiner;
use http\Exception\InvalidArgumentException;
/**
* Class ParameterRecommender
* @package ParagonIE\Argon2Refiner
*/
class ParameterRecommender
{
/** @var int $targetMilliseconds */
private $targetMilliseconds = 500;
/** @var string $backend Resolves to "argon" or "sodium" */
private $backend = 'auto';
/** @var int $minMemory */
private $minMemory = 16777216;
/** @var int $maxMemory */
private $maxMemory = 268435456;
/** @var int $minTime */
private $minTime = 2;
/** @var int $maxTime */
private $maxTime = 9;
/** @var string $testPassword */
private $testPassword = '';
/** @var int|null $tolerance */
private $tolerance = null;
/**
* ParameterRecommender constructor.
* @param int $milliseconds
*/
public function __construct(int $milliseconds = 500)
{
$this->targetMilliseconds = $milliseconds;
try {
$this->testPassword = bin2hex(random_bytes(64));
} catch (\Throwable $ex) {
$this->testPassword = str_repeat("X", 128);
}
}
/**
* @return string
*/
private function getBackend(): string
{
if ($this->backend === 'auto') {
if (extension_loaded('sodium') && is_callable('sodium_crypto_pwhash_str')) {
return 'sodium';
}
return 'argon';
}
return $this->backend;
}
/**
* @return int
*/
public function getTarget(): int
{
return $this->targetMilliseconds;
}
/**
* @param int $t
* @param int $m
* @return int (milliseconds)
*/
public function getMillisecondCost(int $t, int $m): int
{
$backend = $this->getBackend();
$start = $stop = 0.0;
if ($backend === 'sodium') {
$start = microtime(true);
sodium_crypto_pwhash_str(
$this->testPassword,
$t,
$m
);
$stop = microtime(true);
} elseif ($backend === 'argon') {
$arr = [
'memory_cost' => $m,
'time_cost' => $t
];
$start = microtime(true);
password_hash(
$this->testPassword,
PASSWORD_ARGON2ID,
$arr
);
$stop = microtime(true);
}
return (int) round(1000 * ($stop - $start));
}
/**
* @param int|null $distance
* @return self
*/
public function setTolerance(?int $distance = null): self
{
$this->tolerance = $distance;
return $this;
}
/**
* @param int $milliseconds
* @return int
*/
public function decide(int $milliseconds): int
{
if (is_null($this->tolerance)) {
$diff = $this->targetMilliseconds >> 1;
} else {
$diff = $this->tolerance;
}
$min = $this->targetMilliseconds - $diff;
$max = $this->targetMilliseconds + $diff;
if ($milliseconds < $min) {
// Too small
return -1;
}
if ($milliseconds > $max) {
// Too big
return 1;
}
// Within reasonable bounds
return 0;
}
/**
* Returns an array of candidate values. It is structured like so:
* [
* ['mem_cost' => X1, 'time_cost' => Y1, 'bench_time' => Z1],
* ['mem_cost' => X2, 'time_cost' => Y2, 'bench_time' => Z2],
* ]
*
* Internally, this uses a strategy similar to a binary search
* rather than a linear scan to quickly identify candidate memory costs
* within an acceptable range. All memory costs given are even multiples of 1KiB.
*
* Time costs are evaluated by a linear scan from min to max. Memory
* costs are evaluated for each time cost.
*
* @return array
*/
public function runBenchmarks(): array
{
$success = [];
for ($t = $this->minTime; $t <= $this->maxTime; ++$t) {
$m = $this->minMemory;
$diff = $this->maxMemory - $this->minMemory;
while ($diff >= 1024) {
$cost = $this->getMillisecondCost($t, $m);
$decision = $this->decide($cost);
$diff >>= 1;
if ($decision === -1) {
// Too small
$m += $diff;
} elseif ($decision === 1) {
// Too big
$m -= $diff;
} else {
// We found one within range!
$success[]= [
'mem_cost' => $m,
'time_cost' => $t,
'bench_time' => $cost
];
/*
We're still going to look for other values to the right of this one,
since we want to prioritize conservative security estimates that still
meet acceptable performance benchmarks. If performance was a higher
concern, we'd decrease $diff in this case.
*/
$m += $diff;
}
// Mask the lower bits so we're always dealing with KB blocks
$m &= 0x7fffffffffffe000;
}
}
usort($success, function (array $a, array $b): int {
return $b['bench_time'] <=> $a['bench_time'];
});
return $success;
}
/**
* @param int $requestsPerSecond
* @return self
*/
public static function forRequestsPerSecond(int $requestsPerSecond = 5): self
{
if ($requestsPerSecond < 1) {
throw new \RangeException('Requests per second cannot be zero or negative');
}
/** @var int $time */
$time = (int) round(1000 / $requestsPerSecond);
return new self($time);
}
/**
* @param string $target
* @return self
*/
public function specifyBackend(string $target): self
{
switch (strtolower($target)) {
case 'auto':
case 'argon':
case 'sodium':
$this->backend = $target;
break;
case 'argon2':
case 'libargon':
case 'libargon2':
$this->backend = 'argon';
break;
case 'nacl':
case 'libsodium':
$this->backend = 'sodium';
break;
default:
throw new \InvalidArgumentException(
"Invalid backend: ". $target
);
}
return $this;
}
/**
* @param int $min
* @return self
*/
public function setMinMemory(int $min): self
{
$this->minMemory = $min;
return $this;
}
/**
* @param int $max
* @return self
*/
public function setMaxMemory(int $max): self
{
$this->maxMemory = $max;
return $this;
}
/**
* @param int $min
* @return self
*/
public function setMinTime(int $min): self
{
$this->minTime = $min;
return $this;
}
/**
* @param int $max
* @return self
*/
public function setMaxTime(int $max): self
{
$this->maxTime = $max;
return $this;
}
}
|