diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 003a9774..c40b8dc5 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -51,6 +51,31 @@ protected function __construct() { parent::__construct(); } + /** + * Timestamp returned by time() + * + * @var int $now + */ + private static $now; + + /** + * Override time() in the current object for testing. + * + * @return int + */ + private static function time() { + return self::$now ?: time(); + } + + /** + * Set up the internal state of time() invocations for deterministic generation. + * + * @param int $now Timestamp to use when overriding time(). + */ + public static function set_time( $now ) { + self::$now = $now; + } + /** * Register the rest-api endpoints required for this provider. * @@ -529,11 +554,13 @@ public function validate_code_for_user( $user, $code ) { * * @param string $key The share secret key to use. * @param string $authcode The code to test. + * @param string $hash The hash used to calculate the code. + * @param int $time_step The size of the time step. * * @return bool Whether the code is valid within the time frame. */ - public static function is_valid_authcode( $key, $authcode ) { - return (bool) self::get_authcode_valid_ticktime( $key, $authcode ); + public static function is_valid_authcode( $key, $authcode, $hash = self::DEFAULT_CRYPTO, $time_step = self::DEFAULT_TIME_STEP_SEC ) { + return (bool) self::get_authcode_valid_ticktime( $key, $authcode, $hash, $time_step ); } /** @@ -541,10 +568,12 @@ public static function is_valid_authcode( $key, $authcode ) { * * @param string $key The share secret key to use. * @param string $authcode The code to test. + * @param string $hash The hash used to calculate the code. + * @param int $time_step The size of the time step. * * @return false|int Returns the timestamp of the authcode on success, False otherwise. */ - public static function get_authcode_valid_ticktime( $key, $authcode ) { + public static function get_authcode_valid_ticktime( $key, $authcode, $hash = self::DEFAULT_CRYPTO, $time_step = self::DEFAULT_TIME_STEP_SEC ) { /** * Filter the maximum ticks to allow when checking valid codes. * @@ -562,11 +591,13 @@ public static function get_authcode_valid_ticktime( $key, $authcode ) { $ticks = range( - $max_ticks, $max_ticks ); usort( $ticks, array( __CLASS__, 'abssort' ) ); - $time = floor( time() / self::DEFAULT_TIME_STEP_SEC ); + $time = floor( self::time() / self::DEFAULT_TIME_STEP_SEC ); + + $digits = strlen( $authcode ); foreach ( $ticks as $offset ) { $log_time = $time + $offset; - if ( hash_equals( self::calc_totp( $key, $log_time ), $authcode ) ) { + if ( hash_equals( self::calc_totp( $key, $log_time, $digits, $hash, $time_step ), $authcode ) ) { // Return the tick timestamp. return $log_time * self::DEFAULT_TIME_STEP_SEC; } @@ -619,6 +650,30 @@ public static function pack64( $value ) { return pack( 'NN', $higher, $lower ); } + /** + * Pad a short secret with bytes from the same until it's the correct length + * for hashing. + * + * @param string $secret Secret key to pad. + * @param int $length Byte length of the desired padded secret. + * + * @throws InvalidArgumentException If the secret or length are invalid. + * + * @return string + */ + protected static function pad_secret( $secret, $length ) { + if ( empty( $secret ) ) { + throw new InvalidArgumentException( 'Secret must be non-empty!' ); + } + + $length = intval( $length ); + if ( $length <= 0 ) { + throw new InvalidArgumentException( 'Padding length must be non-zero' ); + } + + return str_pad( $secret, $length, $secret, STR_PAD_RIGHT ); + } + /** * Calculate a valid code given the shared secret key * @@ -627,21 +682,37 @@ public static function pack64( $value ) { * @param int $digits The number of digits in the returned code. * @param string $hash The hash used to calculate the code. * @param int $time_step The size of the time step. + * + * @throws InvalidArgumentException If the hash type is invalid. * * @return string The totp code */ public static function calc_totp( $key, $step_count = false, $digits = self::DEFAULT_DIGIT_COUNT, $hash = self::DEFAULT_CRYPTO, $time_step = self::DEFAULT_TIME_STEP_SEC ) { $secret = self::base32_decode( $key ); + switch ( $hash ) { + case 'sha1': + $secret = self::pad_secret( $secret, 20 ); + break; + case 'sha256': + $secret = self::pad_secret( $secret, 32 ); + break; + case 'sha512': + $secret = self::pad_secret( $secret, 64 ); + break; + default: + throw new InvalidArgumentException( 'Invalid hash type specified!' ); + } + if ( false === $step_count ) { - $step_count = floor( time() / $time_step ); + $step_count = floor( self::time() / $time_step ); } $timestamp = self::pack64( $step_count ); $hash = hash_hmac( $hash, $timestamp, $secret, true ); - $offset = ord( $hash[19] ) & 0xf; + $offset = ord( $hash[ strlen( $hash ) - 1 ] ) & 0xf; $code = ( ( ( ord( $hash[ $offset + 0 ] ) & 0x7f ) << 24 ) | diff --git a/tests/providers/class-two-factor-totp.php b/tests/providers/class-two-factor-totp.php index 52aa6090..909e52c7 100644 --- a/tests/providers/class-two-factor-totp.php +++ b/tests/providers/class-two-factor-totp.php @@ -14,6 +14,18 @@ */ class Tests_Two_Factor_Totp extends WP_UnitTestCase { + private static $token = '12345678901234567890'; + private static $step = 30; + + private static $vectors = [ + 59 => ['94287082', '46119246', '90693936'], + 1111111109 => ['07081804', '68084774', '25091201'], + 1111111111 => ['14050471', '67062674', '99943326'], + 1234567890 => ['89005924', '91819424', '93441116'], + 2000000000 => ['69279037', '90698825', '38618901'], + 20000000000 => ['65353130', '77737706', '47863826'] + ]; + /** * Instance of our provider class. * @@ -317,4 +329,122 @@ function test_get_authcode_valid_ticktime() { $this->assertFalse( Two_Factor_Totp::get_authcode_valid_ticktime( $key, '000000' ) ); } + + /** + * @covers Two_Factor_Totp::calc_totp + */ + public function test_sha1_generate() { + if ( PHP_INT_SIZE === 4 ) { + $this->markTestSkipped( 'calc_totp requires 64-bit PHP' ); + } + + $provider = $this->provider; + $hash = 'sha1'; + $token = $provider->base32_encode( self::$token ); + + foreach (self::$vectors as $time => $vector) { + $provider::set_time( (int) $time ); + $this->assertEquals( $vector[0], $provider::calc_totp( $token, false, 8, $hash, self::$step ) ); + $this->assertEquals( substr( $vector[0], 2 ), $provider::calc_totp( $token, false, 6, $hash, self::$step ) ); + } + } + + /** + * @covers Two_Factor_Totp::is_valid_authcode + * @covers Two_Factor_Totp::calc_totp + */ + public function test_sha1_authenticate() { + if ( PHP_INT_SIZE === 4 ) { + $this->markTestSkipped( 'calc_totp requires 64-bit PHP' ); + } + + $provider = $this->provider; + $hash = 'sha1'; + $token = $provider->base32_encode( self::$token ); + + foreach ( self::$vectors as $time => $vector ) { + $provider::set_time( (int) $time ); + $this->assertTrue( $provider::is_valid_authcode( $token, $vector[0], $hash ) ); + $this->assertTrue( $provider::is_valid_authcode( $token, substr( $vector[0], 2 ), $hash ) ); + } + } + + /** + * @covers Two_Factor_Totp::calc_totp + */ + public function test_sha256_generate() { + if (PHP_INT_SIZE === 4) { + $this->markTestSkipped( 'calc_totp requires 64-bit PHP' ); + } + + $provider = $this->provider; + $hash = 'sha256'; + $token = $provider->base32_encode( self::$token ); + + foreach ( self::$vectors as $time => $vector ) { + $provider::set_time( (int) $time ); + $this->assertEquals( $vector[1], $provider::calc_totp( $token, false, 8, $hash, self::$step ) ); + $this->assertEquals( substr( $vector[1], 2 ), $provider::calc_totp( $token, false, 6, $hash, self::$step ) ); + } + } + + /** + * @covers Two_Factor_Totp::is_valid_authcode + * @covers Two_Factor_Totp::calc_totp + */ + public function test_sha256_authenticate() { + if ( PHP_INT_SIZE === 4 ) { + $this->markTestSkipped( 'calc_totp requires 64-bit PHP' ); + } + + $provider = $this->provider; + $hash = 'sha256'; + $token = $provider->base32_encode( self::$token ); + + foreach ( self::$vectors as $time => $vector ) { + $provider::set_time( (int) $time ); + $this->assertTrue( $provider::is_valid_authcode( $token, $vector[1], $hash ) ); + $this->assertTrue( $provider::is_valid_authcode( $token, substr( $vector[1], 2 ), $hash ) ); + } + } + + /** + * @covers Two_Factor_Totp::calc_totp + */ + public function test_sha512_generate() { + if ( PHP_INT_SIZE === 4 ) { + $this->markTestSkipped('calc_totp requires 64-bit PHP'); + } + + $provider = $this->provider; + $hash = 'sha512'; + $token = $provider->base32_encode( self::$token ); + + foreach ( self::$vectors as $time => $vector ) { + $provider::set_time( (int) $time ); + $this->assertEquals( $vector[2], $provider::calc_totp( $token, false, 8, $hash, self::$step ) ); + $this->assertEquals( substr($vector[2], 2 ), $provider::calc_totp( $token, false, 6, $hash, self::$step ) ); + } + } + + /** + * @covers Two_Factor_Totp::is_valid_authcode + * @covers Two_Factor_Totp::calc_totp + */ + public function test_sha512_authenticate() { + if ( PHP_INT_SIZE === 4 ) { + $this->markTestSkipped( 'calc_totp requires 64-bit PHP' ); + } + + $provider = $this->provider; + $hash = 'sha512'; + $token = $provider->base32_encode( self::$token ); + + foreach ( self::$vectors as $time => $vector ) { + $provider::set_time( (int) $time ); + $this->assertTrue( $provider::is_valid_authcode( $token, $vector[2], $hash ) ); + $this->assertTrue( $provider::is_valid_authcode( $token, substr( $vector[2], 2 ), $hash ) ); + } + + } }