MENU

时间带来的安全

July 2, 2026 • Read: 16 • 常山阅读设置

时间带来的安全

TOTP原理解析

高中的时候,我缠着我父亲要去办银行卡,当时为了网购开了网上银行,银行给了我一个U盾,每次从网上转账的时候,都要按一下U盾,U盾上显示一串数字,我把数字输到银行的网站里就能把钱转走,当时觉得这个操作很神奇。后来上了大学玩游戏发现很多游戏厂商都要强制人绑定一个与银行U盾功能差不多的验证码,后来我知道这叫2FA(Two Factor Authentication),也知道了这种U盾被叫做OTP(one-time password),昨天在用TOTP(Time-Based One-Time Password)的时候突然想到我竟然没有研究过TOTP是如何保证安全性的,于是写下本文,以窥探“时间带来的安全”。

本文主要简单回顾一下2FA、OTP的演化,以及HOTP(HMAC-Based One-Time Password)、TOTP的实现。

验证两种不同的凭证就能增强安全性?

想必大家都经历过使用密码登录APP后,APP还要进行短信验证,甚至还要在一堆图片中挑出红绿灯、自行车等,你可能会想:我都输对了密码,为什么还要这么折磨我?后来我才知道这种多次验证身份的操作叫双因素验证,即2FA。2FA除了验证用户设置的密码之外,还会验证用户“拥有的东西”,比如:手机、U盾、指纹、面容等。攻击者通过其他渠道获取了你的用户名、密码,如果没有启用2FA验证,那么攻击者就可以直接使用偷取的用户名密码登录你的账户;如果你启用了2FA验证,那么攻击者就需要去验证你“拥有的东西”,而这些东西都是你贴身,甚至你独一无二的东西,攻击者无法通过远程的方式窃取。2FA的核心就是为一道门增加一种不同的锁,把门锁增加成两个不同类型。而且这两个锁不是简单的1+1=2,因为同时解锁两个不同类型锁的难度远大于一个锁。

想象一下,你家的防盗门:密码就是钥匙,2FA就像防盗门的锁外加了一把密码锁,虽然小偷能撬开防盗门的锁,但是却不知道密码,不能打开密码锁,密码锁的密码被你放在银行的保险柜里,小偷是无论如何都不可能去银行里偷这个密码锁的密码的。

常见2FA组合有:

  1. 密码+短信验证:这是国内目前最普及的的2FA
  2. 密码+TOTP:国际上比较流行的2FA
  3. 密码+安全密钥:最安全,但是安全密钥需要额外购买
  4. 密码+APP推送确认:推送确认依赖网络

综合下来,TOTP算是安全性、便捷性比较有性价比的2FA手段了。

HOTP

要想了解TOTP的话,就应该先了解一下TOTP的前身HOTP。

HOTP全称是基于HMAC的一次性密码(HMAC-based One-time Password),是一种基于散列消息验证码的一次性密码算法,在2005年由IETF发布在RFC 4226标准文档中。

HOTP的核心思想,其实可以用一句话概括:服务器和你的令牌,各自悄悄藏着一模一样的两样东西——一把“秘密钥匙”和一个“计数器”。 它们用同一套固定的算法去搅拌这俩东西,因为输入相同、算法相同,算出来的数字必然相同;而攻击者没有那把钥匙,就永远算不出来。

这里有个关键角色叫 HMAC。要理解它,得先认识 SHA-1——一台“搅拌机”。你往里扔任何东西,它都吐出固定 20 字节的“乱码”,同样的输入永远得到同样的输出,但你没法从输出反推出输入(这叫单向性)。而 HMAC 给这台搅拌机配了一把钥匙,让它变成“认钥匙的搅拌机”:没有这把钥匙,盖不出相同的印章;换了钥匙,盖出来的印章完全不一样。一句话记——HMAC(钥匙, 消息) 就是一台认钥匙的搅拌机

有了 HMAC,HOTP 的算法就只剩三步:

  1. 搅拌:把秘密钥匙 K 和计数器 C 一起丢进 HMAC-SHA-1,得到 20 字节的乱码。
  2. 截取:20 字节太长,得砍成 4 字节。巧妙的地方在于——让这 20 字节自己告诉你从哪砍:看最后一个字节的低 4 位,得到一个位置(0~15),从那位置开始连取 4 字节。
  3. 取模:把这 4 字节当成一个大数字,对 100 万取余,就得到一个 6 位数,前面补 0,就是你看到的那串动态密码。
为什么要“让数据自己说从哪取”,而不是固定取前 4 字节?因为这样每次取的位置会随机落在 20 字节里的不同地方,避免不同密码之间出现规律性的关联。这就是 RFC 里所说的“动态截断”(Dynamic Truncation)。

举个真实的例子(RFC 里的标准数据)。假设 HMAC 算出来的 20 字节长这样:

位置:  00 01 02 ... 10 11 12 13 ... 19
数值:  1f 86 98 ... 50 ef 7f 19 ... 5a
  • 最后一个字节是 5a,它的低 4 位是 a(十进制 10),所以从第 10 个位置开始取;
  • 取出来是 50 ef 7f 19,当成数字是 1,357,872,921;
  • 对 1,000,000 取余 = 872921

这就是那一刻 U 盾上显示的那串数字。

为什么用 TOTP 而不是 HOTP ?

HOTP 有个根本性的尴尬:它的计数器,是交给人来“按”的。

这就解释了我小时候那个 U 盾为什么非得按一下——它在让计数器 +1。可“交给人的按钮”会带来一连串麻烦:

  1. 误按就失步:令牌揣在兜里,不小心被挤按了几下,计数器就偷偷走了几步,而服务器毫不知情。从此令牌和服务器永远对不上号,轻则要重新同步,重则得换个新令牌。
  2. 不按就不刷新:你不去按,令牌上一直显示同一个旧码,而这个旧码服务器可能早就不再接受了。你得时刻记得“用之前先按一下”。
  3. 和今天的手机 App 格格不入:HOTP 是给“带按钮的硬件令牌”设计的。可如今主流场景是手机里的验证器 App,你总不能让用户每次登录都去戳一下 App 里的“刷新”按钮——那也太蠢了。
  4. 最要命的是:好用和安全在打架。服务器为了猜测你到底按到了第几步,只能往后多试好几个值(这叫容错窗口)。窗口开得越大,越能容忍你乱按,但攻击者暴力猜中密码的概率也跟着水涨船高。换句话说,HOTP 里“对用户宽容”和“对攻击者严防”是冲突的。

问题的根源其实只有一句话:HOTP 把“走几步”这件事,托付给了会犯错的人。 那能不能把这个责任,交给一个永远不会犯错的东西呢?——时间,正好永不犯错。于是就有了 TOTP。

TOTP

把“按按钮的次数”换成“时间”

TOTP 全称是基于时间的一次性密码(Time-Based One-time Password),2011 年发布在 RFC 6238。它和 HOTP 的关系,一句话就能说清:

TOTP 就是 HOTP,只不过把“计数器”的来源,从“按按钮的次数”换成了“当前时间”。

搅拌、截取、取模那一整套机器,原封不动照搬。

这一换,HOTP 那四个麻烦就全解了:

  1. 不会再失步:时间是全世界统一的标准(UTC),令牌和服务器各自看钟就能对上号,根本不需要你做任何操作,自然也就不存在“误按”。
  2. 永远是最新的:时间在自动往前走,密码每 30 秒自动刷新一次,你打开 App 看到的永远是当下能用的码。
  3. 完美适配手机 App:你什么都不用按,打开就看,这正是它能成为今天主流 2FA 方式的根本原因。
  4. 好用和安全不再打架:因为不需要开大窗口来容忍你乱按,容错窗口可以设得很小(一般前后各 1 步,±30 秒),所以同样的用户体验下,TOTP 反而比 HOTP 更安全

说到底,TOTP 相对 HOTP 的全部进步,就是把对“人”的依赖,换成了对“时间”的依赖。人会因为手抖、健忘、嫌麻烦而出错;而时间,每过 30 秒就精准地走一步,谁也别想让它多走或少走。

那“时间”是怎么变成“计数器”的呢?很简单:

计数器 T = 当前 Unix 时间戳 ÷ 30  (向下取整)

Unix 时间戳,就是从 1970 年 1 月 1 日 0 点到现在一共过了多少秒。把它除以 30,就是“已经过去了多少个 30 秒”。每隔 30 秒,T 就 +1,密码就跟着变一次。

为什么是 30 秒?这是安全好用之间的折中。太长了,密码有效时间久,被偷看到的攻击窗口就大;太短了,你手还没敲完密码它就过期了,体验很差。30 秒是实践出来的甜点。

因为全世界所有人的钟都基于同一套标准时间(UTC),所以同一时刻,你和服务器算出的 T 永远相同,再加上同一把钥匙,双方就能在完全离线、互不通网的情况下,各自算出同一个密码。这也解释了为什么手机上的验证器 App 不需要联网就能蹦出数字——它只需要那把钥匙,和你手机自己的时钟。

这里有个特别妙的验证,能让你彻底相信“TOTP 就是 HOTP 换了个计数器”:

  • 在 HOTP 的标准测试里,计数器 = 1,算出来的中间数字是 1094287082,取 6 位是 287082
  • 在 TOTP 的标准测试里,时间 = 59 秒,而 59 ÷ 30 = 1,是同一个计数器!取 8 位,就是 1094287082 对 10⁸ 取余 = 94287082,正是 TOTP(59) 的值。

同一个“1”,同一个中间数,只是最后截取的位数不同。 搅拌机是同一台,只不过是时间在替你按按钮。

当然,用时间也有新的麻烦:时钟会有偏差。你的手机如果快了几秒、慢了几秒,或者出国忘了调时区,算出来的 T 可能就跟服务器对不上。TOTP 的解法是:服务器验证时,不只看当前这一个 T,而是前后各放宽几步(一般 ±1 步,即 ±30 秒)一起试,只要命中任意一个就算通过。这就是为什么你刚生成的密码有时隔几秒还能用,但过一会儿就失效了——容错窗口在起作用。

时间到底带来了什么?

了解了原理,再回头看安全性,会发现一件有意思的事:不管是 HOTP 还是 TOTP,最后都是一串 6~8 位数字,理论上只有一百万到一亿种可能,暴力穷举似乎很容易。那它凭什么安全?

靠的是两件事:

  1. 搅拌机的不可逆:HMAC 是单向的,攻击者就算偷看到了你过去的一万个历史密码,也反推不出那把钥匙,更算不出下一个密码。
  2. 服务器端的“限流”:服务器会把暴力尝试的次数卡死,失败几次就锁账户,或让每次失败后的等待时间翻倍。配上公式 成功概率 ≈ (容错窗口 × 尝试次数) / 10⁶,只要尝试次数被限死,暴力破解就基本不可能。

所以“时间”带来的安全,本质是:用一把共享的钥匙给“当前时间”盖一枚无法伪造的印章,再配合服务器对尝试次数的严格限制,让“猜”变得不现实。 而“时间”这个维度,相比 HOTP 的“按按钮”,最大的贡献是——它让验证变成了零操作:你什么都不用做,时间自己在走,密码自己在变,体验丝滑,安全照旧。

等等,TOTP 就完美了吗?

能防穷举,不代表什么问题都防住了。TOTP 的弱点,不在算法本身,而在它身处的那个更大的系统里。

  1. 实时钓鱼转发:这是 TOTP 最怕的一类攻击。对方搭建一个长得一模一样的假登录页,你输入密码之后它也让你输入 TOTP 码,你刚一输完,它立刻拿着这套"密码+TOTP"转发给真服务器——整个过程就是一次你替攻击者按了一次"合法按钮"。TOTP 的 30 秒有效期对它毫无意义,因为它根本不用等,转手就送出去。业界管这叫"钓鱼中间人"或 evilginx 类攻击。解法也很直接:别往陌生链接里输你的 TOTP 码。 技术上个更强的解法是 WebAuthn(通行密钥),因为它绑定了你访问的域名,钓鱼网站上压根不会签发给真域名。
  2. 种子密钥被一锅端:TOTP 的安全性完全建立在"那把钥匙只有你和服务器知道"上。但如果服务器被拖库、种子密钥泄露,绑在你账号上的所有 TOTP 就全部失效——攻击者拿到种子就能自己算出未来的每一个验证码,比从零开始穷举快得多。不像密码可以加盐哈希存储,TOTP 种子必须明文(或可逆加密)存在服务器上,才能在你验证时算同一串数。所以服务器的数据库安全,才是 TOTP 安全的真正脊梁
  3. 恶意 App 偷看屏幕:手机上装的恶意应用,如果被赋予了屏幕读取或截屏权限(或在后台拍下你的验证器画面),就能在每次你打开 Authenticator 的时候把种子扫描走。这类攻击甚至不需要接触服务器。
  4. 后备通道的短板:很多网站绑 TOTP 时会让你留一个手机号或邮箱作为恢复手段。攻击者通过 SIM swap(换卡诈骗)控制了你的手机号,就可以用"我收不到验证码"的理由绕过 TOTP、通过短信重置 2FA——你 TOTP 保护得再严,后门被人踹开了也没用。
  5. 缺乏胁迫保护:TOTP 没有"胁迫码"(duress code)的设计——你被持刀逼着解锁账户时,没法输入一个表面正常但暗中报警的假码。不过这不是 TOTP 独有的问题,大部分 2FA 方案都有。

所以结论很有趣:TOTP 防住了"远程猜到密码"这一关,却挡不住"你主动输给钓鱼网站"和"服务器里的密钥被人端走"这两关。 它是一个优秀的"第二把锁",但别忘了——锁永远只是整体安全链条里的一环。真正的安全,是链上最弱的一环说了算

写到这里,我才算真正搞懂了童年那把 U 盾:它当年按一下蹦出来的那串数字,不是什么魔法,而是 HMAC 搅拌机和计数器的一次合谋。而今天手机里那个每 30 秒跳一次的验证码,不过是同一台搅拌机,换了个由时间替你按动的按钮罢了。

时间在走,密码在变,而那把藏在两端的钥匙,始终守口如瓶。


附上一个极简的实现(Python,十几行),好奇的话可以跑一下,亲眼看看 94287082 是怎么“蹦”出来的:
import hmac, hashlib, struct, time

def hotp(secret, counter, digits=6):
    msg = struct.pack(">Q", counter)              # 计数器转成 8 字节
    h = hmac.new(secret, msg, hashlib.sha1).digest()
    o = h[-1] & 0x0f                              # 动态截取的起点
    code = ((h[o]     & 0x7f) << 24
          | (h[o + 1] & 0xff) << 16
          | (h[o + 2] & 0xff) << 8
          | (h[o + 3] & 0xff))
    return str(code % 10 ** digits).zfill(digits)

def totp(secret, t=None):
    t = int(time.time()) if t is None else t
    return hotp(secret, t // 30, digits=6)

# 用 RFC 标准密钥验证:counter=1 取 8 位,应该正好等于 TOTP(59)
secret = b"12345678901234567890"
print(hotp(secret, 1, digits=8))   # 94287082  —— 时间替你按的那一下“按钮”
Archives QR Code Tip
QR Code for this page
Tipping QR Code