Skip to content

Instantly share code, notes, and snippets.

@roycewilliams
Last active October 8, 2023 22:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save roycewilliams/09ddd10504d560c02b28049759cd666f to your computer and use it in GitHub Desktop.
Save roycewilliams/09ddd10504d560c02b28049759cd666f to your computer and use it in GitHub Desktop.
sha512crypt PHP autotune demo
<?php
//------------------------------------------------------------------------
// Name: sha512crypt_autotune_demo.php v1.1
// Purpose: demo of tuning sha512crypt rounds to match platform speed
// Author: Royce Williams - royce@techsolvency.com
// License: MIT
// Source: https://gist.github.com/roycewilliams/09ddd10504d560c02b28049759cd666f
//
// To test, SSH to your system, fetch the file, and then run it as PHP.
//
// You can help! To pick default values, I'd like to collect the output
// from this script from a variety of platforms, including both the common
// generic platforms and for official Netgate / pfSense appliances.
// Please post results in a comment on the gist!
//
//
// Example usage:
//
// $ fetch https://gist.githubusercontent.com/roycewilliams/09ddd10504d560c02b28049759cd666f/raw/sha512crypt_autotune_demo.php
// $ php sha512crypt_autotune_demo.php
// Tuning sha512crypt rounds ...
// Elapsed time: 0.34419012069702, rounds: 100000
// Elapsed time: 0.45798897743225, rounds: 145268
// Final autotuned rounds: 158593
//
// PHP version: 7.4.20
// Attempting 'dmidecode -s processor-version' ...
// Processor: AMD GX-412TC SOC
//
// Hash:
// $6$rounds=158593$eyEH7J2wcQB8JxHs$wHZIrl1WmOXB6dDqIEqhXjhLOiB/ba1qmsZbv.MUpuWmtrT3gKfVe7LzwN2uNqd47mhg2QXM5qC//KYtZyYcf0
//
//
// Convenience fetch command for Linux:
//
// wget -O sha512crypt_autotune_demo.php https://gist.githubusercontent.com/roycewilliams/09ddd10504d560c02b28049759cd666f/raw/sha512crypt_autotune_demo.php
//
// References:
//
// https://redmine.pfsense.org/issues/12863 - feature: request to use this approach
//
// https://redmine.pfsense.org/issues/12800 - bug: suboptimal hashing
// https://redmine.pfsense.org/issues/12855 - feature: allow user to choose hashing method
// https://www.php.net/manual/en/function.crypt.php, esp. Steve Thomas (tobtu) and Marten Jacobs comments
// https://www.php.net/manual/en/function.random-bytes.php
// https://stackoverflow.com/a/37244092/263879 - Scott Arciszewski answer
//
//------------------------------------------------------------------------
// Version history
//
// 1.1 - 2022-02-24
// Add simple average, to cover cases of high test variability.
//
// 1.0 - 2022-02-23
// Initial version.
//
//------------------------------------------------------------------------
//function command_exist($cmd) {
// $return = shell_exec(sprintf("which %s", escapeshellarg($cmd)));
// return !empty($return);
// }
function hash_sha512crypt($password, $rounds) {
// Purpose: Hash a password using sha512crypt.
// This is to support apparent compliance use cases. To implement
// sha512crypt, we must use crypt() and roll our own salt - because
// password_hash() (rightly) only supports bcrypt and Argon2i/2id.
// First, generate the salt.
// Valid chars are [A-Za-z0-9/.], per multiple mkpasswd() sources.
// For size, we follow bcrypt and scrypt (128 bits) as feasible, so
// we use the entire character set across all 16 possible characters.
// This is to maximize uniqueness of hashes globally (not just locally),
// for robust precomputation resistance even for very common passwords.
// By using base64 instead of hex, we increase the nominal max salt
// "space" from 16^16 (2x10^19) to 64^16 (8x10^28).
// We generate more bytes than we need and then truncate, to avoid
// reduced randomness of the last characters in encoded base64.
// Since '+' is valid in base64, but not valid in sha512crypt salts,
// we replace '+' with '.'.
$salt = substr(base64_encode(random_bytes(22)), 0, 16);
$salt = str_replace('+', '.', $salt);
// Hash the password.
// When used for sha512crypt, the PHP crypt() 'salt' parameter is
// overloaded to include the entire hash prefix - including hash type,
// rounds, and the real salt. The hash type is detected as sha512crypt
// when the 'salt' string begins with '$6$'.
$hash_prefix = '$6$' . sprintf('rounds=%d', $rounds) . '$' . $salt . '$' ;
$hash = crypt($password, $hash_prefix);
return $hash;
}
//------------------------------------------------------------------------
function get_target_sha512crypt_rounds($target_seconds) {
// Purpose: Tune sha512crypt rounds to a target runtime.
// Note that we do *not* set a rounds value once globally, nor do we
// normalize or round up or down here, by design. This is because
// having a variable number of rounds is a security feature, to resist
// correlation attacks (JtR's single mode or hashcat -a 9 mode).
// Some variability in runtime also provides rough protection
// against sha512crypt's "guess how long the password is" flaw
// (see https://pthree.org/2018/05/23/do-not-use-sha256crypt-sha512crypt-theyre-dangerous/)
// Set a test password to use for the tuning.
// sha512crypt speed roughly increases with password length, so we
// pick a test password that is larger than an average simple password,
// but smaller than a passphrase.
$test_password = 'pfsense89ABCDEF';
// To minimize testing time, pick a relatively small value relative to
// modern performance for common platforms, but large enough to offset
// some variability in runtime. Very old systems may take significantly
// longer, so the initial rounds_candidate value may need to be adjusted.
$rounds_candidate = 100000;
// How close we have to get to the target seconds to be "good enough".
$accuracy_margin_seconds = .1;
// In the worst case, if system load variability is so unusually
// variable that we cannot get within $accuracy_margin_seconds in a few
// iterations of testing, we will exit after this number of tests.
// If this max is exceeded, we will use an average of the tests.
$max_test_iterations = 5;
// Set a minimum number of rounds.
// If the platform is slow, attack can happen on a faster system,
// so this value should be as high as can be tolerated across the
// expected fleet of systems we can reasonably expect to support.
// PHP's current minimum is 1000, so this value should never be less.
// For attack resistance, it should be far more than 1000 or even 5000.
$minimum_rounds = 50000;
// Adjust rounds until hash time is roughly close to the target time.
// Since we use the results to calculate the next run, and we don't
// care if it's rough, this loop should only run a couple of times.
//
// Reference round counts, idle (dmidecode -s processor-version):
// (Examples wanted - especially old and new pfSense/Netgate appliances)
//
// - AMD Geode LX800 500 MHz (alix2): rounds=11851
// - AMD GX-412TC SOC (apu2): rounds=157921
// - Intel(R) Celeron(R) CPU N3150 @ 1.60GHz: rounds=209662
// - Pentium(R) Dual-Core CPU E5: rounds=568985
// - 11th Gen Intel(R) Core(TM) i7-11700K @ 3.60GHz: rounds=1741092
//
// By contrast, a medium-sized pentest cracking rig (equivalent of 6 GTX
// 1080s) can do a little over 2 *billion* rounds in half a second against
// a single hash (scaling downward against multiple salted hashes). So the
// goal is to counter such attack speeds by as much as can be tolerated.
$test_elapsed_secs = 0;
$test_iteration_count = 0;
$cumulative_test_time = 0;
$cumulative_rounds = 0;
// Get the initial benchmark rounds. We do this outside of the tuning
// loop, because it is expected to be significantly different from
// target performance and would skew the average (if it's needed).
$start = microtime(true);
print "Starting ...<br/>\n";
#ob_flush();
$test_hash = hash_sha512crypt($test_password, $rounds_candidate);
$test_elapsed_secs = microtime(true) - $start;
print "- Initial benchmark test time: " . $test_elapsed_secs . ", rounds: $rounds_candidate<br>\n";
$perf_ratio = $target_seconds / $test_elapsed_secs;
$rounds_candidate = intval($rounds_candidate * $perf_ratio);
// Now tune for target performance.
while ( (abs($target_seconds - $test_elapsed_secs) > $accuracy_margin_seconds)
&& ($test_iteration_count < $max_test_iterations) ) {
// Time the hash.
$start = microtime(true);
$test_hash = hash_sha512crypt($test_password, $rounds_candidate);
$test_elapsed_secs = microtime(true) - $start;
$test_iteration_count++;
// In case we never get close to the target, accumulate average.
$cumulative_test_time += $test_elapsed_secs;
$cumulative_rounds += $rounds_candidate;
$average_test_secs = $cumulative_test_time / $test_iteration_count;
$average_rounds = intval($cumulative_rounds / $test_iteration_count);
print "- Tuning iteration $test_iteration_count: " . $test_elapsed_secs . ", rounds: $rounds_candidate, ";
print "average: $average_test_secs - $average_rounds<br>\n";
// Adjust the next number of rounds based on the runtime.
$perf_ratio = $target_seconds / $test_elapsed_secs;
$rounds_candidate = intval($rounds_candidate * $perf_ratio);
// If we've exceeded the max, use the average.
if ($test_iteration_count >= $max_test_iterations) {
$rounds_candidate = $average_rounds;
}
}
print "- Final autotuned rounds: $rounds_candidate<br>\n";
// If rounds are below minimum, warn the user and use the minimum instead.
// Should only happen on very old hardware.
if ($rounds_candidate < $minimum_rounds) {
fwrite(STDERR, "Warning: detected rounds $rounds_candidate is less than minimum of $minimum_rounds - using minimum\n");
$rounds_candidate = $minimum_rounds;
}
return $rounds_candidate;
}
//------------------------------------------------------------------------
function show_processor() {
// For demo, show processor (assumes the current user can run dmidecode).
#print "<br>\n--- Attempting <kbd>dmidecode -s processor-version</kbd> ...\n";
#system('dmidecode -s processor-version');
if (file_exists('/proc/cpuinfo')) {
$cpuinfo_array = explode("\n", file_get_contents('/proc/cpuinfo'));
$match_array = preg_grep('/model name/', $cpuinfo_array);
if (array_key_exists(0, $match_array)) {
print "Processor: ";
print "$match_array[0]\n";
}
} else {
print "Processor: ";
print "<br>\n- Attempting <kbd>sysctl hw.model</kbd> ...</br>\n";
print "--- ";
#system('sysctl hw.model');
system('/sbin/sysctl hw.model');
}
print "<br>\n";
print "<br>\n";
}
//------------------------------------------------------------------------
// Main.
$password = 'ilovepfsense';
// Determine the number of rounds.
// On modern CPUs, the default rounds of 5000 is too 'fast' (good for
// the attacker). Instead, dynamically tune this value to ~ .5s runtime
// on the current platform. This should usually only roughly double the
// computation cost of storing the hash, but is only incurred once at
// storage time.
$target_runtime_seconds = .5;
#ob_end_flush();
#ob_start();
#ob_implicit_flush(true);
print "<!DOCTYPE html>\n<html lang=\"en-US\">\n<head><title>sha512crypt autotune demo</title></head>\n<body>\n";
print "<h1>Demo of autotuning sha512crypt in PHP</h1>\n";
print "<p><em>Note: sha512crypt is deprecated - only use if required for some external reason</em></p>\n";
print "Tuning sha512crypt rounds (target runtime: $target_runtime_seconds seconds) ...<br>\n";
print "PHP version: " . phpversion() . "<br>\n";
show_processor();
// Get target rounds.
$rounds = get_target_sha512crypt_rounds($target_runtime_seconds);
// Hash the password.
//
$user['sha512-hash'] = hash_sha512crypt($password, $rounds);
print "<br>\n";
print "Example hash (password: \"$password\"):<br>\n";
print $user['sha512-hash'] . "\n";
print "<hr>\n";
print "<p><a href=\"https://gist.github.com/roycewilliams/09ddd10504d560c02b28049759cd666f\">Source</a></p>\n";
print "<br>\n</body>\n</html>\n";
//------------------------------------------------------------------------
?>
@atoponce
Copy link

Comes in around 1,400,000 rounds to target 0.5 seconds on my processor (give or take):

# dmidecode -s processor-version
AMD Ryzen 5 3400G with Radeon Vega Graphics

# repeat 5; do php8.1 sha512crypt_autotune_demo.php | grep 'rounds='; done
$6$rounds=1405778$EGse72XiJG80LtOd$a3RdfYT28F0nj0Q3bgan7ueDpuAz5I0JREjUYzXBNhJdvkTt56rJi1wo4IKYcWPK8OQ4K02UxQFPjFNpkVgw2.
$6$rounds=1386376$XPUZ.Us1WMu7bNun$.dTnUCaz0fJwzZrXUdsssbbPrbNV2EW18827To/ZeMdRhnniSngOHMZx6JwVJIU.oq3CfKZ8ATRh2ntTVDIp70
$6$rounds=1319568$t5jpHD2yLB243bNq$q5pXhr0TWzIUMeuzTuFaMXPJebZ5z4/sqFbXCwrOSLi6mfJXukTresosbWZjNzdhCTZv.ICEN5fzY7WPuv3oS/
$6$rounds=1378969$1H3MTrlU2p00GCjd$TcQ5AwBByOO4PhUk8YCMWUePBv9ME2ZIti3ueniWK5mLd3bKfxgLCJSIziyGxONJBFBZWpV8Y62L6DVEZZkol.
$6$rounds=1394109$.E/KjGDKjs6TeF.b$/RMnZMslrxsEsyAesBL5rZmRaCHj33RKqxVaHX5D9OqAXK2ETDSwADNr.DQA.mqlaZirf3iZ11ck0yFfmWO01.

Doing a quick sanity check with mkpasswd(1) to make sure PHP isn't stepping on any performance or you have any other bottlenecks. Timing 1,000,000 rounds and iterating every 100,000 up to 2,000,000 rounds. Comes is around 1,400,000 - 1,600,000:

# for ((i=1000000;i<2000000;i+=100000)); do printf "${i}: "; echo 'ilovepfsense' | time mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random; done
1000000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.36s user 0.00s system 99% cpu 0.358 total
1100000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.44s user 0.00s system 99% cpu 0.439 total
1200000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.45s user 0.00s system 99% cpu 0.454 total
1300000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.43s user 0.00s system 99% cpu 0.436 total
1400000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.47s user 0.00s system 99% cpu 0.474 total
1500000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.50s user 0.00s system 99% cpu 0.502 total
1600000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.57s user 0.00s system 99% cpu 0.572 total
1700000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.57s user 0.00s system 99% cpu 0.573 total
1800000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.65s user 0.00s system 99% cpu 0.654 total
1900000: mkpasswd -S GzFb3slKegqTRyn4 -R "$i" -m sha-512 -s > /dev/random  0.65s user 0.01s system 99% cpu 0.658 total

Script seems reasonable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment