What's wrong with my implementation of 2 Factor Authorization?

0

I'm trying to implement my own PHP function to generate codes for Google Authenticator. I do it for fun and to learn something new. Here's what I did:

function twoFactorAuthorizationCode(string $secretBase32, int $digitsCount): string {
    $counter = (int) (time() / 30);
    $secret = Base32::decode($secretBase32);
    $hash = hash_hmac('sha1', $counter, $secret, true); // 20 binary characters
    $hexHash = unpack('H*', $hash)[1]; // 40 hex characters
    $offset = hexdec($hexHash[-1]); // last 4 bits of $hash
    $truncatedHash = hexdec(substr($hexHash, $offset * 2, 8)) & 0x7fffffff; // last 31 bits
    $code = $truncatedHash % (10 ** $digitsCount);

    return str_pad($code, $digitsCount, '0', STR_PAD_LEFT);
}

I'm not sure which step is wrong, but it doesn't generate the same results as Google Authenticator. Obviously, I tried to play with time offsets in case my clock is not in sync with Google Authenticator's.

Some of the things I'm not sure are:

  • Should the secret be decoded from Base32, or should it stay the Base32 string?
  • Is the counter a value or a key for SHA1 hash?

I did a lot of experiments and I can't get my algorithm to generate a valid result. Any advice is highly appreciated.

php
authorization
sha1
one-time-password
google-authenticator
asked on Stack Overflow Jan 14, 2020 by Robo Robok • edited Jan 14, 2020 by Robo Robok

1 Answer

1

I have found the answer by trials and errors. So, the problem was in the $counter value that I've been hashing directly:

$hash = hash_hmac('sha1', $counter, $secret, true);

Instead, it should be a 64-bit binary string made from the $counter:

$packedCounter = pack('J', $counter);
$hash = hash_hmac('sha1', $packedCounter, $secret, true);

Explanation

Let's say our Unix timestamp is 1578977176.

That makes the counter as follows: (int) (1578977176 / 30) = 52632572.

The value used for hashing needs to be a 64-bit, big endian byte order string. It means that we need to left-pad it with zeros to make it 64-bit.

52632572 is 11001000110001101111111100 in binary. That's just 26 bits, so we need 38 more. What we have now is:

0000000000000000000000000000000000000011001000110001101111100010.

Every character is one byte, so we split it into the groups of 8:

00000000 00000000 00000000 00000000 00000011 00100011 00011011 11100010

We can now convert every group to a character by its code:

$packedCounter = chr(0b00000000)
               . chr(0b00000000)
               . chr(0b00000000)
               . chr(0b00000000)
               . chr(0b00000011)
               . chr(0b00100011)
               . chr(0b00011011)
               . chr(0b11100010);

And that's the string we want to hash, which is exactly what pack('J', $string) does.

VoilĂ !

answered on Stack Overflow Jan 14, 2020 by Robo Robok • edited Jun 20, 2020 by Community

User contributions licensed under CC BY-SA 3.0