杂记 › 技术笔记 › TOTP 口令差 30 秒就换码:±1 期反查能定位偏差,算法却只有 SHA-1 一档

TOTP 口令差 30 秒就换码:±1 期反查能定位偏差,算法却只有 SHA-1 一档

那次登录让我第一次盯着这六位数字:手机上的码已经跳了新值,我这边算的还是上一个,填进去自然被拒。差的几十秒去了哪里,页面不说,也没有地方填我慢了多少秒。

30 秒一格,换码只看跨不跨格

口令是 floor(sec/30) 那一格的函数。sec=1700000000 落在 counter 56666666 出 921300,本周期只剩 10 秒:往后连点 30 拍只出现 2 个码值,前 10 秒 921300、后 20 秒 732303。我在 TOTP 口令生成页逐秒跑完这串偏移,翻转只看 counter 跨没跨格。

时间因子只有 Date.now() 加 setInterval(tpTick, 1000) 心跳,没有任何校准入口:TP.clockMs 赋值点 0 个,也没有填秒数或选时区的表单。显示层有三套互不相等的口径(条宽 toFixed(1)、文字 Math.floor、环按常量 100.53 折算)——周期起点 sec=1699999980 条宽 100.0%、环 dashoffset 0.00 都是满格,只有文字写「本周期已过 0%」,真实已过 100.0% 那一格永不显示。

条宽 文字已过 环 offset 100.0 3.3 0.00 97.18 第 15 秒才相等 图 1:三套口径三条线

±1 期反查最多倒推 60 秒

反查只倒推方向:填进手机上那 6 位,它在各账户的前后期里找,窗口上限 ±2 期。基准 sec=1700000000、3 个账户(含 1 个 8 位)的命中数如下。

反查窗口k=-2k=-1k=0k=+1k=+2
该期真实口令713364276857921300732303136087
前后各 1 期01110
前后各 2 期11111

偏到 60 秒时「前后各 1 期」喂 713364 命中 0,报「全部 3 个账户在 ±1 期里都没有这个码」,开到 2 期才报快了 60 秒;秒数按 offset×period 折算,60 秒周期的账户命中 ±1 期就报 60 秒。「多半是密钥抄错了」不能当结论——位数不符与解不开都被 continue 掉,skipped=1 把两种跳过混着报。

窗口与试算点数 只看当期 1 点 / 当期 前后各 1 期 3 点 / ±30 秒 前后各 2 期 5 点 / ±60 秒 偏 60 秒这一档命中 0,另有 1 个账户被跳过 图 2:一档多试 2 个点

向量这一关只过了 SHA-1 一档

自测表写死 16 项,tpSelfTest() 返回 items=16 pass=16 fail=0,提示语是「16 项官方向量全部命中」:SHA-1 确实 6/6,T=59 → 94287082、T=20000000000 → 65353130,HOTP 10 条逐字相同。

表里没有 SHA-256 与 SHA-512 的一行,模板只有 tpSha1 与 tpHmacSha1,L319–L326 讲算法那段没有可选控件。对 RFC 6238 附录 B 就露底:SHA-256 六条 0/6(T=59 期望 46119246,本页 97599872)、SHA-512 同样 0/6(期望 90693936,本页 14779409);写着 algorithm=SHA256 的链接照样按 SHA-1 建账户,只追加一句固定按 SHA-1 计算。强度距离见 SHA-256 与 MD5 那篇。

Base32 的宽容度:脏写法同码,末名字符也同码

脏写法真能混过去:同一份 32 字符密钥的全小写、每 4 字符一空格、连字符按 8 分组、尾部补 8 个填充符(39、35、40 字符)四种写法都解出同一个 20 字节,T=1111111111 的 8 位口令都是 14050471;被当空白跳过的共 9 种。拦的只有字符集:0、1、8、9、下划线、中文一律抛错并带位置,只认 A–Z 和 2–7。TP_B32(L390)是 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567,I 是第 9 个、L 是第 12 个,被去掉的只有那四个数字,「去掉了 0O1IL」那句与实现相反。

名义字符数也不等于强度:n 字符解出 floor(n*5/8) 字节,20 字符档的 bits 按 n*5 报 100 位,实际只落回 12 字节 = 96 位,比标签写的「20 字符(128 bit)」差 28 位;20、16 字符两档都会触发告警,只有 32 字符(160 位)不触发。也没一律走 crypto.getRandomValues,L1151–L1156 有 Math.random() 降级分支。

尾名丢弃最隐蔽:20 字符档只换末名时 32 种取值塌成 2 种字节串,「…GEZA」与「…GEZB」同为 12 字节、同一个码 228934,只有 n%8=0 的 8/16/24/32 字符才 32 种互不相同。中间字符不受影响:32 字符第 5 位 G→H 让 921300 变 188215。

位数、周期、计数器:静默兜底不等于拦下来

位数那一栏几乎不设防:1 到 10 全收,非法值静默兜成 6,6.9 读成 6、8abc 读成 8;同一个 counter 0 的 30 位整数 1284755224 逐档掉下来。

  • 1/4/6/8/10 位分别是 4 / 5224 / 755224 / 84755224 / 1284755224,只有 10 位不丢信息。
  • 周期走 parseInt:1e9 读成 1 秒、86401 封顶成 86400;digits=12 静默变 6 且 warn 数组为空,文案仍写「按 6 位处理」。

计数器这边反而不怕截断:tpCounterBytes 除 256 取余写 8 字节大端,9007199254740991 得 891307;越过 2^64 才静默回绕成 counter 0 的 755224,而时间因子要涨到 270215977642229760 秒(约 85.6 亿年)才与规范第一次分歧。

Q:手机和电脑差一期,这页能改哪一边?

都不能改:没有填偏移秒数或选时区的入口,反查只倒推方向,命中下一期读作本机时间大约慢了 30 秒。

Q:密钥少抄了一个字符会一直错吗?

抄在中间一定错,抄在末名可能是另一份密钥却同出 228934。长度不合规另说:11 字符抛「末尾剩 3 个凑不出完整字节」,13 字符照样算出 78121305,12 字符 93818264 并警告末尾 4 位不是 0。

Q:一条 otpauth 链接能带走几个账户?

一次一个,TP.linkId 是标量,100 个账户就是手点 100 次复制,密钥解不开的点下去还抛未捕获异常。页面不上传:fetch 与 XMLHttpRequest 各 0 次,账户只落在 localStorage(键名 u2-totp-accounts),qrcode 字样 0 次。

绕回那次被拒的登录:改时钟得回到系统里去,这页要么在 921300 还剩的 10 秒里填完,要么按下一期的 732303 填。文案那侧的账也就能算清:62 条主张里 [一致] 40 条、[部分冲突] 16 条、[冲突] 6 条,被量错的正是最有用的那几句。

📎 相关文章

后缀决定 MIME:同一份 70 字节 PNG 头改出 18 个后缀、13 种类型和 5 个未知 2026-10-23 转 JPG 时透明被强制填底色:91 个质量档位在 PNG 面前不参与编码 2026-10-23 「全部清除」后还剩 553 字节,「只去掉位置信息」连缩略图都没动 2026-10-23 序号位数只是下限、重名时 (2) 插在后缀之前:批量重命名 2000 个文件的实测口径 2026-10-23 证书指纹实测:1159 字节 DER 与 3 种摘要、4 种写法 2026-10-23