动态令牌的原理(OTP & HOTP &TOTP)

动态令牌的原理(OTP & HOTP &TOTP)

文章目录

  • 动态令牌的原理(OTP & HOTP &TOTP)
    • 前言
    • 2FA(Two-Factor Authentication)
    • 2FA实现方式1: OTP(One-Time Password)
    • 2FA实现方式2: HOTP(HMAC-based One-Time Password)基于计数器的验证码
    • 2FA实现方式3: TOTP(Time-based One-time Password)基于时间的验证码
    • 常见的开源实现
      • otpauth --- TOTP实现
      • rsc/2fa --- TOTP实现
    • Python实现
      • Python实现HOTP & TOTP
        • TOTP
        • HOTP
      • Python实现QR Code image

前言

也许你用过银行的密码器,以农商银行的网银电子密码器为例,它长这样:

动态令牌的原理(OTP & HOTP &TOTP)_第1张图片

可能你以为它是会联网的,毕竟你只要输入转账页面给的一串数字,然后它就会生成一串六位数的一次性口令,你把它输入到转账页面的验证码中,你的钱就转出去了。

但是,实际上它是不联网的,你将它放入断网的地方它照样能生成一次性口令,这是如何运作的?

2FA(Two-Factor Authentication)

  • 2FA,全称 Two-Factor Authentication,中文名叫 双重因素认证,便是里面的核心技术。字面意来理解,双重因素认证,即认证需要用到两重因素。比如,你在银行 ATM 取钱的时候既要你的银行卡,也需要只有你才知道的六位数口令,这便是双重因素认证。

  • 再以我们常见的手机验证码为例,它也是属于 2FA 的一种方式,比如,当你在进行敏感操作时(修改口令),首先你需要知道你的用户名,然后需要下发一条手机验证码到你的手机上,目的是为了验证你对手机的所有权,这也是属于双重因素认证的一种方式。

  • 中国大陆由于实名制的关系,几乎所有网站注册都需要使用手机号,而国内用户也没有使用手机令牌的习惯,因此,国内最常见的 2FA 就是手机验证码。

  • 但如果你注册国外的网站,比如 GitHub,你可以选择使用手机令牌,使用到的技术叫做 TOTP(Time-based One-time Password,中文名:基于时间的一次性口令),简单来说就是,当你将你的账户和验证软件绑定之后,在一定时间内,验证软件会生成一串数字(一般是六位数),你将这串数字输入到目标网站,即可完成认证,以谷歌身份验证器为例,它长这样:

2FA实现方式1: OTP(One-Time Password)

  • OTP(One-Time Password,中文名:一次性口令)。正如字面意,他是只能使用一次的口令。对于 OTP,没有特定的算法,但是要求必须是一次性、不可预测 ,一般为了用户输入方便,会使用四位、六位或八位数字。

  • 常见的应用场景还是手机验证码,不过还有一种使用场景也属于 OTP 范畴,那就是高考志愿修改口令卡。以湖南省为例,学校会给学生下发一张小卡片用于修改志愿表。上面有 20 条刮刮乐,每一条都对应一个一次性口令,当你将你填报志愿的账户与该卡片绑定(卡片上会有卡号)时,在你每次要进行志愿保存时,均需要提供一条口令,用一次即失效。本质上这也是 2FA,这次你需要提供的是你的志愿填报网站的账号密码一次性口令

动态令牌的原理(OTP & HOTP &TOTP)_第2张图片

2FA实现方式2: HOTP(HMAC-based One-Time Password)基于计数器的验证码

RFC4226

  • HOTP(HMAC-based One-Time Password,中文名:基于哈希消息认证码的一次性口令)。它也属于一次性口令,但是生成这个一次性口令,还另外需要提供一串密钥一个随机数,用于生成口令。

    • HOTP中,它的英文名中有一个词,叫做 HMAC(Hash-based Message Authentication Code,中文名:基于哈希的消息认证码),这个算法主要是用于验证消息的合法性,与常见的哈希算法的唯一区别是,在计算哈希摘要时,还需要额外提供一串密钥,俗称加盐(salt 或 nonce)。一言蔽之:使用一串只有你自己(或双方)才知道的密钥,可以生成一串独一无二的哈希值。
  • 在 HOTP 的应用中,这串密钥只有客户端和服务端双方才知道,被计算摘要的消息要求双方都能知道并保持相同,一般是一个自增计数器,比如:0, 1, 2, 3, 4。被计算出的一次性口令每使用一次,这个计数器就加一,由于密钥只有双方才知道,故双方都可以计算出一样的一次性口令,而第三方不知道这串密钥的,无法计算出一样的口令。

  • 另外这里有个坑,根据 RFC4226#section-5.1 规定,这个计数器必须为一个 8-byte 的整数,即 Int64,高位字节若不足应填充 0x00

  • RFC 规定哈希算法使用 HMAC-SHA1,假设密钥为:K='6shyg3uens2sh5slhey3dmh47skvgq5y',计数器当前值为:C=1,计算 HMAC 结果如下:

    // C 的十六进制表示如下:00 00 00 00 00 00 00 01,而不是 01,我被这里坑了好久
    // K 为 UTF8 编码的字符串
    HmacSHA1(C, K) // 结果为 b0 d4 8d 7f 4d 5d 39 49 ca 71 97 08 28 14 ec 6e e6 b5 14 a5
    
  • 由于最终结果需要用户手动输入到对应程序,但 SHA1 生成的为 20 字节,转换为十六进制字符串为长度 40 的英文 + 数字的字符串,不便于用户输入。因此,我们需要将这串摘要结果转换为便于用户输入的数据,也就是六位数数字

  • 转换算法为,取摘要结果最后一个字节的低 4 位,作为偏移值,然后以该偏移值为下标,从摘要中取从下标为该偏移值开始的 4 个字节,按大端模式组合成一个纯数字并忽略符号位,再取这个数字的后六位,高位长度不足 6 的应补上 0。

  • 看例子就知道了,以上面的例子为例,最后一个字节为 0xa5 ,转换为二进制为 1010 0101,他的低 4 位为 0101,再转回十进制为 5,因此,我们需要从摘要结果中第 6 个字节开始(下标为 5),取四个字节。

    b0 d4 8d 7f 4d 5d 39 49 ca 71 97 08 28 14 ec 6e e6 b5 14 a5
    ---------------***********---------------------------------
    

    这四个字节就是 5d 39 49 ca,按大端组成一个纯数字,并忽略符号位,结果就是:1564035530,取他的后 6 位数字,如果位数不足 6 则用 0 填充在前面,结果,验证码就是:035530

  • 如果密钥足够复杂且不可预测,那么该验证码就是不可预测的,为了防范暴力破解,应当在用户输错一次验证码后就立即增加计数器

  • 当然,HOTP 存在一个很影响用户体验的缺点:在离线状态下,应当如何让客户端和服务端同步计数器?由于离线状态下客户端和服务端无法通信,因此刷新计数器只能靠用户手动去点刷新,万一用户手残,亦或是觉得好玩,多点了几下,导致客户端和服务端的计数器不同步了,怎么办?

    • 解决方案一,让服务端一并计算计数器前后的验证码值(假设当前计数器为 5,一并计算 0~10 的验证码值),只要用户输入正确一个,视为验证成功。但是这也增加了被暴力破解的风险,而且万一用户的计数器还是超过太多,也会失效。
    • 解决方案二,向用户展示服务端当前计数器的值,然后用户手动同步客户端的计数器。但是,这也会产生麻烦和不安全,用户需要手动同步计数器,而且如果用户的计数器大于服务端计数器的值,就会看到未来某个时候的验证码值,可能会被其他人看到,产生不安全因素。计数器内部值最好不要展示出来。

2FA实现方式3: TOTP(Time-based One-time Password)基于时间的验证码

  • TOTP是基于时间的一次性密码(Time-based One-Time Password),他只是把上文 HOTP 中的计数器换成了时间戳除此之外没有任何区别。它是公认的可靠解决方案,已经写入国际标准RFC6238。

  • 但是这个时间戳,不能直接当做计数器的值,因为还需要留给用户足够的输入时间,一般是 30 秒。因此,真正计数器的值的计算方法如下:

    TC = floor((unixtime(now)unixtime(T0)) / TS)
    
    • TC 表示一个时间计数器
    • unixtime(now) 是当前 Unix 时间戳
    • unixtime(T0) 是约定的起始时间点的时间戳,默认是0,也就是1970年1月1日
    • TS 则是哈希有效期的时间长度,默认是30秒

    因此,上面的公式就变成下面的形式。

    TC = floor(unixtime(now) / 30)
    

    所以,只要在 30 秒以内,TC 的值都是一样的。前提是服务器和手机的时间必须同步。

    接下来,就可以算出哈希了。

    TOTP = HASH(SecretKey, TC)
    
    • HASH就是约定的哈希函数,默认是 SHA-1。
  • TOTP是非常棒的解决方案,是一种用于身份验证的技术。它通过在客户端和服务器之间共享的密钥和时间戳生成一个短期的密码,用于验证用户身份。TOTP通常用于增强网络安全,例如在网银、电子邮件、VPN等应用中。常见的TOTP实现包括Google Authenticator、Microsoft Authenticator等。

动态令牌的原理(OTP & HOTP &TOTP)_第3张图片

  • 你需要使用摄像头扫描网站给你提供的二维码,然后它会自动为你存储相关信息,然后就可以开始为你生成验证码了,这个验证码每 30 秒刷新一次,过时即失效。

  • 二维码内容如下:

    otpauth://totp/{label}?secret={secret}&issuer={issuer}
    
    • label 可以填写用户的名字
    • secret 就是上文中经过 Base32 编码后的密钥
    • issuer 代表应用名,比如 Google。

动态令牌的原理(OTP & HOTP &TOTP)_第4张图片

  • 注意:用户登录时,手机客户端使用这个密钥和当前时间戳,生成一个哈希,有效期默认为30秒。用户在有效期内,把这个哈希提交给服务器。服务器也使用密钥和当前时间戳,生成一个哈希,跟用户提交的哈希比对。只要两者不一致,就拒绝登录。

常见的开源实现

otpauth — TOTP实现

https://github.com/hectorm/otpauth

rsc/2fa — TOTP实现

https://github.com/rsc/2fa

默认的基于时间的验证码是从密钥和当前时间的哈希值推导出来的,因此系统时钟的精确度至少要达到一分钟。

钥匙串未经加密存储在文本文件 $HOME/.2fa 中。

$ go install rsc.io/2fa@latest
# 以给定的名称添加一个新密钥
$ 2fa -add github

# 列出钥匙串中所有钥匙的名称
$ 2fa -list
# 使用给定名称的密钥打印双因素身份验证代码,如果指定了 -clip 还会将代码复制到系统剪贴板
$ 2fa name

# 从所有已知的基于时间的密钥中打印双因素身份验证代码
$ 2fa

Python实现

Python实现HOTP & TOTP

https://github.com/pyauth/pyotp

$ pip install pyotp
TOTP
import pyotp
import time

totp = pyotp.TOTP('base32secret3232')
print(totp.now())  # 412379

# OTP verified for current time
print(totp.verify('412379'))  # True
time.sleep(30)
print(totp.verify('412379'))  # False
HOTP
import pyotp

hotp = pyotp.HOTP('base32secret3232')
print(hotp.at(0))  # 260182
print(hotp.at(1))  # 055283
print(hotp.at(1401))  # 316439

# OTP verified with a counter
print(hotp.verify('316439', 1401))  # True
print(hotp.verify('316439', 1402))  # False

Python实现QR Code image

$ pip install qrcode
import qrcode
import pyotp

# 生成一个6位数的随机密钥
key = pyotp.random_base32()

# 创建一个TOTP对象
totp = pyotp.TOTP(key)

# 生成一个QR码图片
img = qrcode.make(totp.provisioning_uri("[email protected]"))

# 将QR码保存为PNG图片文件
img.save("qr_code.png")
  • 这里需要注意一下,Base32 有两种编码规范,注意,这两种编码字符表完全不一样,2FA 规范要求使用前者,如果规格不一样将导致验证失败。

    • 一种是 26 个英文字母 + 数字 2~7:2FA要求使用这种,这就是为什么pyotp.random_base32()源码是:

      import random
      from typing import Sequence
      
      chars: Sequence[str] = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
      print("".join(random.choice(chars) for _ in range(32)))
      
    • 还有一种是数字 0~9 + 22 个英文字母

你可能感兴趣的:(Python,python,OTP,HOTP,TOTP,2FA,动态令牌,HMAC)