Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Full RFC6238 Compatibility #656

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -529,22 +554,26 @@ 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 );
}

/**
* Checks if a given code is valid for a given key, allowing for a certain amount of time drift.
*
* @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.
*
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
*
Expand All @@ -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 ) |
Expand Down
130 changes: 130 additions & 0 deletions tests/providers/class-two-factor-totp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 ) );
}

}
}
Loading