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?
A conversation with ChatGPT finally got me the answer. It was a mixture of several mistakes:
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