You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于RFC 6238的VB.NET 2FA实现与主流APP不兼容问题排查

VB.NET实现RFC 6238 TOTP与FreeOTP不兼容的问题及解决方案

问题背景

用VB.NET实现了符合RFC 6238标准的TOTP应用,可复现文档附录测试结果,但与FreeOTP等移动端2FA APP测试时结果不一致。尝试ChatGPT协助未解决,网上线索指向Base32编码相关的初始文本编码问题,但对具体细节存疑。

Base32定义疑问

FreeOTP的iOS和Android版本密钥输入框均标注Base32,但iOS端无法输入0、1、8、9,Android端则允许。需明确:

  • 此场景下Base32的字符集规范是什么?
  • 密钥是否只能包含[A-Z2-7],还是算法可处理任意字母数字输入?

现有VB.NET实现代码

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

RFC 6238官方代码片段(Java)

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();   

    // 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 =                                
            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);          

    // 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;
}

异常现象

使用测试密钥"12345678901234567890"时,VB.NET代码可得到RFC文档结果,但FreeOTP返回不同结果(已同步手机时间),且iOS端无法输入该密钥。所有测试的2FA APP结果一致且遵循RFC 6238,但VB.NET实现无法匹配。


问题解答

1. 核心错误:密钥编码方式错误

你当前直接将密钥字符串通过Encoding.UTF8.GetBytes转换为字节数组,这是问题根源:

  • RFC 6238的测试用例使用十六进制字符串作为密钥,代码中hexStr2Bytes是将十六进制字符串解码为原始字节;
  • 真实2FA场景(如FreeOTP)的密钥是Base32编码字符串,必须先解码为原始字节数组,再作为HMAC算法的密钥。

2. Base32规范与两端差异

  • 标准Base32(RFC 4648)的字符集为[A-Z2-7],排除0、1、8、9是因为这些字符易与O、I、B、G混淆,避免用户输入错误;
  • iOS端FreeOTP严格遵循标准,禁止输入非法字符;Android端做了兼容处理,会自动将0→O、1→I、8→B、9→G(或忽略非法字符),本质还是按标准Base32解码。
  • 算法只处理Base32解码后的原始字节,不直接支持任意字母数字输入。

3. SecretKeySpec的作用

Java中的SecretKeySpec仅将原始字节数组包装为符合Java加密API规范的SecretKey对象,作用和VB.NET中直接传入HMACSHA1(secretKeyBytes)完全一致——只是传递原始密钥字节给HMAC算法,无额外编码或转换逻辑。

4. 代码修正方案

必须添加Base32解码逻辑,替换原有的UTF-8编码步骤,同时移除多余的密钥字节大小端反转代码:

' 实现标准RFC4648 Base32解码(可使用成熟的VB.NET Base32库,或自行实现)
Public Function Base32Decode(input As String) As Byte()
    ' 示例逻辑框架:
    ' 1. 统一转换为大写,移除空格等格式符
    input = input.ToUpper().Replace(" ", "")
    ' 2. 按Base32规则将字符映射为5位二进制
    ' 3. 拼接二进制位并分组为8位字节
    ' 4. 返回解码后的字节数组
    ' 注意处理填充字符"="
End Function

' 修正后的GenerateTOTP方法
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
    
    ' 关键修正:Base32解码密钥字符串为原始字节
    Dim secretKeyBytes As Byte() = Base32Decode(secretKey)
    
    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 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 result As String = totp.ToString().PadLeft(digits, "0"c)

    ' 调试输出(可选)
    Console.WriteLine("-------------------------------------------------------")
    Console.WriteLine("Key (Base32):          " & secretKey)
    Console.WriteLine("Key (Decoded Bytes):   " & BitConverter.ToString(secretKeyBytes))
    Console.WriteLine("Date:                  " & dt.ToString("dd.MM.yyyy HH:mm:ss"))
    Console.WriteLine("unixEpoch:             " & unixEpoch)
    Console.WriteLine("steps:                 " & steps)
    Console.WriteLine("TOTP:                  " & result)

    Return result
End Function

5. 测试用例差异的原因

RFC 6238的测试密钥是十六进制字符串,你之前禁用密钥字节反转后,UTF-8编码的字节刚好巧合匹配了十六进制解码的结果,但真实场景是Base32解码,所以必须替换编码逻辑。


内容的提问来源于stack exchange,提问作者Stefan L.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 20:00:56