关于

对于一个需要身份认证的系统而言,账号和密码是必备的,而敏感系数比较高的应用,固定的账号和密码不足以保护其安全性。通常会采集多种信息来确认当前访问者的身份,即多因素认证(Multi-factor authentication,MFA),常见的多因素认证方式:

  • 短信验证码
  • 邮件验证码

上面介绍的几种方式在成本,可用性,便捷性上来说都表现不是很好,比如短信和邮件都需要收费,同时都必须保证网络在线。

Google现在也推荐用户启用两步验证(2 Factor Authentication)功能,并且除了以短信或者电话的方式发送一次性密码之外,还提供了另一种基于时间的一次性密码(Time-based One-time Password,简称TOTP),只需要在手机上安装密码生成应用程序,就可以生成一个随着时间变化的一次性密码,用于帐户验证,而且这个应用程序不需要连接网络即可工作。Google的TOTP方案其实是基于HOTP(HMAC-based One-Time Password),所以在介绍TOTP之前先了解一下HOTP的原理。

HOTP

HOTP是一种基于散列消息验证码(HMAC)生成一次性密码值的算法,HMAC利用哈希算法,以一个密钥和一个消 息为输入,生成一个消息摘要作为输出。

进行验证时,客户端对密钥和计数器的组合(K,C)使用HMAC(Hash-based Message Authentication Code)算法计算一次性密码,公式如下:

Text
1
HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))
  • K为双方协定的密钥,需要自行保存,不要泄漏
Text
1
otpauth://totp/op_app:AccountName?algorithm=SHA1&digits=6&issuer=op_app&period=30&secret=SecretKey
  • C为计数器,每次认证通过都会+1
  • 上面采用了HMAC-SHA-1,当然也可以使用HMAC-MD5等
  • HMAC算法得出的值位数比较多,不方便用户输入,因此需要截断(Truncate)成为一组不太长十进制数(例如6位)。

1

TOTP

表示基于时间戳算法的一次性密码。 即基于客户端的动态口令和动态口令验证服务器的时间进行比对,一般每30秒产生一个新口令,要求客户端和服务器能够十分精确的保持正确的时钟,客户端和服务端基于时间计算的动态口令才能一致。

Text
1
TOTP = HMAC-SHA-1(K, (T - T0) / X)
  • T0是约定的起始时间点的时间戳,默认是0,也就是1970年1月1日 00:00:00。T 是当前时间,X为时间步长,通常为30s。所以(T - T0) / X指的是当前到1970年1月1日 00:00:00间隔多少个30s
  • K为双方协定的密钥
  • HMAC-SHA-1是约定的哈希函数

2

验证码计算

RFC4226标准: https://datatracker.ietf.org/doc/html/rfc4226#section-5.4

Text
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
func GenerateCode(secret string, counter uint64, opts ValidateOpts) (passcode string, err error) {
// As noted in issue #10 and #17 this adds support for TOTP secrets that are
// missing their padding.
secret = strings.TrimSpace(secret)

// but the StdEncoding (and the RFC), expect a dictionary of only upper case letters.
secret = strings.ToUpper(secret)

secretBytes, err := base32.StdEncoding.DecodeString(secret)
if err != nil {
return "", err
}

buf := make([]byte, 8)
mac := hmac.New(opts.Algorithm.Hash, secretBytes)
binary.BigEndian.PutUint64(buf, counter)

mac.Write(buf)
sum := mac.Sum(nil)

// HMAC-SHA-1加密后的长度得到一个20字节的密串;
// 取这个20字节的密串的最后一个字节,取这字节的低4位,作为截取加密串的下标偏移量;
// 按照下标偏移量开始,获取4个字节,按照大端方式组成一个整数;
// 截取这个整数的后6位或者8位转成字符串返回。
// "Dynamic truncation" in RFC 4226
// http://tools.ietf.org/html/rfc4226#section-5.4
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()
mod := int32(value % int64(math.Pow10(l)))

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

容错机制

  • 由于网络延时,用户输入延迟等因素,可能当服务器端接收到一次性密码时,T的数值已经改变,这样就会导致服务器计算的一次性密码值与用户输入的不同,验证失败。解决这个问题个一个方法是,服务器计算当前时间片以及前面的n个时间片内的一次性密码值,只要其中有一个与用户输入的密码相同,则验证通过。当然,n不能太大,否则会降低安全性。
Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
counters := []uint64{}
counter := int64(math.Floor(float64(t.Unix()) / float64(opts.Period)))

counters = append(counters, uint64(counter))
for i := 1; i <= int(opts.Skew); i++ {
counters = append(counters, uint64(counter+int64(i)))
counters = append(counters, uint64(counter-int64(i)))
}

for _, counter := range counters {
rv, err := hotp.ValidateCustom(passcode, counter, secret, hotp.ValidateOpts{
Digits: opts.Digits,
Algorithm: opts.Algorithm,
})

if err != nil {
return false, err
}

if rv == true {
return true, nil
}
}