| 
<?phpdeclare(strict_types=1);
 namespace ParagonIE\Argon2Refiner;
 
 /**
 * 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)
 * @psalm-suppress InvalidArgument
 */
 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;
 }
 }
 
 |