两步验证器是如何工作的
当今一个互联网账号,几乎包含了你所有的信息。一位 Google 的重度用户,会上传他的照片、文件、通讯录、Docs、密码、浏览记录、搜索记录等,一个账号行走天下。虽然很方便,但随之而来的安全隐患就必须引起重视了。我们已经厌烦了各种照片泄漏事件了,对吧?
记住,密码总是不安全的,为了牢牢地保护好账号,开启两步验证(2FA)是一个好主意。两步验证开启之后,在输入正确的账号和密码之后,需要一步额外的信息验证,比如短信验证码、邮件验证码。
可每次打开手机、邮箱查看验证码多麻烦啊,短信有延迟、邮件会进垃圾邮件,再说也不是很安全。账号服务商为了方便用户登陆,会推出相应的两步验证器,比如 Google Authenticator 和 Microsoft Authenticator 等,相信不少用户都听过或用过。两步验证器会提供给你一个神秘代码,一般是6位或者8位数字,有效期一般为30s,可以用它作为额外的安全信息。
有趣的是,一个两步验证器,可以支持多个不同的账号。比如我的验证器里有 Microsoft, Google, Github 和 Firefox 的账号。在登陆的时候,在网页中填入相应的神秘代码就好。(注意下图中的代码和上图中并不一致,因为是在不同时间截的图啦。)
更神奇的是,两步验证器即使在离线(断网)的情况下,也能正常工作。这就很有意思啦,好奇的同学一定会思考,“6位或8位、30s、多账号通用、断网也能用”,这冥冥之中一定意味着什么。宇宙的奥秘?人生的意义?还是那通往一切的终极答案?别瞎想啦,少年,今天我就带你揭开两步验证器的神秘面纱。
猜想
让我们做一些猜想:
- 断网也能用,说明验证器客户端和认证服务器之间没有数据传输。没有通信的情况下,验证器产生的数字,可以通过服务器的认证,说明服务器也产生了一样的数字
- 有效期一般为30s,说明产生数字这个计算的一个变量是当前时间,每30s为一个窗口。
- 在同一个时间窗口下,不同的账号产生了不同的数字,说明这个计算还有一个变量是和账号有关的。这个账号相关的变量是什么还不确定,我们通过设置 Github 两步验证来研究一下。
使用验证器扫描 Github 服务器生成的二维码(这里做了马赛克处理),验证器立马就能产生一个6位数字,然后在下方输入这个数字,验证一下,和服务器建立同步。看来关键是,二维码中的数据是什么?我提取之后,发现它长下面这样
# easy-pieces 是我为这篇文章注册的 Github 小号的用户名
# secret 是 Github 生成的 secret,出于安全考虑,这里用了伪 secret
otpauth://totp/GitHub:easy-pieces?secret=iamapseudosecret&issuer=GitHub
可以发现关键就是用户名和 secret,由于 secret 已经能唯一代表一个账号,但用户名不一定能(不同账号可以有同样的用户名)唯一代表一个账号,所以 secret 就是生成6位数字所依赖的变量。
生成这个数字的公式可以表示为:
number = f(current_datetime, secret)
总结一下:
- 验证器客户端和服务器首先通过二维码,协商出一个 secret,然后各自保存
- 在用户登录的时候,打开验证器,验证器会根据当前时间窗口以及之前协商好的 secret,算出一个数字
- 用户手动输入这个数字,传输到服务器
- 服务器使用同样的函数,算出一个数字,判断与用户输入的数字是否匹配
这就是两步验证器的基本原理。这里提一个问题供大家思考:
* 当使用验证器扫描二维码的时候,验证器有没有向服务器通信?如果没有,服务器如何确认客户端已经扫了二维码,从而开始同步计算?
接下来我会介绍一下背后的 RFC 标准,涉及函数 f 的定义、时间窗口如何计算、安全问题等、如果不感兴趣,可以跳过。
标准
无论什么两步验证器,都有以上特性,那背后就很可能有一个标准来定义两步验证器怎么实现。这个标准就是 RFC 6238:Time-Based One-Time Password(TOTP) Algorithm
字面上的意思很直白,基于时间的一次性密码生成算法。TOTP 基于 RFC4226:HMAC-based One-Time Password (HOTP) algorithm
# S 是 secret,T 是时间窗口
TOTP(S,T) = Truncate(HMAC(S,T))
时间窗口
考虑到网络和人的延迟(从你看到验证器上的数字,到认证服务器接收到你输入的数字之间的延迟),T 不能特别精确,模糊处理一下,一般每30s为一个窗口。
T = math.floor(current_unix_timestamp / 30)
比如当前 unix 时间戳是29,那么 T=0; 如果时间戳是31, 那么 T=1. 注意这个时间戳的类型要是 Int64,不然2038年的时候就不够用了😂
考虑到用户可能在 unix timestamp 29 的时候,输入了这个数字,但不幸网络不好,到达认证服务器的时候,已经是 unix timestamp 31 了,这时候服务器算出来的数字就会和客户端不同,为了处理这种情况,RFC 标准里推荐当数字不匹配时,服务器可以多算前一个窗口的数字,但出于安全考虑,最好只多算一个窗口。
HMAC
HMAC 函数同时提供数据完整性校验和消息认证。这里的 MAC 不是 Macintosh,更不是口红 MAC,而是 message authentication code,消息认证代码,我们之前说的那个数字就是一个消息认证代码。H 代表这个代码是通过 hash 生成的,TOTP 中,HMAC 的哈希函数默认使用 SHA1. HMAC 基于 secret 和 T,通过两阶段运算,算出一个 20-byte(SHA1) 的字符串,具体运算过程参考 HMAC
消息认证和消息签名有点细微的差别,后者一般用公私密钥对加密,前者用一个 key 加密
Truncate
Truncate 函数要把 HMAC 生成的 20-byte 字符串转化成一个6位的数字。
- 首先,通过一个算法把它转成 4-byte 的字符串
- 将这个 4-byte 的字符串转成数字,然后对 10^6 取模得到一个6位数字
具体的算法参考 RFC4226#Generating an HOTP Value,确保生成均匀分布的数字。
更改参数
有人可能会说,我不想用6位数字,我要8位;我希望窗口可以长一点,60s 吧;SHA1 有风险,我要用 SHA256。这些可以通过修改 totp 的 uri 的参数来达成。
比如 Github 的就可以这样改
otpauth://totp/GitHub:easy-pieces?digits=8&period=60&algorithm=SHA256&secret=iamapseudosecret&issuer=GitHub
最后有一点需要提醒一下,这个 uri 二维码绝对不能分享。经过实际测试验证,一个二维码可以被多次扫描,也没有过期时间。因为在服务器端并不知道二维码被扫描的事件。
总结
由现象来猜想可能的实现,最后查阅标准来验证猜想,是一个很有趣的事情。但其实我是看了标准后,才有之前的猜想的。主要是为了写作考虑,莫怪。