├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── benchmark ├── composer.json ├── phpunit.xml ├── psalm.xml ├── src └── ParameterRecommender.php └── tests └── ParameterRecommenderTest.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | old_php: 7 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 8 | runs-on: ${{ matrix.operating-system }} 9 | strategy: 10 | matrix: 11 | operating-system: ['ubuntu-latest'] 12 | php-versions: ['7.3'] 13 | phpunit-versions: ['latest'] 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php-versions }} 22 | extensions: mbstring, intl, sodium 23 | ini-values: post_max_size=256M, max_execution_time=180 24 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 25 | 26 | - name: Install dependencies 27 | run: composer install 28 | 29 | - name: PHPUnit tests 30 | uses: php-actions/phpunit@v2 31 | timeout-minutes: 30 32 | with: 33 | memory_limit: 256M 34 | 35 | - name: Static Analysis 36 | run: vendor/bin/psalm 37 | 38 | modern: 39 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} 40 | runs-on: ${{ matrix.operating-system }} 41 | strategy: 42 | matrix: 43 | operating-system: ['ubuntu-latest'] 44 | php-versions: ['7.4', '8.0'] 45 | phpunit-versions: ['latest'] 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v2 49 | 50 | - name: Setup PHP 51 | uses: shivammathur/setup-php@v2 52 | with: 53 | php-version: ${{ matrix.php-versions }} 54 | extensions: mbstring, intl, sodium 55 | ini-values: post_max_size=256M, max_execution_time=180 56 | tools: psalm, phpunit:${{ matrix.phpunit-versions }} 57 | 58 | - name: Install dependencies 59 | run: composer install 60 | 61 | - name: PHPUnit tests 62 | uses: php-actions/phpunit@v2 63 | timeout-minutes: 30 64 | with: 65 | memory_limit: 256M 66 | 67 | - name: Static Analysis 68 | run: vendor/bin/psalm 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * ISC License 3 | * 4 | * Copyright (c) 2019 5 | * Paragon Initiative Enterprises 6 | * 7 | * Permission to use, copy, modify, and/or distribute this software for any 8 | * purpose with or without fee is hereby granted, provided that the above 9 | * copyright notice and this permission notice appear in all copies. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 14 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 15 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 16 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 17 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 | */ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Argon2 Refiner 2 | 3 | [![Build Status](https://github.com/paragonie/argon2-refiner/actions/workflows/ci.yml/badge.svg)](https://github.com/paragonie/argon2-refiner/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/paragonie/argon2-refiner/v/stable)](https://packagist.org/packages/paragonie/argon2-refiner) 5 | [![Latest Unstable Version](https://poser.pugx.org/paragonie/argon2-refiner/v/unstable)](https://packagist.org/packages/paragonie/argon2-refiner) 6 | [![License](https://poser.pugx.org/paragonie/argon2-refiner/license)](https://packagist.org/packages/paragonie/argon2-refiner) 7 | [![Downloads](https://img.shields.io/packagist/dt/paragonie/argon2-refiner.svg)](https://packagist.org/packages/paragonie/argon2-refiner) 8 | 9 | Easily and effectively benchmark the real time to perform 10 | Argon2id password hashes on your machine. 11 | 12 | > Warning: This might take many seconds or minutes to complete. 13 | 14 | ## Installation Instructions 15 | 16 | Use [Composer](https://getcomposer.org/download). 17 | 18 | ``` 19 | composer require paragonie/argon2-refiner 20 | ``` 21 | 22 | Alternatively, you can install this with Git. 23 | 24 | ``` 25 | git clone https://github.com/paragonie/argon2-refiner 26 | cd argon2-refiner 27 | composer install 28 | ``` 29 | 30 | ## Usage Instructions 31 | 32 | ### Command Line 33 | 34 | Run the bundled `benchmark` script like so: 35 | 36 | ``` 37 | # Installed via Composer: 38 | vendor/bin/benchmark [milliseconds=500] [tolerance=250] 39 | 40 | # Installed via Git: 41 | composer run-benchmarks [milliseconds=500] [tolerance=250] 42 | ``` 43 | 44 | The expected output will look something like this: 45 | 46 | ``` 47 | $ vendor/bin/benchmark 125 48 | Recommended Argon2id parameters: 49 | Memory cost (sodium): 79691776 50 | Memory cost (password_hash): 77824 51 | Time cost: 3 52 | 53 | Real time: 124ms 54 | ``` 55 | 56 | This means that if you set your Argon2id mem_cost to `79691776` bytes 57 | (or `77824` KiB, which is what `password_hash()` expects) and the 58 | `time_cost` to 3, you will get the closest parameters that take about 59 | 125 milliseconds to process (in this example, it took 124). 60 | 61 | ### Object-Oriented API 62 | 63 | You can fine-tune your min/max costs to search within from the object 64 | by invoking the appropriate methods. 65 | 66 | ```php 67 | setMinMemory(1 << 20) 72 | ->setMaxMemory(1 << 31) 73 | ->setMinTime(2) 74 | ->setMaxTime(4) 75 | ->setTolerance(25); 76 | 77 | $results = $refiner->runBenchmarks(); 78 | ``` 79 | 80 | The `runBenchmarks()` method returns a two-dimensional array of arrays. 81 | Each child array consists of the following data: 82 | 83 | * `mem_cost` (int) -- Candidate parameter 84 | * `time_cost` (int) -- Candidate parameter 85 | * `bench_time` (int) -- Milliseconds elapsed in Argon2id calculation 86 | 87 | From this data, you can devise your own strategy for selecting which 88 | parameters set is most suitable for your environment. 89 | -------------------------------------------------------------------------------- /bin/benchmark: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | 1 ? $argv[1] : 500; 17 | $recommender = (new ParameterRecommender($ms)); 18 | if ($argc > 2) { 19 | $tolerance = (int) $argv[2]; 20 | if ($tolerance > 0) { 21 | $recommender->setTolerance($tolerance); 22 | } 23 | } 24 | 25 | $results = $recommender->runBenchmarks(); 26 | if (empty($results)) { 27 | echo 'No parameters meet your target time window.', PHP_EOL; 28 | exit(255); 29 | } 30 | 31 | $min = [ 32 | 'diff' => PHP_INT_MAX, 33 | 'data' => [ 34 | 'time_cost' => null, 35 | 'mem_cost' => null, 36 | 'bench_time' => PHP_INT_MAX 37 | ] 38 | ]; 39 | 40 | foreach ($results as $i => $res) { 41 | $weightedDiff = $res['bench_time'] - $ms; 42 | if ($weightedDiff > 0) { 43 | // Apply a penalty to overshots 44 | $weightedDiff *= 2; 45 | } else { 46 | $weightedDiff *= -1; 47 | } 48 | if ($weightedDiff < $min['diff']) { 49 | $min = [ 50 | 'diff' => $weightedDiff, 51 | 'data' => $res 52 | ]; 53 | } 54 | $results[$i]['diff'] = $weightedDiff; 55 | } 56 | 57 | $reduced = $min['data']['mem_cost'] >> 10; 58 | echo 'Recommended Argon2id parameters:', PHP_EOL; 59 | echo "\t Memory cost (sodium): {$min['data']['mem_cost']}\n"; 60 | echo "\tMemory cost (password_hash): {$reduced}\n"; 61 | echo "\t Time cost: {$min['data']['time_cost']}\n\n"; 62 | echo "Real time: {$min['data']['bench_time']}ms\n"; 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paragonie/argon2-refiner", 3 | "description": "Calculate the appropriate Argon2id parameters for the local hardware environment.", 4 | "license": "ISC", 5 | "authors": [ 6 | { 7 | "name": "Paragon Initiative Enterprises", 8 | "email": "security@paragonie.com" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "ParagonIE\\Argon2Refiner\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "ParagonIE\\Argon2Refiner\\Tests\\": "tests/" 19 | } 20 | }, 21 | "bin": [ 22 | "bin/benchmark" 23 | ], 24 | "scripts": { 25 | "post-autoload-dump": ["chmod +x bin/benchmark"], 26 | "run-benchmarks": ["bin/benchmark"] 27 | }, 28 | "require": { 29 | "php": ">=7.3" 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "^8|^9", 33 | "vimeo/psalm": "^3|^4" 34 | } 35 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/ParameterRecommender.php: -------------------------------------------------------------------------------- 1 | targetMilliseconds = $milliseconds; 42 | try { 43 | $this->testPassword = bin2hex(random_bytes(64)); 44 | } catch (\Throwable $ex) { 45 | $this->testPassword = str_repeat("X", 128); 46 | } 47 | } 48 | 49 | /** 50 | * @return string 51 | */ 52 | private function getBackend(): string 53 | { 54 | if ($this->backend === 'auto') { 55 | if (extension_loaded('sodium') && is_callable('sodium_crypto_pwhash_str')) { 56 | return 'sodium'; 57 | } 58 | return 'argon'; 59 | } 60 | return $this->backend; 61 | } 62 | 63 | /** 64 | * @return int 65 | */ 66 | public function getTarget(): int 67 | { 68 | return $this->targetMilliseconds; 69 | } 70 | 71 | /** 72 | * @param int $t 73 | * @param int $m 74 | * @return int (milliseconds) 75 | * @psalm-suppress InvalidArgument 76 | */ 77 | public function getMillisecondCost(int $t, int $m): int 78 | { 79 | $backend = $this->getBackend(); 80 | $start = $stop = 0.0; 81 | if ($backend === 'sodium') { 82 | $start = microtime(true); 83 | \sodium_crypto_pwhash_str( 84 | $this->testPassword, 85 | $t, 86 | $m 87 | ); 88 | $stop = microtime(true); 89 | } elseif ($backend === 'argon') { 90 | $arr = [ 91 | 'memory_cost' => $m, 92 | 'time_cost' => $t 93 | ]; 94 | $start = microtime(true); 95 | password_hash( 96 | $this->testPassword, 97 | PASSWORD_ARGON2ID, 98 | $arr 99 | ); 100 | $stop = microtime(true); 101 | } 102 | return (int) round(1000 * ($stop - $start)); 103 | } 104 | 105 | /** 106 | * @param int|null $distance 107 | * @return self 108 | */ 109 | public function setTolerance(?int $distance = null): self 110 | { 111 | $this->tolerance = $distance; 112 | return $this; 113 | } 114 | 115 | /** 116 | * @param int $milliseconds 117 | * @return int 118 | */ 119 | public function decide(int $milliseconds): int 120 | { 121 | if (is_null($this->tolerance)) { 122 | $diff = $this->targetMilliseconds >> 1; 123 | } else { 124 | $diff = $this->tolerance; 125 | } 126 | $min = $this->targetMilliseconds - $diff; 127 | $max = $this->targetMilliseconds + $diff; 128 | if ($milliseconds < $min) { 129 | // Too small 130 | return -1; 131 | } 132 | if ($milliseconds > $max) { 133 | // Too big 134 | return 1; 135 | } 136 | // Within reasonable bounds 137 | return 0; 138 | } 139 | 140 | /** 141 | * Returns an array of candidate values. It is structured like so: 142 | * [ 143 | * ['mem_cost' => X1, 'time_cost' => Y1, 'bench_time' => Z1], 144 | * ['mem_cost' => X2, 'time_cost' => Y2, 'bench_time' => Z2], 145 | * ] 146 | * 147 | * Internally, this uses a strategy similar to a binary search 148 | * rather than a linear scan to quickly identify candidate memory costs 149 | * within an acceptable range. All memory costs given are even multiples of 1KiB. 150 | * 151 | * Time costs are evaluated by a linear scan from min to max. Memory 152 | * costs are evaluated for each time cost. 153 | * 154 | * @return array 155 | */ 156 | public function runBenchmarks(): array 157 | { 158 | $success = []; 159 | for ($t = $this->minTime; $t <= $this->maxTime; ++$t) { 160 | $m = $this->minMemory; 161 | $diff = $this->maxMemory - $this->minMemory; 162 | while ($diff >= 1024) { 163 | $cost = $this->getMillisecondCost($t, $m); 164 | $decision = $this->decide($cost); 165 | 166 | $diff >>= 1; 167 | if ($decision === -1) { 168 | // Too small 169 | $m += $diff; 170 | } elseif ($decision === 1) { 171 | // Too big 172 | $m -= $diff; 173 | } else { 174 | // We found one within range! 175 | $success[]= [ 176 | 'mem_cost' => $m, 177 | 'time_cost' => $t, 178 | 'bench_time' => $cost 179 | ]; 180 | /* 181 | We're still going to look for other values to the right of this one, 182 | since we want to prioritize conservative security estimates that still 183 | meet acceptable performance benchmarks. If performance was a higher 184 | concern, we'd decrease $diff in this case. 185 | */ 186 | $m += $diff; 187 | } 188 | // Mask the lower bits so we're always dealing with KB blocks 189 | $m &= 0x7fffffffffffe000; 190 | } 191 | } 192 | usort($success, function (array $a, array $b): int { 193 | return $b['bench_time'] <=> $a['bench_time']; 194 | }); 195 | return $success; 196 | } 197 | 198 | /** 199 | * @param int $requestsPerSecond 200 | * @return self 201 | */ 202 | public static function forRequestsPerSecond(int $requestsPerSecond = 5): self 203 | { 204 | if ($requestsPerSecond < 1) { 205 | throw new \RangeException('Requests per second cannot be zero or negative'); 206 | } 207 | /** @var int $time */ 208 | $time = (int) round(1000 / $requestsPerSecond); 209 | return new self($time); 210 | } 211 | 212 | /** 213 | * @param string $target 214 | * @return self 215 | */ 216 | public function specifyBackend(string $target): self 217 | { 218 | switch (strtolower($target)) { 219 | case 'auto': 220 | case 'argon': 221 | case 'sodium': 222 | $this->backend = $target; 223 | break; 224 | case 'argon2': 225 | case 'libargon': 226 | case 'libargon2': 227 | $this->backend = 'argon'; 228 | break; 229 | case 'nacl': 230 | case 'libsodium': 231 | $this->backend = 'sodium'; 232 | break; 233 | default: 234 | throw new \InvalidArgumentException( 235 | "Invalid backend: ". $target 236 | ); 237 | } 238 | return $this; 239 | } 240 | 241 | /** 242 | * @param int $min 243 | * @return self 244 | */ 245 | public function setMinMemory(int $min): self 246 | { 247 | $this->minMemory = $min; 248 | return $this; 249 | } 250 | 251 | /** 252 | * @param int $max 253 | * @return self 254 | */ 255 | public function setMaxMemory(int $max): self 256 | { 257 | $this->maxMemory = $max; 258 | return $this; 259 | } 260 | 261 | /** 262 | * @param int $min 263 | * @return self 264 | */ 265 | public function setMinTime(int $min): self 266 | { 267 | $this->minTime = $min; 268 | return $this; 269 | } 270 | 271 | /** 272 | * @param int $max 273 | * @return self 274 | */ 275 | public function setMaxTime(int $max): self 276 | { 277 | $this->maxTime = $max; 278 | return $this; 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /tests/ParameterRecommenderTest.php: -------------------------------------------------------------------------------- 1 | assertSame(500, $par->getTarget()); 18 | 19 | $this->assertSame(250, ParameterRecommender::forRequestsPerSecond(4)->getTarget()); 20 | $this->assertSame(125, ParameterRecommender::forRequestsPerSecond(8)->getTarget()); 21 | $this->assertSame(100, ParameterRecommender::forRequestsPerSecond(10)->getTarget()); 22 | $this->assertSame( 40, ParameterRecommender::forRequestsPerSecond(25)->getTarget()); 23 | } 24 | 25 | public function testDecision() 26 | { 27 | $par = new ParameterRecommender(500); 28 | $this->assertSame(-1, $par->setTolerance(100)->decide(250)); 29 | $this->assertSame(0, $par->setTolerance(250)->decide(250)); 30 | $this->assertSame(-1, $par->setTolerance(250)->decide(100)); 31 | $this->assertSame(1, $par->setTolerance(100)->decide(750)); 32 | $this->assertSame(0, $par->setTolerance(250)->decide(750)); 33 | $this->assertSame(1, $par->setTolerance(250)->decide(1000)); 34 | 35 | $par = new ParameterRecommender(250); 36 | $this->assertSame(-1, $par->decide(124)); 37 | $this->assertSame(0, $par->decide(125)); 38 | $this->assertSame(0, $par->decide(375)); 39 | $this->assertSame(1, $par->decide(376)); 40 | 41 | $par->setTolerance(50); 42 | $this->assertSame(-1, $par->decide(199)); 43 | $this->assertSame(0, $par->decide(200)); 44 | $this->assertSame(0, $par->decide(300)); 45 | $this->assertSame(1, $par->decide(301)); 46 | } 47 | } 48 | --------------------------------------------------------------------------------