基于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.
相关产品推荐
相关产品推荐

