目标
我们接下来要实现一下 TouchGal 网站的 2FA 功能,所以需要一点 HOTP 和 TOTP 的基础,所以下面来详细解释一下这两个东东。
只是解释有一点过于苍白,我们还会结合 https://github.com/PlanetHoster/time2fa 这个库来实战解释一下这两个东东是怎么在实际应用中实现的。
首先需要明确,HOTP 和 TOTP 这两个东东都属于一次性密码 (OTP,即 One-Time Password) 算法。它们都是开放标准,广泛用于双因素认证 (2FA) 或多因素认证 (MFA)。
OTP 的核心思想是生成一个仅在短时间内或仅使用一次后就失效的密码。这大大增强了安全性,因为即使密码被截获,攻击者也无法在稍后使用它。
HOTP 和 TOTP 都是基于对称加密 HMAC (Hash-based Message Authentication Code) 的,用户的认证器(Authenticator)和服务器(Server)都需要一个共同的密钥来按照某种 Hash 算法(例如 SHA1,RFC 4226 推荐 SHA1,但现在更推荐使用 SHA256 或 SHA512)计算出一个 Hash 然后比较两者是否相同,以此来认证用户。
对称加密这里就不展开了,这不是本文的目的。
HOTP (HMAC-based One-Time Password)
HOTP 是基于事件(计数器)的 OTP 算法。核心原理是每次请求或使用密码时,一个叫计数器的东东会递增,从而生成一个新的密码。
下面的基础知识部分感谢我们热心的鸡米妮 (啊,这其实是 Gemini) 同学进行提供
基础知识
-
共享密钥 (Shared Secret Key, K):
- 这是服务器和客户端(例如,用户的手机认证应用)之间共享的一个秘密字符串。
- 这个密钥在初始设置时生成,并且必须保密。通常是160位 (20字节) 或更长。
- 生成方式:通常是随机生成的。
- 分发方式:通过安全通道(例如,扫描二维码,手动输入)从服务器传递给客户端。
-
计数器 (Counter, C):
- 一个单调递增的整数值(通常是64位)。
- 客户端和服务器都必须维护和同步这个计数器。
- 每次生成 OTP 时,客户端会使用当前的计数器值。
- 服务器在验证 OTP 后,会更新其存储的计数器值,以匹配客户端下次将使用的计数器。
-
HMAC 函数:
- HOTP 使用 HMAC 算法(例如 HMAC-SHA1, HMAC-SHA256, HMAC-SHA512)。
- HMAC 函数以共享密钥
K
和计数器C
作为输入,生成一个哈希值。HS = HMAC(K, C)
(HS 是一个较长的十六进制字符串)
-
动态截断 (Dynamic Truncation):
-
HMAC 的输出通常是一个较长的十六进制字符串(例如,SHA1 输出20字节)。
-
因此,需要一个截断函数将这个长字符串转换为一个较短的、用户友好的数字码(通常是6位或8位)。
-
截断步骤: a. 取
HS
的最后4位 (last nibble/half-byte)。这个值(0-15)作为偏移量 (offset)。 b. 从HS
的offset
字节开始,取4个字节。 c. 将这4个字节视为一个32位的大端整数。 d. 清除这个32位整数的最高位(将其与0x7FFFFFFF
进行按位与操作),确保结果为正数。 e. 将结果对10^D
取模 (D 是期望的数字位数,例如10^6
表示6位数字)。 f. 如果结果位数不足D位,则在前面补零。
-
一个简单的示例
鸡米妮同学讲动态截断有点抽象,我们举个栗子吧。
我们假设 HMAC-SHA1(K, C)
产生 1f8698690e02ca16618550ef7f19da8e945b555a
(20字节),这个码给用户让用户自己输的话会被产品打死的,用户无法方便地输入这么长的字符串。
所以我们需要从这一长串字母中拿出一部分来,方便用户输入的同时也能进行认证。所以我们可以按照下面的步骤进行截断
-
最后半字节是
0xa
(十进制 10)。所以offset = 10
。 -
从第10个字节开始取4字节 (索引从0开始):
HS[10]...HS[13]
=>50ef7f19
(十六进制) -
转换为32位整数:
0x50ef7f19
=>1357872921
(十进制) -
清除最高位 (已经是正数,此步无变化)
-
假设需要6位数字:
1357872921 % 1000000
=>872921
-
OTP =
872921
使用 TypeScript 来实现上面的步骤大概是这样的
const generatePasscode = (options: HotpCode, config: ValidTotpConfig) => {
const secretBytes = Buffer.from(Decode32(options.secret));
if (secretBytes.length !== config.secretSize) {
throw new Error('INVALID_SECRET_ERR');
}
const buf = Buffer.alloc(8);
buf.writeUInt32BE(options.counter, 4);
const hmac = crypto.createHmac(config.algo, secretBytes);
hmac.update(buf);
const hmacResult = hmac.digest();
// https://www.rfc-editor.org/rfc/rfc4226#section-5.4
const offset = hmacResult[hmacResult.length - 1] & 0xf;
const value =
((hmacResult[offset] & 0x7f) << 24) |
((hmacResult[offset + 1] & 0xff) << 16) |
((hmacResult[offset + 2] & 0xff) << 8) |
(hmacResult[offset + 3] & 0xff);
const mod = value % Math.pow(10, config.digits);
return mod.toString().padStart(config.digits, "0");
}
这个东东跑出来的结果就是一个 6 位的数字,这次应该熟悉了吧,很多地方的 2FA 都是输入 6 位数字进行 2FA (比如我们的大善人 Cloudflare),这个数字就是这么生成的。
上面的 RFC 4426 示意图可以看一下
SHA-1 HMAC Bytes (Example)
-------------------------------------------------------------
| Byte Number |
-------------------------------------------------------------
|00|01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|
-------------------------------------------------------------
| Byte Value |
-------------------------------------------------------------
|1f|86|98|69|0e|02|ca|16|61|85|50|ef|7f|19|da|8e|94|5b|55|5a|
-------------------------------***********----------------++|
HOTP 工作流程
HOTP 很不靠谱,HOTP 的核心是基于一个计数器的(上面鸡米妮同学提到了),客户端和服务器都必须维护和同步这个计数器,如果用户在客户端生成了 OTP 但未使用,客户端计数器会增加,而服务器计数器不变,导致失步。
服务器通常会实现一个“前瞻窗口”(look-ahead window),检查未来几个计数器的 OTP,以缓解这个问题。但如果失步太大,就需要重新同步。
因此我们接下来实现 2FA 的时候肯定是用 TOTP,下面的工作流程由鸡米妮同学提供,仅供理论上的参考。
-
设置阶段:
- 服务器生成一个唯一的共享密钥
K
和初始计数器值C
(通常为0或1)。 - 服务器将
K
和C
安全地提供给用户的认证设备/应用。
- 服务器生成一个唯一的共享密钥
-
OTP 生成 (客户端):
- 用户请求 OTP。
- 客户端使用其存储的
K
和当前的C
,通过 HMAC 和截断函数生成 OTP。 - 客户端显示 OTP,并将内部计数器
C
增1。
-
OTP 验证 (服务器):
- 用户将客户端生成的 OTP 输入到服务器。
- 服务器使用其存储的
K
和期望的计数器值C
(以及可能的一个小窗口内的后续计数值,以处理同步问题) 来生成一个或多个 OTP。 - 服务器将用户提交的 OTP 与自己生成的 OTP(s) 进行比较。
- 如果匹配:
- 认证成功。
- 服务器将其存储的计数器
C
更新为匹配的那个计数器值(确保与客户端同步)。
- 如果不匹配:认证失败。
TOTP (Time-based One-Time Password)
上面我们着重提到了 HOTP 的失步问题,而 TOTP 就是用来解决这个问题的。
TOTP 是 HOTP 的一个改进,它使用时间而不是计数器作为动态因素(Moving Factor)。这是目前最广泛使用的 OTP 算法(例如 Google Authenticator, Microsoft Authenticator 等应用都支持 TOTP)。
TOTP 基本上是 HOTP 的一个特例,它将 HOTP 中的计数器 C
替换为一个基于当前时间的离散值,用 TypeScript 来描述是这样的
const epoch = Math.floor(Date.now() / 1000);
const counter = Math.floor(epoch / (config.period || DEFAULT_TOTP_PERIOD));
这里我们就得到了一个基于时间的计数器,那么我们可以反过来看,刚才 HOTP 困扰我们的是什么,是计数器同步!
那么什么是永恒同步的呢?是时间!对了孩子们,到了这里我们的一切问题都解决喽~
基本知识
下面由鸡米妮同学为我们讲解 TOTP 的基本知识,这其实和 HOTP 没什么太大的不同(除了计数器)
- 共享密钥 (Shared Secret Key, K): 与 HOTP 中的
K
完全相同。 - 时间步长 (Time Step, X): 定义了 OTP 的有效期限,通常是30秒或60秒。RFC 6238 默认是30秒。例如,如果 X=30秒,则每30秒会生成一个新的 OTP。
- 初始时间 (T0): 一个参考时间戳,通常是 Unix 纪元时间 (1970年1月1日 00:00:00 UTC)。默认
T0 = 0
。 - 当前时间 (T): 当前的 Unix 时间戳 (从 T0 开始计数的秒数)。
- 计算时间计数器 (C_T): TOTP 的核心是将当前时间转换为一个离散的整数值,这个值在每个时间步长内保持不变。这个值就充当了 HOTP 中的计数器
C
。C_T = floor((Current Unix Time - T0) / X)
(floor 表示向下取整)。 - HMAC 函数: 与 HOTP 相同,使用共享密钥
K
和计算出的时间计数器C_T
。HS = HMAC(K, C_T)
- 动态截断: 与 HOTP 的截断过程完全相同,将
HS
转换为6位或8位数字码。
工作流程
工作流程是下一篇文章给 TouchGal 对接 2FA 要讲的,这里仅由鸡米妮同学简单提一句
- 设置阶段:
- 服务器生成一个唯一的共享密钥
K
。 - 服务器将
K
(以及可选的X
和T0
,如果不是默认值) 安全地提供给用户的认证设备/应用。
- 服务器生成一个唯一的共享密钥
- OTP 生成 (客户端):
- 客户端获取当前 Unix 时间
T
。 - 使用
T
,T0
,X
计算出当前的时间计数器C_T
。 - 使用
K
和C_T
,通过 HMAC 和截断函数生成 OTP。 - 客户端显示 OTP。它会自动在下一个时间步长开始时刷新。
- 客户端获取当前 Unix 时间
- OTP 验证 (服务器):
- 用户将客户端显示的 OTP 输入到服务器。
- 服务器获取当前 Unix 时间,并计算出其认为的当前时间计数器
C_T_server
。 - 时间窗口容错: 由于客户端和服务器之间可能存在轻微的时钟漂移 (clock drift) 或网络延迟,服务器通常不仅会验证基于
C_T_server
生成的 OTP,还会验证基于前一个时间步长 (C_T_server - 1
) 和后一个时间步长 (C_T_server + 1
) 生成的 OTP。这提供了一个容错窗口(例如,如果 X=30秒,则容错窗口可以是 -30秒 到 +30秒)。 - 服务器使用
K
和这些时间计数器值(例如C_T_server-1
,C_T_server
,C_T_server+1
)生成对应的 OTPs。 - 服务器将用户提交的 OTP 与自己生成的这些 OTPs 进行比较。
- 如果匹配:认证成功。
- 如果不匹配:认证失败。
时间窗口容错
有时候网站 2FA 时,我们输入完验证码发现验证码立马变了,但是这个时候点击登录仍然可以正确进行 2FA,这就是时间窗口容错带来的好处(我所能体验到的最大的好处,因为眼睁睁看着验证码过期了又得重新输一个,就会让我很难受!),原理是这样的
- 你输入的验证码属于刚刚过去的那个时间窗口。
- 服务器在验证时,会检查当前时间窗口以及前后(取决于 drift 设置)的一个或多个时间窗口内生成的验证码。
- 由于你输入的验证码在服务器的容错窗口内,所以即使认证应用上已经显示了新的验证码,你的登录依然能够成功。
这大大提升了用户体验,避免了因为微小的时间差异或操作延迟导致的频繁登录失败。
使用 TypeScript 来实现大概是这样的
const generatePasscodes = (options: TotpCode, config: ValidTotpConfig) => {
const epoch = Math.floor(Date.now() / 1000);
const counter = Math.floor(epoch / (config.period || DEFAULT_TOTP_PERIOD));
const counters = [counter]; // 1. 首先包含当前时间窗口的计数器
if (options.drift && options.drift > 0) { // 2. 如果提供了 drift 值
for (let i = 1; i <= options.drift; i++) {
counters.push(counter + i); // 3. 添加未来时间窗口的计数器
counters.push(counter - i); // 4. 添加过去时间窗口的计数器
}
}
const codes: string[] = [];
const hmac = new HmacBased();
for (let i = 0; i < counters.length; i++) {
codes.push(
hmac.generatePasscode( // 5. 为所有这些计数器生成密码
{
secret: options.secret,
counter: counters[i],
},
config
)
);
}
return codes;
}
然后在验证时,我们将生成的这些 codes
全部与用户提供的 passcode
进行比较,只要用户提供的 passcode
有一个在 codes
这个数组中,那么即可验证成功
如果想让用户有更多的容错时间,只需要增加 drift
的值即可,下面是一个 TypeScript 实现的验证函数,以此来验证用户是否通过认证
const validate = (options: TotpValidateOptions, config?: TotpConfig) => {
const validatedConfig = generateConfig(config);
const passcode = options?.passcode.replace(/\s/g, "") || "";
if (passcode.length !== validatedConfig.digits) {
throw new Error("Invalid passcode");
}
const codes = this.generatePasscodes(options, validatedConfig);
if (codes.includes(passcode)) {
return true;
}
return false;
}
总结
下面画一个图图来总结一下下 TOTP 究竟在做什么东东,叠词词恶心心
这个绘图可以在 https://excalidraw.com/#json=ZGunAF4EiEuqVVGKj4m9u,8cc8E6XTpjbb9l3okwa4GQ 查看
通用安全注意事项
下面由我们今天的榜样鸡米妮同学总结一下安全事项
- 共享密钥的安全性:
K
是整个系统的核心。它必须在生成、传输(例如通过otpauth://
URI 中的 QR 码)和存储(服务器端和客户端)过程中都得到妥善保护。如果K
泄露,则 OTP 机制失效。 otpauth://
URI:- 这是一种标准化的 URI 格式,用于方便地将 OTP 参数(密钥、类型、发行者、账户名、算法、位数、周期/计数器)配置到认证应用中。
- 示例:
otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
- 防暴力破解: 服务器端必须实现速率限制,以防止攻击者尝试大量猜测 OTP。通常在几次失败尝试后会锁定账户或增加延迟。
- 传输安全: 用户提交的 OTP(以及用户名、密码)应该通过 HTTPS 等安全通道传输。
- 备份和恢复: 为用户提供安全的备份码或恢复机制,以防他们丢失认证设备。
这里的备份和回复接下来会用到,otpauth://
URI 在实践中被我们做成一个二维码,这也是常规的方式(我不理解为什么一定要扫这个二维码,复制这个东东再粘贴进 Authenticator 不是很好吗,简直是现代互联网给用户惯的,怕用户看着这个 URI 觉得吓人,哎)
上面的代码实现摘自 time2fa
这个库,库的实现也比较干净利落,我们接下来的项目实现默认调用了这个包(嗯,我是调包侠,我懒得自己写,来打我鸭)
我们接下来的文章要讲解如何给网站对接 2FA 了,需要这些基础知识铺垫,嗯,就是这样!