Authy的2FA认证过程解析

AI 摘要: 双因子认证是一种安全机制,通过要求用户提供两种不同的身份验证因素来增强账户的安全性。文章介绍了2FA的背景及Authy在2FA中的实现,以及TOTP认证过程的生成和验证流程。同时解释了Authy 2FA的工作流程分解,包括设置绑定阶段和登录验证阶段,以及时序图和其他补充内容。

1. 双因子认证

1.1. 2FA 使用背景

2FA(Two-Factor Authentication)双因素认证是一种安全机制,旨在通过要求用户提供两种不同的身份验证因素来增强账户的安全性。

最近在Godday上面选购域名,手机无法接收 sms 短信,选择了使用第三方的 2FA Authy软件绑定 2FA 认证,之前在腾讯课堂系统重构过帐号体系,这里也简单盘了 Authy2FA 上是如何实现的。

1.2. TOTP 认证过程

TOTP(Time-based One-Time Password),基于时间的一次性密码是一种用于身份验证的安全机制,广泛应用于两步验证(2FA)和多因素认证(MFA)中。

生成和验证 TOTP 过程

服务提供商和你的 Authy 应用共享同一个秘密密钥,Authy 应用使用这个秘密密钥和当前的时间来算出一个动态密码,服务提供商也使用同一个秘密密钥和自己的服务器时间来验证你输入的动态密码是否正确。

  1. 共享密钥:用户和认证服务器之间共享一个秘密密钥(通常以 Base32 编码存储)。
  2. 时间戳:TOTP 使用当前的时间戳,通常以 30 秒为单位进行分段(即每 30 秒生成一个新的密码)。
  3. 哈希算法:TOTP 使用 HMAC-SHA1 等哈希算法,将共享密钥和时间戳作为输入,生成一个 6 位或 8 位的动态密码。
  4. 验证:用户在登录时输入生成的动态密码,服务器使用相同的算法和共享密钥计算出当前时间段的动态密码进行比对,从而验证用户身份。

2. Authy 2FA 工作流程分解

authy-2fa工作流程步骤

2.1. 步骤说明

设置绑定阶段 (Setup)

  • 服务生成一个秘密密钥,并将密钥安全存储在你的账户记录中
  • 服务将密钥显示给你(通常通过二维码或一串字符)
  • 你在 Authy 应用中添加账户,扫描二维码或输入密钥
  • Authy 应用将密钥安全存储

登录验证阶段 (Verification)

  • 用户操作:
    • 打开应用,查询对应服务的 TOTP 动态密码
  • Authy 应用端生成 TOTP 动态密码:
    • 获取设备的当前时间,计算当前的时间步长值(例如:floor(current_unix_timestamp / 30)
    • 使用存储的秘密密钥和计算出的时间步长值,运行标准的 TOTP 算法。
    • 将算法输出的哈希值截断并转换为 6 位或 8 位数字,在屏幕上显示这个动态密码。
  • 用户操作:
    • 从 Authy 应用中读取当前显示的动态密码
    • 你将动态密码输入到服务登录页面的 2FA 字段中
  • 服务提供商服务器端,校验 TOTP 密码:
    • 收到你输入的动态密码后,开始计算 TOTP 预期时间步长密码。
      • 获取服务器的当前时间。
      • 计算当前的时间步长值(例如:floor(current_unix_timestamp / 30))。
      • 使用存储的与你账户关联的秘密密钥和计算出的时间步长值,运行  相同的  标准 TOTP 算法。
      • 将算法输出的哈希值截断并转换为 6 位或 8 位数字。这就是服务器预期的当前动态密码。
      • (为了应对时间漂移)  服务器通常还会计算前一个和后一个时间步长的预期密码。
    • 将用户输入的步长密码和服务端自己计算出的预期密码(包括当前、前一个和后一个时间步长的)进行比对后响应验证成功\失败给用户

2.2. 时序图

alt text

Go 语言实现

TOTP 生成 6 位数字函数

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package totp

import (
 "crypto/hmac"
 "crypto/sha1" // SHA-1 is the default algorithm for compatibility
 "encoding/binary"
 "fmt"
 "hash"
 "math"
 "time"
 // You can uncomment these if you need SHA-256 or SHA-512 support
 // "crypto/sha256"
 // "crypto/sha512"
)

// Algorithm represents the hash algorithm constructor function.
type Algorithm func() hash.Hash

var (
 AlgorithmSHA Algorithm = sha1.New
 // AlgorithmSHA256 Algorithm = sha256.New // Uncomment if needed
 // AlgorithmSHA512 Algorithm = sha512.New // Uncomment if needed
)

// Constants for common TOTP parameters
var (
 DefaultStep      = 30 // seconds
 DefaultDigits    = 6
 DefaultAlgorithm = AlgorithmSHA
)

// GenerateTOTP generates a Time-based One-Time Password.
// It implements the algorithm defined in RFC 6238.
//
// secret: The shared secret key as a byte slice. This is the key shared
//
// between the server and the client (authenticator app).
//
// t: The time for which to generate the code. Usually time.Now().
// step: The time step in seconds (e.g., 30 seconds is common).
// digits: The number of digits in the output code (e.g., 6 or 8).
// algorithm: The hash algorithm constructor function (e.g., sha1.New).
//
// Returns the TOTP code as a string (padded with leading zeros) and an error.
func GenerateTOTP(secret []byte, t time.Time, step int, digits int, algorithm Algorithm) (string, error) {
 if len(secret) == 0 {
  return "", fmt.Errorf("secret cannot be empty")
 }
 if step <= 0 {
  return "", fmt.Errorf("step must be positive")
 }
 if digits <= 0 || digits > 9 { // Practical limit for digits in common use
  return "", fmt.Errorf("digits must be between 1 and 9")
 }
 if algorithm == nil {
  return "", fmt.Errorf("algorithm cannot be nil")
 }

 // Calculate the time-based counter T
 // T = floor((Current Unix time - T0) / Time Step)
 // T0 is the Unix epoch (Jan 1, 1970 00:00:00 UTC)
 // For simplicity and common use, T0 is usually 0, so T = floor(Current Unix time / Time Step)
 counter := uint64(t.Unix() / int64(step))

 // Generate the HOTP code using the calculated counter
 code, err := generateHOTP(secret, counter, digits, algorithm)
 if err != nil {
  return "", fmt.Errorf("failed to generate HOTP: %w", err)
 }

 // Format the code with leading zeros to the specified number of digits
 format := fmt.Sprintf("%%0%dd", digits)
 return fmt.Sprintf(format, code), nil
}

// generateHOTP generates an HMAC-based One-Time Password.
// It implements the algorithm defined in RFC 4226.
//
// secret: The shared secret key.
// counter: The counter value (a monotonically increasing value).
// digits: The number of digits in the output code.
// algorithm: The hash algorithm constructor.
//
// Returns the HOTP code as an integer and an error.
func generateHOTP(secret []byte, counter uint64, digits int, algorithm Algorithm) (int, error) {
 // Convert the counter (uint64) to an 8-byte slice in big-endian order
 counterBytes := make([]byte, 8)
 binary.BigEndian.PutUint64(counterBytes, counter)

 // Create a new HMAC hash instance with the specified algorithm and secret key
 h := hmac.New(algorithm, secret)

 // Write the counter bytes to the HMAC hash
 h.Write(counterBytes)

 // Compute the HMAC sum
 hmacResult := h.Sum(nil)

 // Perform Dynamic Truncation (RFC 4226, Section 5.3)
 // 1. Take the last 4 bits of the HMAC result as an offset.
 offset := int(hmacResult[len(hmacResult)-1] & 0x0F)

 // 2. Extract 4 bytes from the HMAC result starting from the offset.
 //    These 4 bytes are treated as a 32-bit integer.
 truncatedHash := hmacResult[offset : offset+4]

 // 3. Convert the 4 bytes to a 32-bit integer in big-endian order.
 //    Ignore the most significant bit (MSB) to avoid signed integer issues.
 otpValue := binary.BigEndian.Uint32(truncatedHash) & 0x7FFFFFFF

 // Calculate the final code by taking the integer modulo 10^digits
 // This ensures the code has the desired number of digits.
 mod := int(math.Pow10(digits))
 code := int(otpValue) % mod

 return code, nil
}

// GenerateTOTP6DigitsSHA1 is a convenience function to generate a 6-digit
// TOTP using the default SHA-1 algorithm and a 30-second time step.
// This is the most common configuration compatible with apps like Google Authenticator.
//
// secret: The shared secret key as a byte slice.
// t: The time for which to generate the code.
//
// Returns the 6-digit TOTP code as a string and an error.
func GenerateTOTP6DigitsSHA1(secret []byte, t time.Time) (string, error) {
 return GenerateTOTP(secret, t, DefaultStep, DefaultDigits, DefaultAlgorithm)
}

UT 测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

func TestGenerateTOTP6DigitsSHA1(t *testing.T) {
 base32Secret := "JBSWY3DPEHPK3PXP" // Example from RFC 6238 Appendix A

 // Decode the base32 secret into bytes
 secretBytes, err := base32.StdEncoding.DecodeString(base32Secret)
 if err != nil {
  t.Log("Error decoding secret:", err)
  return
 }

 // --- Using the convenience function for 6-digit SHA-1 (most common) ---
 t.Log("--- 6-Digit SHA-1 TOTP (Default) ---")
 currentTime := time.Now()
 code6, err := GenerateTOTP6DigitsSHA1(secretBytes, currentTime)
 if err != nil {
  t.Log("Error generating TOTP:", err)
  return
 }
 t.Logf("Current time: %s\n", currentTime.Format(time.RFC3339))
 t.Logf("Generated 6-digit code: %s\n", code6)
}

3. 其他补充

3.1. 时间同步的重要性

虽然服务通常会允许一个小的验证窗口(检查前后几个时间步长的密码),但设备和服务器之间的时间同步仍然很重要。如果你的设备时间与服务器时间相差太大(超过几个时间步长),Authy 生成的密码就会与服务器预期的密码不匹配,导致验证失败。这就是为什么有时需要确保手机时间是自动同步的。

3.2. 安全问题

  1. 直接对 TOTP 动态密码进行暴力破解,试图在短时间内猜中正确的 6 位或 8 位数字,在技术上是不可行的,因为时间窗口太短,密码组合数量庞大,而且服务端的速率限制会阻止任何大规模的猜测尝试。
  2. 服务端的速率限制 (Rate Limiting):这是最关键的防御措施,例如,服务可能只允许你在短时间内(比如 5 分钟)尝试 3-5 次,如果超过这个次数账户被暂时锁定,或者需要等待更长时间才能再次尝试。 有了速率限制,攻击者根本没有机会在密码失效前尝试足够多的组合。他们会在尝试几次错误密码后就被锁定。
  3. Authy 存储的秘密密钥是生成 TOTP 动态密码的关键,如果客户端的密钥 SecretKey 被恶意获取,攻击者就具备了生成第二个认证因子的能力,结合他们可能已经获得的第一个因子(用户名和密码),他们就可以绕过 2FA 保护,成功登录你的账户

最后,可以我们了解 Authy 软件工作原理后,可以看到实际上开发一个 2FA 的软件也不难,不外乎存储和服务端一致的密钥,不过如何保存好保险箱以及开锁的钥匙,又是另一个安全话题了~