Featured image of post 双因素认证(2FA)实现原理

双因素认证(2FA)实现原理

基于go语言的双因素认证实现

简介

什么是双因素认证?顾名思义,一般情况下一个账号登录只要输入账号密码即可,开启了双因素认证的应用,在用户输入密码后会要求再次输入一个验证码,这个过程可以理解为双因素认证,即第二次认证,可以有效保护账号的安全。即使用户的账号密码被盗,还是要求输入验证码,可以阻止不安全操作。 2FA的两个因素:

  1. 账号的一般密码
  2. 基于2FA程序生成的验证码,如谷歌的身份验证器

工作原理

认证过程如下图: Alt text 在用户登录时候,会要求输入验证码,这个验证码是在服务端会生产,安装了2FA程序的客户端也会生产,而且都是一样的。当输入了验证码和密码后,服务端会进行验证,只有两个验证因素都满足了才能登录。

OTP模式

OTP全称One Time Password,因此一般叫做一次密码,与常规密码不同的是,该密码一般只能使用一次,下次使用时作废。 OTP模式与服务端模式在验证码生成和验证方式上有很大的不同,主要表现在(OTP模式下):

  1. 服务端和客户端事先共享的OTP密钥。
  2. 客户端和服务端基于共享的秘钥会分别生成code。

OTP模式的主要流程如下:

登录流程图

01: 用户打开登陆页,登陆页要求用户输入验证码(一次密码)
02-04: 用户打开手机App(如Google Authenticator),该App会基于本地计数C或本地时间和共享密钥K,并使用密码技术HMAC算法计算得到验证码,并显示给用户
05-06: 用户输入用户名/密码/验证码进行登陆认证>
07: 服务端首先验证用户的用户名/密码是否正确。
08-10: 服务端使用与客户端相同的方式计算验证码,并与从客户端收到的进行比对。若不匹配,则失败;若匹配,则返回登陆成功

HOTP主要技术实现有:

https://datatracker.ietf.org/doc/html/rfc4226
HOTP算法基于递增的计数器值和仅令牌和验证服务已知的静态对称密钥。为了创建HOTP值,我们将使用RFC2104中定义的HMAC-SHA-1算法。 由于HMAC-SHA-1计算的输出为160位,我们必须将该值截断为用户可以轻松输入的值。

1
2
h = HMAC-SHA-1(K,C)
HOTP(K,C) = Truncate(h,digit)

从算法可以看出关键的三个参数K,C,digit。
K:表示共享密钥
C:表示计数器,RFC 中把它称为移动因子(moving factor)是一个 8 个 byte 的数值,而且需要服务器和客户端同步。
digit:表示要截取的code的位数,通常为6位。

HMAC-SHA-1截取步骤

第一步:使用 HMAC-SHA-1 算法基于 K 和 C 生成一个20个字节的十六进制字符串(HS)。

关于如何生成这个是另外一个协议来规定的,RFC 2104 HMAC Keyed-Hashing for Message Authentication. 实际上这里的算法并不唯一,还可以使用 HMAC-SHA-256 和 HMAC-SHA-512 生成更长的序列。

对应到协议中的算法标识就是:HS = HMAC-SHA-1(K,C)

第二步:选择这个20个字节的十六进制字符串(HS 下文使用 HS 代替 )的最后一个字节,取其低4位数并转化为十进制。

比如图中的例子,第二个字节是 5a,第四位就是 a,十六进制也就是 0xa,转化为十进制就是 10。

该数字我们定义为 Offset,也就是偏移量。

第三步:根据偏移量 Offset,我们从 HS 中的第 10(偏移量)个字节开始选取 4 个字节,作为我们生成 OTP 的基础数据。

图中例子就是选择 50ef7f19,十六进制表示就是 0x50ef7f19,我们成为 Sbits 以上两步在协议中的成为 Dynamic Truncation (DT)算法。

具体参考以下伪代码:

1
2
3
   Step 2: Generate a 4-byte string (Dynamic Truncation)
   Let Sbits = DT(HS)   //  DT, defined below,
                        //  returns a 31-bit string

第四步:将上一步4个字节的十六进制字符串 0x50ef7f19 转化为十进制,然后用该十进制数对 10 的 Digit 次幂 进行取模运算。 图中的例子,50ef7f19 转化为十进制为 1357872921,然后如果需要6位 OTP 验证码,则 1357872921 MOD 10^6 = 872921。

872921 就是我们最终生成的 OTP。 算法如下:

1
2
3
4
5
  Step 3: Compute an HOTP value
   Let Snum  = StToNum(Sbits)   // Convert S to a number in
                                    0...2^{31}-1
   Return D = Snum mod 10^Digit //  D is a number in the range
                                    0...10^{Digit}-1

这里的4个字节的十六进制字符串如何转为十进制? 以下代码示例描述了动态二进制代码的提取,前提是hmac_结果是具有hmac-SHA-1结果的字节数组: 算法如下:

1
2
3
4
5
        int offset =  hmac_result[19] & 0xf ;
        int bin_code = (hmac_result[offset]  & 0x7f) << 24
           | (hmac_result[offset+1] & 0xff) << 16
           | (hmac_result[offset+2] & 0xff) <<  8
           | (hmac_result[offset+3] & 0xff) ;

其中的offset 就是5a中的a转为十进制就是10,通过以上算法得到的bin_code就是1357872921,然后将这个数取余1357872921 MOD 10^digit= 872921。 872921就是最后得到的验证码。

TOTP 基于时间一次性密码

https://www.ietf.org/rfc/rfc6238.txt

TOTP:time-based one time password,基于时间的一次密码,该技术需要客户端和服务端共享加密密钥K和更新周期Period并进行时钟同步,然后通过如下算法计算一次密码(验证码):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  #  T表示当前时间的unix时间戳,T0表示初始时间(一般为0,可省略)
  # Period表示更新周期(一般为30秒)
  # C表示基于时间生成的计数
   C = (T - T0) / Period

  # K:加密密码,作为HMAC密码算法输入,由于只有客户端和服务端共享,因此在不知道K的情况下,无法生成,保证安全性。
  # C:计数器,客户端和服务端基于本地时间分别计算
  # h:表示使用密码技术得到的一次密码(但是由于h长度较大,不适合作为验证码,因此需要进一步截取
   h = HMAC(K, C)

  # otp:一次密码,通过对h进行截取处理(这里的digit代表需要截取的位数,通常情况下为6)
   otp = Trunc(h, digit)

流程图: Alt text TOTP也是基于HOTP的,只不过计数器C是根据unix时间戳和Period计算得到的。

go语言的TOTP的实现

接下来我们对go语言的2FA库进行分析 https://github.com/pquerna/otp

1. 如何产生secret

 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
// Generate creates a new HOTP Key.
func Generate(opts GenerateOpts) (*otp.Key, error) {
	// url encode the Issuer/AccountName
	if opts.Issuer == "" {
		return nil, otp.ErrGenerateMissingIssuer
	}

	if opts.AccountName == "" {
		return nil, otp.ErrGenerateMissingAccountName
	}

	if opts.SecretSize == 0 {
		opts.SecretSize = 10
	}

	if opts.Digits == 0 {
		opts.Digits = otp.DigitsSix
	}

	if opts.Rand == nil {
		opts.Rand = rand.Reader
	}

	// otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example

	v := url.Values{}
	if len(opts.Secret) != 0 {
		v.Set("secret", b32NoPadding.EncodeToString(opts.Secret))
	} else {
		secret := make([]byte, opts.SecretSize)
		_, err := opts.Rand.Read(secret)
		if err != nil {
			return nil, err
		}
		v.Set("secret", b32NoPadding.EncodeToString(secret))
	}

	v.Set("issuer", opts.Issuer)
	v.Set("algorithm", opts.Algorithm.String())
	v.Set("digits", opts.Digits.String())

	u := url.URL{
		Scheme:   "otpauth",
		Host:     "hotp",
		Path:     "/" + opts.Issuer + ":" + opts.AccountName,
		RawQuery: internal.EncodeQuery(v),
	}

	return otp.NewKeyFromURL(u.String())
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func main() {
	opts, err := hotp.Generate(hotp.GenerateOpts{
		Issuer:      "asdxet",
		AccountName: "feast",
		SecretSize:  0,
	})
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println(opts)
}

输出

1
otpauth://hotp/asdxet:feast?algorithm=SHA1&digits=6&issuer=asdxet&secret=7YKOTBHWHGSAZ37Y

通常这个函数产生的secret,可以用二维码库生产二维码给客户端扫描,客户端提取到这个secret后,就可以生产code。

2. TOTP如何生成code

 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
func GenerateCodeCustom(secret string, t time.Time, opts ValidateOpts) (passcode string, err error) {
	if opts.Period == 0 {
		opts.Period = 30
	}
  // 生成计数器counter
	counter := uint64(math.Floor(float64(t.Unix()) / float64(opts.Period)))
  // 调用hotp.GenerateCodeCustom()函数传入secret,计数器
	passcode, err = hotp.GenerateCodeCustom(secret, counter, hotp.ValidateOpts{
		Digits:    opts.Digits,
		Algorithm: opts.Algorithm,
	})
	if err != nil {
		return "", err
	}
	return passcode, nil
}
// 下面是htop的GenerateCodeCustom()函数

// 接受secret,counter和配置
func GenerateCodeCustom(secret string, counter uint64, opts ValidateOpts) (passcode string, err error) {
	//设置code的位数,默认是6位
	if opts.Digits == 0 {
		opts.Digits = otp.DigitsSix
	}
	// As noted in issue #10 and #17 this adds support for TOTP secrets that are
	// missing their padding.
  // 如果secret不是8的整数,用=补齐
	secret = strings.TrimSpace(secret)
	if n := len(secret) % 8; n != 0 {
		secret = secret + strings.Repeat("=", 8-n)
	}

	// As noted in issue #24 Google has started producing base32 in lower case,
	// but the StdEncoding (and the RFC), expect a dictionary of only upper case letters.
	secret = strings.ToUpper(secret)
  //将secret编码为base32的字节切片
	secretBytes, err := base32.StdEncoding.DecodeString(secret)
	if err != nil {
		return "", otp.ErrValidateSecretInvalidBase32
	}

	buf := make([]byte, 8)
  // 利用hamc函数生成sum
	mac := hmac.New(opts.Algorithm.Hash, secretBytes)
	binary.BigEndian.PutUint64(buf, counter)
	if debug {
		fmt.Printf("counter=%v\n", counter)
		fmt.Printf("buf=%v\n", buf)
	}

	mac.Write(buf)
     // 这里sum就是根据hmac-sha1生成的20字节数据切片,格式是
     // [88 34 93 234 103 118 184 221 224 36 35 171 23 5 35 93 130 251 225 119]
     // 用函数 	fmt.Println(hex.EncodeToString(sum))转成16进制字符串,格式是
     // 58225dea6776b8dde02423ab1705235d82fbe177
	sum := mac.Sum(nil)
	// "Dynamic truncation" in RFC 4226
	// http://tools.ietf.org/html/rfc4226#section-5.4
  //根据RFC4226规定得到偏移量为7,并截取4个字节数据 221 224 36 35进行如下算法运算
	offset := sum[len(sum)-1] & 0xf
	value := int64(((int(sum[offset]) & 0x7f) << 24) |
		((int(sum[offset+1] & 0xff)) << 16) |
		((int(sum[offset+2] & 0xff)) << 8) |
		(int(sum[offset+3]) & 0xff))

	l := opts.Digits.Length()
  // 得到value 为 1574970403

	mod := int32(value % int64(math.Pow10(l)))
// 将value进行取余得到mod为970403,即为生成的code
	if debug {
		fmt.Printf("offset=%v\n", offset)
		fmt.Printf("value=%v\n", value)
		fmt.Printf("mod'ed=%v\n", mod)
	}

	return opts.Digits.Format(mod), nil
}

参考:
https://www.jianshu.com/p/a7b900e8e50a https://www.cnblogs.com/informatics/p/17625253.html https://www.ietf.org/rfc/rfc4226.txt
https://www.ietf.org/rfc/rfc6238.txt