vb.nettwo-factor-authenticationtotpgoogle-2fa

Algorithm two factor authentification not working


I tried to build my own 2FA application with VB.NET, according to RFC 6238 (https://datatracker.ietf.org/doc/html/rfc6238).

I managed to reproduce the results of the appendix of the paper, but when I try to test it with a real 2FA mobile app, I got different results.

I asked ChatGPT for help ("generate a code that calculates a key like the google authenticator"), but the returned code segments didn't help either. The very few hints I found online, indicated that there might be a problem with the initial encoding of the text. "Base32" seems to be the needed encoding, but I cannot understand what this means exactly.

The FreeOTP apps (https://github.com/freeotp) for IOS and Android need different input secrets. Both apps show "Base32" at the text box, but while it is not possible to enter a 0, 1, 8 or 9 on IOS, it definitely is possible on Android.

So what does Base32 mean in this context? Must the secret key only contain [A-Z2-7], or will the algorithm do something with any alphanumeric input?

Here is my code:

   Public Function GenerateTOTP(secretKey As String, stepwidth As Long, stepdeviation As Integer, dt As DateTime, digits As UInteger) As String
       Dim unixEpoch As Long = Convert.ToInt64((dt - New DateTime(1970, 1, 1)).TotalSeconds)
        Dim steps As Long = Math.Floor(unixEpoch / stepwidth) + stepdeviation
        Dim secretKeyBytes As Byte() = Encoding.UTF8.GetBytes(secretKey.ToUpper)
        If (BitConverter.IsLittleEndian) Then Array.Reverse(secretKeyBytes) 'For RFC 6238 appendix, this line needs to be disabled

        Dim hmac As New HMACSHA1(secretKeyBytes)
        Dim stepbytes As Byte() = BitConverter.GetBytes(steps)
        If (BitConverter.IsLittleEndian) Then Array.Reverse(stepbytes)
        Dim hash As Byte() = hmac.ComputeHash(stepbytes)
        Dim offset As Integer = hash(hash.Length - 1) And 15

        'Additional check
        Dim binaryArray(3) As Byte
        Array.Copy(hash, offset, binaryArray, 0, 4)
        If (BitConverter.IsLittleEndian) Then Array.Reverse(binaryArray)
        Dim binaryBA As UInteger = BitConverter.ToUInt32(binaryArray, 0)

        Dim binary As UInteger = ((hash(offset) And 127) << 24) Or ((hash(offset + 1) And 255) << 16) Or ((hash(offset + 2) And 255) << 8) Or (hash(offset + 3) And 255)

        Dim totp As UInteger = binary Mod (10 ^ digits)
        Dim totpBA As UInteger = binaryBA Mod (10 ^ digits)
        Console.WriteLine("-------------------------------------------------------")
        Console.WriteLine("Key:                   " & secretKey)
        Console.WriteLine("Date:                  " & dt.ToString("dd.MM.yyyy HH:mm:ss"))
        Console.WriteLine("unixEpoch:             " & unixEpoch)
        Console.WriteLine("steps:                 " & steps)
        Console.WriteLine("Binary Norm UInt:      " & totp.ToString().PadLeft(digits, "0"c))
        Console.WriteLine("Binary Norm UInt check:" & totpBA.ToString().PadLeft(digits, "0"c))

        Return totp.ToString().PadLeft(digits, "0"c) & " " & totpBA.ToString().PadLeft(digits, "0"c)
    End Function

I guess the main problem lies in the UTF8 encoding, but I have no idea how I should convert my string to something Base32 compatible. I tried different encodings, played with Big and Little Endian, changed the bits, but nothing worked.

The RFC 6238 contains a class I don't understand. What does SecretKeySpec do? The crucial code segments do not show any hint of using Base32. Even the test key contains 0, 1, 8 and 9.

Here is an abstract of RFC 6238:

 private static byte[] hexStr2Bytes(String hex){
     // Adding one byte to get the right conversion
     // Values starting with "0" can be converted
     byte[] bArray = new BigInteger("10" + hex,16).toByteArray();   ' <--------- standard hex from ASCII or UTF8

     // Copy all the REAL bytes, not the "first"
     byte[] ret = new byte[bArray.length - 1];
     for (int i = 0; i < ret.length; i++)
         ret[i] = bArray[i+1];
     return ret;
 }

 private static byte[] hmac_sha(String crypto, byte[] keyBytes,
         byte[] text){
     try {
         Mac hmac;
         hmac = Mac.getInstance(crypto);
         SecretKeySpec macKey =                                ' <--------- no idea
             new SecretKeySpec(keyBytes, "RAW");
         hmac.init(macKey);
         return hmac.doFinal(text);
     } catch (GeneralSecurityException gse) {
         throw new UndeclaredThrowableException(gse);
     }
 }

 public static String generateTOTP(String key,
         String time,
         String returnDigits,
         String crypto){
     int codeDigits = Integer.decode(returnDigits).intValue();
     String result = null;

     // Using the counter
     // First 8 bytes are for the movingFactor
     // Compliant with base RFC 4226 (HOTP)
     while (time.length() < 16 )
         time = "0" + time;

     // Get the HEX in a Byte[]
     byte[] msg = hexStr2Bytes(time);
     byte[] k = hexStr2Bytes(key);
     byte[] hash = hmac_sha(crypto, k, msg);          ' <--------- just "normal" bytes, no Base32

     // put selected bytes into result int
     int offset = hash[hash.length - 1] & 0xf;

     int binary =
         ((hash[offset] & 0x7f) << 24) |
         ((hash[offset + 1] & 0xff) << 16) |
         ((hash[offset + 2] & 0xff) << 8) |
         (hash[offset + 3] & 0xff);

     int otp = binary % DIGITS_POWER[codeDigits];

     result = Integer.toString(otp);
     while (result.length() < codeDigits) {
         result = "0" + result;
     }
     return result;
 }

The strange thing is, when I use the test key "12345678901234567890" I got the results of the paper, but the FreeOTP app returns something different (I changed my phone time). On IOS I cannot even enter the key (as I stated above). Every 2FA app I checked returns the same result and refers to RFC 6238, but the algorithm doesn't work for me.

So what am I doing wrong? Do I have to include something Base32-ish to the code? If so, what should the encoding do exactly? What does SecretKeySpec do? Why is there a difference between the IOS and Android algorithms?


Solution

  • A conversation with ChatGPT finally got me the answer. It was a mixture of several mistakes:

    1. I didn't get that the RFC 6238 only explains the algorithm for hashing bytes, but not the creation or encoding of the bytes themselves.
    2. The key must consist of the Base32 alphabet to match the common composition of keys generated by websites. It is possible to create another set of characters, but you need [A-Z2-7] to fit the norm.
    3. The UTF-8 or ASCII strings needs to be translated to the Base32 alphabet. The index numbers have to be divided to 5 bit groups and then rearranged to 8 bit to create several bytes.
    4. Big and little endian have a huge impact on the result. ChatGPT and some online help mixed up the endians.

    Finally I was able to create a working class:

    Public Class TOTP
        Private Const Base32Alphabet As String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
    
        Public Function GenerateSecretKey(length As Byte) As String
            Dim rnd As New Random
            Dim output As String = ""
            For i = 0 To length - 1
                output &= Base32Alphabet(rnd.Next(32)).ToString
            Next
            Return output
        End Function
    
        Public Function GenerateTOTP(secretKey As String, stepwidth As Long, stepdeviation As Integer, dt As DateTime, digits As UInteger) As String
            Dim unixEpoch As Long = Convert.ToInt64((dt - New DateTime(1970, 1, 1)).TotalSeconds)
            Dim steps As Long = Math.Floor(unixEpoch / stepwidth) + stepdeviation
    
            Dim secretKeyBytes As Byte() = Base32StringToByteArray(secretKey.ToUpper)
    
            Dim hmac As New HMACSHA1(secretKeyBytes)
            Dim stepbytes As Byte() = BitConverter.GetBytes(steps)
            If (BitConverter.IsLittleEndian) Then Array.Reverse(stepbytes)
            Dim hash As Byte() = hmac.ComputeHash(stepbytes)
            Dim offset As Integer = hash(hash.Length - 1) And 15
    
            Dim binary As Integer = ((hash(offset) And 127) << 24) Or ((hash(offset + 1) And 255) << 16) Or ((hash(offset + 2) And 255) << 8) Or (hash(offset + 3) And 255)
            Dim totp As Integer = binary Mod (10 ^ digits)
    
            Return totp.ToString().PadLeft(digits, "0"c)
        End Function
    
        Private Function Base32StringToByteArray(base32String As String) As Byte()
            Dim bitString As New StringBuilder()
    
            For Each character In base32String
                Dim index As Integer = Base32Alphabet.IndexOf(Char.ToUpper(character))
    
                If index < 0 Then
                    Throw New ArgumentException("Invalid character")
                End If
    
                bitString.Append(Convert.ToString(index, 2).PadLeft(5, "0"c))
            Next
    
            Dim byteList As New List(Of Byte)()
    
            For i As Integer = 0 To bitString.Length - 1 Step 8
                If i + 8 <= bitString.Length Then
                    byteList.Add(Convert.ToByte(bitString.ToString().Substring(i, 8), 2))
                End If
            Next
    
            Return byteList.ToArray()
        End Function
    
    End Class