雜記 › 技術筆記 › 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 條,被量錯的正是最有用的那幾句。

📎 相關文章

70 個位元組沒動,MIME 類型換了 13 次:副檔名才是唯一輸入 2026-10-23 轉 JPG 時透明被強制填底色:91 個品質檔位在 PNG 面前不參與編碼 2026-10-23 「全部清除」後還剩 553 位元組,「只去掉位置信息」連縮圖都沒動 2026-10-23 序號位數只是下限、重名時 (2) 插在後綴之前:批次重新命名 2000 個檔案的實測口徑 2026-10-23 兩條憑證指紋一條 95 個字元一條 64 個:差的 31 個全是冒號,摘要物件是整張 DER 2026-10-23