TOTP と HOTP

3 行まとめ
Section titled “3 行まとめ”- TOTP は HOTP の時刻版。 HOTP(RFC 4226)が カウンタで動かすものを、TOTP(RFC 6238)は 時刻から作ったカウンタで動かす。アルゴリズム本体は同一。
- 中身は HMAC-SHA-1 を 6 桁に潰しているだけ。 山場は
dynamic truncation(RFC 4226 §5.3)で、20 バイトのハッシュから
「最後のバイトが指す位置の 4 バイト」を取り、最上位ビットを落として
mod 10^6する。 - TOTP にフィッシング耐性はない。 NIST SP 800-63B-4 §3.2.5 が、手入力する OTP は “SHALL NOT be considered phishing-resistant” と明記している。 パスワード漏洩には強いが、偽サイトへの入力には無力。
当たるべき文書
Section titled “当たるべき文書”| 文書 | 位置づけ | この記事で扱う範囲 |
|---|---|---|
| RFC 4226 | HOTP(2005)。アルゴリズム本体 | dynamic truncation、鍵長、スロットリング |
| RFC 6238 | TOTP(2011)。HOTP の時刻版 | T の作り方、時刻ずれ、リプレイ禁止 |
| RFC 4648 | Base32 / Base64 | 共有秘密の配布形式 |
| RFC 6287 | OCRA(チャレンジレスポンス版) | 1 段落だけ |
| NIST SP 800-63B-4 | 2025-07-31 発行 | AAL、SMS の扱い、フィッシング耐性の定義 |
| Key Uri Format | RFC ではない。de facto | otpauth:// の書式 |
多要素認証の中の位置づけ
Section titled “多要素認証の中の位置づけ”「要素」は 3 種類しかない。 同じ種類を 2 つ重ねても多要素にはならない。
flowchart TB
MFA["多要素認証<br/>異なる種類を 2 つ以上"]
K["知識<br/>Something you know"]
H["所持<br/>Something you have"]
B["生体<br/>Something you are"]
MFA --> K
MFA --> H
MFA --> B
K --> K1["パスワード<br/>PIN<br/>秘密の質問"]
H --> H1["TOTP アプリ<br/>SMS OTP<br/>セキュリティキー"]
B --> B1["指紋<br/>顔"]
style H fill:#4054b2,color:#fff
style H1 fill:#4054b2,color:#fff
パスワード + 秘密の質問は 2 要素ではない(どちらも知識)。 TOTP が価値を持つのは、「知識」とは独立に盗む必要があるから。
HOTP と TOTP の違い
Section titled “HOTP と TOTP の違い”変わるのはカウンタの作り方だけ。 ハッシュも切り出し方も同じ。
flowchart TB
K["共有秘密 K<br/>両者が同じ値を持つ"]
HC["HOTP のカウンタ<br/>使うたびに +1 する"]
TC["TOTP のカウンタ<br/>T = (現在時刻 - T0) / X"]
ALGO["HMAC-SHA-1(K, C)<br/>dynamic truncation<br/>mod 10^Digit"]
OUT["6 桁の数字"]
K --> ALGO
HC --> ALGO
TC --> ALGO
ALGO --> OUT
style TC fill:#4054b2,color:#fff
style ALGO fill:#4054b2,color:#fff
| HOTP (RFC 4226) | TOTP (RFC 6238) | |
|---|---|---|
| カウンタ | 使うたびに増える整数 | 時刻から計算する整数 |
| ずれる原因 | 利用者が生成だけして使わない | 端末とサーバーの時計のずれ |
| ずれの直し方 | 先読み窓 s(§7.4) |
前後の時間ステップを試す(§5.2) |
| 有効期間 | 次に使うまで(無期限) | 既定 30 秒 |
| 主な用途 | ハードウェアトークン、バックアップコード | 認証アプリ(大多数) |
TOTP のほうが安全に見えるが、根拠は「有効期間が短い」ことだけ。 アルゴリズムの強度は同じ。
データフローを追う
Section titled “データフローを追う”実値で全段追う。 共有秘密は 20 バイト(160 bit)の乱数とする。 すべて手元で再現できる。
1. 共有秘密が配られる
Section titled “1. 共有秘密が配られる”サーバーが乱数を生成し、QR コードで利用者の認証アプリへ渡す。
| 形式 | 値 |
|---|---|
| 生バイト(16 進) | 4a7c1f93e05b28d6a1f4370c9e58bd2617ac40f3(20 バイト = 160 bit) |
| Base32(RFC 4648) | JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHT(32 文字) |
# 20 バイトの乱数を Base32 にするopenssl rand 20 | base32# JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHTBase32 を使うのは、大文字と数字だけで手入力できるから。
A-Z と 2-7 の 33 文字(= を含む)で 1 文字 5 bit。
20 バイト = 160 bit はちょうど 32 文字になり、パディングが要らない。
2. カウンタ T を作る
Section titled “2. カウンタ T を作る”RFC 6238 §4:
T = (Current Unix time - T0) / X
| パラメータ | 既定値 | 意味 |
|---|---|---|
T0 |
0(Unix epoch) | 数え始めの時刻 |
X |
30 秒 | 1 ステップの長さ |
割り算は切り捨て(floor)。
Unix time = 1789000000 (2026-09-10T00:26:40Z)T = 1789000000 / 30 = 59633333T は HOTP のカウンタとして 8 バイトのビッグエンディアンに詰める。
59633333 -> 0x00000000038DEEB530 秒のあいだ T は変わらない。 だから同じコードが 30 秒間表示される。
3. HMAC-SHA-1 を計算する
Section titled “3. HMAC-SHA-1 を計算する”HMAC-SHA-1(K, C) = 986fb9860648f89691632816cfd3e97e75635e4c (20 バイト)4. Dynamic truncation
Section titled “4. Dynamic truncation”ここが RFC 4226 §5.3 の山場で、口頭で説明しても伝わらない箇所。 仕様の記述はこうなっている:
DT(String) // String = String[0]...String[19] Let OffsetBits be the low-order 4 bits of String[19] Offset = StToNum(OffsetBits) // 0 <= OffSet <= 15 Let P = String[Offset]...String[Offset+3] Return the Last 31 bits of Pさっきのハッシュに当てはめる。
flowchart TB
H["HMAC-SHA-1 (20 バイト)<br/>986fb986 0648f896 91632816<br/>cfd3e97e 75635e4c"]
L["最後のバイト = 0x4c<br/>下位 4 bit = 0xc = 12"]
P["12 バイト目から 4 バイト取る<br/>P = cfd3e97e"]
M["最上位ビットを落とす<br/>0xcf and 0x7f = 0x4f<br/>4fd3e97e = 1339287934"]
D["1339287934 mod 10^6<br/>= 287934"]
H --> L --> P --> M --> D
style M fill:#7a4a12,color:#fff
style D fill:#1f5c33,color:#fff
| 段階 | 値 |
|---|---|
| ハッシュの最後のバイト | 0x4c |
| その下位 4 bit | 0xc = 12 |
P = h[12..15] |
cfd3e97e |
| 最上位ビットを落とす | 4fd3e97e = 1339287934 |
mod 10^6 |
287934 |
この例では最上位ビットが実際に立っている(0xcf = 11001111)ので、
マスクが効いて値が変わる。仕様がマスクする理由も書いてある:
The reason for masking the most significant bit of P is to avoid confusion about signed vs. unsigned modulo computations.
符号付き 32 bit 整数しか持たない言語で負の数になるのを避けるため。 セキュリティ上の理由ではなく、実装の相互運用性のための措置。 ここを取り違えて「31 bit にすることで強度を上げている」と説明している記事があるが違う。
5. オフセットが動く意味
Section titled “5. オフセットが動く意味”毎回ハッシュの違う場所から切り出す。 固定位置から取ると、 その 4 バイトだけを狙った解析ができてしまう。
前後の時間ステップも計算してみると、切り出し位置が毎回変わっているのが分かる。
| ステップ | コード |
|---|---|
T - 1 = 59633332 |
066024 |
T = 59633333 |
287934 |
T + 1 = 59633334 |
677107 |
連続する 3 つのコードに何の関係もない。 1 つ漏れても次は予測できない。
6. シェルだけで再現する
Section titled “6. シェルだけで再現する”openssl と xxd だけで組める。 RFC 4226 の検証ベクタと突き合わせられる。
hotp() { # $1 = 共有秘密(hex), $2 = カウンタ local ch h off dbc4 num ch=$(printf '%016X' "$2") # 8 バイト BE に詰める h=$(printf '%s' "$ch" | xxd -r -p \ | openssl dgst -sha1 -mac HMAC -macopt hexkey:"$1" -binary \ | xxd -p | tr -d '\n') # HMAC-SHA-1 (40 hex) off=$(( 16#${h: -1} )) # 最後のバイトの下位 4 bit dbc4=${h:$((off*2)):8} # そこから 4 バイト num=$(( 16#$dbc4 & 0x7fffffff )) # 最上位ビットを落とす printf '%06d\n' $(( num % 1000000 )) # mod 10^6}
# RFC 4226 Appendix D の共有秘密 "12345678901234567890"KEY=$(printf '%s' '12345678901234567890' | xxd -p | tr -d '\n')for c in 0 1 2 3 4 5 6 7 8 9; do hotp "$KEY" "$c"; done出力は RFC 4226 Appendix D の表そのものになる。
755224 287082 359152 969429 338314254676 287922 162583 399871 520489TOTP にするなら、カウンタを時刻から作るだけ。
totp() { hotp "$1" $(( $(date +%s) / 30 )); }totp "$KEY"7. RFC 6238 の検証ベクタ
Section titled “7. RFC 6238 の検証ベクタ”自分の実装が正しいかはこの表で確かめる。 RFC 6238 Appendix B。
| Unix time | T(16 進) |
SHA-1 | SHA-256 | SHA-512 |
|---|---|---|---|---|
| 59 | 0000000000000001 |
94287082 |
46119246 |
90693936 |
| 1111111109 | 00000000023523EC |
07081804 |
68084774 |
25091201 |
| 1111111111 | 00000000023523ED |
14050471 |
67062674 |
99943326 |
| 1234567890 | 000000000273EF07 |
89005924 |
91819424 |
93441116 |
| 2000000000 | 0000000003F940AA |
69279037 |
90698825 |
38618901 |
| 20000000000 | 0000000027BC86AA |
65353130 |
77737706 |
47863826 |
表は 8 桁であることにも注意。6 桁と決まっているわけではない(後述)。
共有秘密の要件
Section titled “共有秘密の要件”「任意の長さでよい」は誤り。 RFC 4226 §4 の要件 R6:
The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits.
| 長さ | 評価 |
|---|---|
| 128 bit(16 バイト) | MUST の下限 |
| 160 bit(20 バイト) | RECOMMENDED。HMAC-SHA-1 の出力長と同じ |
| 80 bit(10 バイト) | 仕様違反。 古い実装に実在する |
RFC 6238 §5.1 の追加要求:
Keys SHOULD be chosen at random or using a cryptographically strong pseudorandom generator
サーバー側は共有秘密を復元可能な形で保存しなければならない。
ハッシュにできない — 検証のたびに HMAC を計算し直すから。
Digest 認証でパスワードを H(A1) で持たざるを得なかったのと同じ構造で、
データベースが漏れたら全員の TOTP が生成できてしまう。暗号化して保存する。
sequenceDiagram
autonumber
participant U as 利用者 (ブラウザ)
participant S as サーバー
participant A as 認証アプリ
U->>S: 2 要素認証を有効にする
S->>S: 20 バイトの乱数を生成<br/>暗号化して保存 (仮登録)
S-->>U: otpauth:// URI を<br/>QR コードで表示
U->>A: QR を読み取る
A->>A: Base32 を復号して保存
A-->>U: 6 桁を表示
U->>S: 表示された 6 桁を入力
S->>S: 同じ計算をして照合<br/>一致したら本登録
S-->>U: リカバリコードを表示
7 番の確認が要る。 これを省くと、QR の読み取りに失敗した利用者が 自分のアカウントから締め出される。

利用者が見るのはこれだけ。 残り時間の輪は「30 秒で変わる」を可視化したもので、 仕様上の要素ではない。
otpauth:// URI は RFC ではない
Section titled “otpauth:// URI は RFC ではない”otpauth://totp/devnote:taro@example.com ?secret=JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHT &issuer=devnote &algorithm=SHA1 &digits=6 &period=30Google Authenticator の wiki が事実上の標準になっているだけで、RFC 化されていない。 IETF に individual draft はあるが RFC にはなっていない。
| パラメータ | 既定 | 注意 |
|---|---|---|
secret |
必須 | Base32。仕様は旧 RFC 3548 を参照している(古さの証拠) |
issuer |
強く推奨 | アプリ内での表示名。ラベルの接頭辞と重複させる慣習 |
algorithm |
SHA1 |
Google Authenticator は無視する |
digits |
6 |
Android 版などは無視する |
period |
30 |
Google Authenticator は無視する |
counter |
— | HOTP のときのみ必須 |
時刻ずれとリプレイ
Section titled “時刻ずれとリプレイ”時刻ずれの許容
Section titled “時刻ずれの許容”RFC 6238 §5.2:
We RECOMMEND that at most one time step is allowed as the network delay.
「前後 1 ステップと決まっている」は半分誤り。 これは RECOMMEND であって MUST ではない。ただし窓を広げると総当たりが通りやすくなる。
flowchart TB
IN["6 桁を受け取る"]
W["許容する時間ステップを決める<br/>T-1 / T / T+1"]
C{"どれかと一致するか"}
R{"その (利用者, ステップ) は<br/>すでに使われたか"}
OK["認証成功<br/>使用済みとして記録"]
NG1["失敗回数を +1<br/>スロットリング"]
NG2["リプレイ<br/>MUST NOT で拒否"]
IN --> W --> C
C -->|"いいえ"| NG1
C -->|"はい"| R
R -->|"はい"| NG2
R -->|"いいえ"| OK
style OK fill:#1f5c33,color:#fff
style NG2 fill:#7a2222,color:#fff
| 窓 | 許容する時間幅 | 総当たり成功率 |
|---|---|---|
T のみ |
最大 30 秒 | 最小 |
T-1 / T / T+1 |
最大 90 秒 | 3 倍 |
| 前後 5 ステップ | 最大 330 秒 | 11 倍。広すぎる |
窓を広げるより、サーバーと端末の時計を NTP で合わせる。 RFC 6238 §6 は、認証が成功したときにその端末のずれを記録しておくことを勧めている。 利用者ごとにオフセットを覚えておけば、窓を広げずに済む。
リプレイは MUST NOT
Section titled “リプレイは MUST NOT”RFC 6238 §5.2:
The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP, which ensures one-time only use of an OTP.
「同じコードを 2 回使えても問題ない」は規格違反。 30 秒のあいだ同じコードが表示される以上、一度成功したコードは即座に無効化しないと、 肩越しに見られた・ログに残ったコードがそのまま通る。
実装は単純で、(利用者 ID, 成功したステップ番号)を記録して、 同じ組み合わせを二度受け付けなければよい。窓を使ったなら 実際に一致したステップを記録する(受信時刻ではない)。
総当たりとスロットリング
Section titled “総当たりとスロットリング”6 桁は 19.93 bit しかない。 Cookie とセッションで 「セッション ID は OWASP が 64 bit 以上を要求」と書いたが、TOTP の 6 桁はその 3 分の 1 以下。
| 値 | エントロピー |
|---|---|
| TOTP 6 桁 | 19.93 bit |
| TOTP 8 桁 | 26.58 bit |
| セッション ID(OWASP 最小) | 64 bit |
それでも成立するのは、有効期間が短く、試行回数が制限されているから。 RFC 4226 §7.2 が成功確率を式で与えている。
Sec = s * v / 10^Digit s = 許容する窓のステップ数 v = 検証を試せる回数 Digit = 桁数窓 3 ステップ・試行 100 回・6 桁なら:
3 * 100 / 1,000,000 = 0.0003 = 0.03%RFC 4226 §7.3 はスロットリングを RECOMMEND(MUST ではない)としているが、 NIST SP 800-63B-4 のほうは強い。
- “If the authenticator output is less than 64 bits in length, SHALL implement a rate-limiting mechanism” — 6 桁は 64 bit 未満なので、レート制限は SHALL
- “limit consecutive failed authentication attempts … to no more than 100”
スロットリングが無い TOTP は、6 桁の数字を総当たりされるだけ。 アルゴリズムの強度ではなく、検証側の実装で守っている。
6 桁と決まっているわけではない
Section titled “6 桁と決まっているわけではない”- RFC 4226 §4 の要件 R4 — “The HOTP value must be at least a 6-digit value”(下限)
- NIST SP 800-63B-4 — “at least six decimal digits (or equivalent) in length”(下限)
- RFC 6238 Appendix B の検証ベクタは 8 桁
6 桁は下限であって規定値ではない。 ただし otpauth:// の digits を
無視するアプリがあるので、実務では 6 桁から動かせない。
TOTP が防げないもの
Section titled “TOTP が防げないもの”パスワード漏洩には強い。フィッシングには無力。 ここを混同すると、 TOTP を入れたことで安心してしまう。

NIST SP 800-63B-4 §3.2.5 のフィッシング耐性の定義:
Phishing resistance is the ability of the authentication protocol to prevent disclosure of authentication secrets and valid authenticator outputs to an impostor
そして OTP は明確に対象外:
Authenticators that involve manual entry of an authenticator output (e.g., out-of-band and OTP authenticators) SHALL NOT be considered phishing-resistant
中間者フィッシング(AiTM)
Section titled “中間者フィッシング(AiTM)”偽サイトが利用者と本物のサーバーの間に入るだけで、TOTP は素通りする。
sequenceDiagram
autonumber
participant U as 利用者 (ブラウザ)
participant M as 偽サイト (AiTM)
participant S as 本物のサーバー
U->>M: ID とパスワードを入力
M->>S: そのまま転送する
S-->>M: 2 要素のコードを要求
M-->>U: 同じ画面を見せる
U->>M: 6 桁を入力
M->>S: 30 秒以内に転送
S-->>M: 認証成功<br/>セッション Cookie
M->>M: Cookie を奪う
M-->>U: 本物へリダイレクト
利用者から見て何もおかしくない。 本物の画面が中継されているので、 見た目でも挙動でも気づけない。
| 攻撃 | TOTP で防げるか |
|---|---|
| パスワードリスト攻撃 | 防げる(コードを知らない) |
| データベース漏洩後のログイン | 防げる |
| 肩越しの盗み見(30 秒後) | 防げる |
| リアルタイム中継フィッシング(AiTM) | 防げない |
| サーバー側の共有秘密の漏洩 | 防げない |
| 端末そのものの乗っ取り | 防げない |
AiTM を防げるのはオリジン束縛を持つ方式だけ — つまり WebAuthn / パスキー。 署名の対象にアクセスしているサイトのオリジンが含まれるので、 偽サイト向けの署名は本物のサーバーで検証に落ちる。
他方式との比較
Section titled “他方式との比較”| 方式 | 要素 | フィッシング耐性 | サーバーが持つ秘密 | NIST の扱い |
|---|---|---|---|---|
| パスワードのみ | 知識 | なし | ハッシュ(復元不可) | 単独では AAL1 |
| TOTP | 所持 | なし | 共有秘密(復元可能) | Single-Factor OTP |
| SMS OTP | 所持 | なし | なし(電話番号) | RESTRICTED |
| マジックリンク | 所持 | なし | なし(トークン) | out-of-band 扱い |
| WebAuthn / パスキー | 所持(+ 生体) | あり | 公開鍵のみ | AAL2 / AAL3 |
SMS OTP は現行の NIST でも restricted のまま。
SP 800-63B-4 §3.1.3.3 は、使う前に
“risk indicators (e.g., device swap, SIM change, number porting, other abnormal behavior)”
を考慮すべき(SHOULD)としている。「SMS OTP と TOTP は同等」は誤り。
flowchart TB
Q1{"フィッシング耐性が<br/>要件に入っているか"}
Q2{"利用者に専用の<br/>端末やキーを<br/>用意させられるか"}
Q3{"認証アプリを<br/>使える利用者か"}
W["WebAuthn / パスキー"]
T["TOTP<br/>+ パスワード"]
S["SMS OTP<br/>最終手段"]
Q1 -->|"はい"| W
Q1 -->|"いいえ"| Q3
Q3 -->|"はい"| T
Q3 -->|"いいえ"| Q2
Q2 -->|"はい"| W
Q2 -->|"いいえ"| S
style W fill:#1f5c33,color:#fff
style T fill:#4054b2,color:#fff
style S fill:#7a2222,color:#fff
TOTP は「パスワードだけ」からの改善としては大きい。 WebAuthn に届かないからといって、入れない理由にはならない。
OCRA — チャレンジレスポンス版
Section titled “OCRA — チャレンジレスポンス版”RFC 6287(OATH Challenge-Response Algorithm)。
a generalization of HOTP with variable data inputs not solely based on an incremented counter and secret key values
HOTP がカウンタだけで動くのに対し、サーバーが送るチャレンジ・時刻・PIN 検証値・ トランザクション内容などを入力にできる。「この振込内容に対する承認コード」を作れるので、 取引ごとの署名に使える。時刻同期にも依存しない。
金融系のハードウェアトークンで使われるが、Web アプリで採用する場面はほとんどない。 名前だけ知っておけばよい。
使ってよいケースと使ってはいけないケース
Section titled “使ってよいケースと使ってはいけないケース”成立の前提条件
Section titled “成立の前提条件”- サーバーと端末の時計が NTP で合っている(ずれを窓で吸収しない)
- 共有秘密が 128 bit 以上(推奨 160 bit)の乱数である
- 共有秘密を暗号化して保存している(平文で持たない)
- リプレイを拒否している(成功したステップを記録する)
- レート制限がある(連続失敗 100 回以内で止める)
- リカバリコードを発行し、端末紛失に備えている
- フィッシング耐性は無いと理解したうえで採用している
ひとつでも欠けたら、TOTP を入れた意味が大きく減る。 特に 4 つ目と 5 つ目が欠けると、6 桁の数字を総当たりされるだけになる。

試験前に見返すならこの 1 枚。 コーラルで示したレート制限は、 無いとアルゴリズムの強度が無関係になる唯一の項目。
使うかどうかの判定
Section titled “使うかどうかの判定”flowchart TB
Q1{"パスワードだけの<br/>状態か"}
Q2{"レート制限と<br/>リプレイ拒否を<br/>実装できるか"}
Q3{"フィッシング耐性が<br/>必須要件か"}
OK["TOTP を入れる<br/>パスワードと併用"]
NG1["先に WebAuthn を検討する<br/>TOTP は併用の位置づけ"]
NG2["実装してから入れる<br/>無いと総当たりで抜ける"]
NG3["すでに 2 要素なら<br/>優先度は下がる"]
Q1 -->|"いいえ"| NG3
Q1 -->|"はい"| Q3
Q3 -->|"はい"| NG1
Q3 -->|"いいえ"| Q2
Q2 -->|"いいえ"| NG2
Q2 -->|"はい"| OK
style OK fill:#1f5c33,color:#fff
style NG2 fill:#7a2222,color:#fff
使ってよいケース
Section titled “使ってよいケース”| ケース | 成立する理由 | 一緒にやること |
|---|---|---|
| パスワード認証への 2 要素追加 | パスワードリスト攻撃と DB 漏洩の両方を止められる | リカバリコード。レート制限 |
| 管理画面・SSH の踏み台 | 利用者が限られ、端末を用意させられる | できれば WebAuthn。TOTP は移行期間の措置 |
| WebAuthn を主とする構成の代替手段 | 端末を持たない利用者の受け皿になる | SMS より TOTP を代替に置く |
| 社内システムでコストをかけずに強化 | サーバー側の実装が小さく、ライブラリで済む | 共有秘密の暗号化。時計の同期 |
| オフライン環境 | 通信が不要。時計さえ合っていれば動く | 時計のずれの記録(RFC 6238 §6) |
使ってはいけないケース
Section titled “使ってはいけないケース”| ケース | できないこと |
|---|---|
| フィッシング対策として導入する | NIST が SHALL NOT で否定。AiTM で素通りする |
| レート制限なしで運用する | 6 桁 = 19.93 bit。総当たりで抜ける |
| リプレイを許す | RFC 6238 §5.2 の MUST NOT 違反 |
| 共有秘密を平文で保存する | DB が漏れたら全員分のコードを生成できる |
| 共有秘密を 80 bit などにする | RFC 4226 §4 の MUST(128 bit 以上)違反 |
digits や period を既定から変える |
主要アプリが無視するので認証が通らなくなる |
| パスワードの代わりに使う | TOTP 単独は AAL1 相当。多要素にならない |
| リカバリ手段を用意せずに必須化する | 端末紛失で全員が締め出される |
| 種別 | 例 |
|---|---|
| 認証アプリ | Google Authenticator、Microsoft Authenticator、Authy、1Password、Bitwarden |
| サーバー側ライブラリ | otplib / speakeasy(Node.js)、pyotp(Python)、rotp(Ruby) |
| CLI | oathtool(brew install oath-toolkit) |
| SaaS | Auth0、Okta、Keycloak(いずれも標準機能として持つ) |
# oathtool で検証する(自作実装の答え合わせに使う)oathtool --totp -b JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHTNode.js で検証する
Section titled “Node.js で検証する”ライブラリを使うのが正解だが、何をやっているかは押さえておく。
import crypto from 'node:crypto';
const STEP = 30; // RFC 6238 の既定 Xconst DIGITS = 6;const WINDOW = 1; // 前後 1 ステップ (§5.2 の RECOMMEND)
function hotp(secret, counter, digits = DIGITS) { const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(counter)); // 8 バイト BE const h = crypto.createHmac('sha1', secret).update(buf).digest();
const offset = h[h.length - 1] & 0x0f; // 最後のバイトの下位 4bit const num = ((h[offset] & 0x7f) << 24) // 最上位ビットを落とす | (h[offset + 1] << 16) | (h[offset + 2] << 8) | h[offset + 3];
return String(num % 10 ** digits).padStart(digits, '0');}
// 戻り値は「一致したステップ番号」。リプレイ判定にこれを使うexport function verifyTotp(secret, input, { now = Date.now(), drift = 0 } = {}) { const current = Math.floor(now / 1000 / STEP) + drift;
for (let d = -WINDOW; d <= WINDOW; d++) { const expected = hotp(secret, current + d); // 桁数が同じなので長さは常に一致する。タイミング安全比較を使う if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(input))) { return current + d; } } return null;}押さえる点は 4 つ。
- 比較は
timingSafeEqual。===は先頭から違いが出た位置でタイミングが変わる - 戻り値をステップ番号にする。 リプレイ判定に「受信時刻」ではなく 実際に一致したステップを使う
driftを引数にして、利用者ごとの時計ずれを記録して補正する(RFC 6238 §6)- 入力の桁数を先に検証する。
timingSafeEqualは長さが違うと例外を投げる
リプレイとレート制限
Section titled “リプレイとレート制限”export async function login(userId, code) { // 1. レート制限が先。検証より前に止める const fails = await countRecentFailures(userId); if (fails >= 100) throw new Error('locked'); // NIST: 連続 100 回以内
const { secret, drift } = await loadTotpSecret(userId); // 復号して取り出す const step = verifyTotp(secret, code, { drift });
if (step === null) { await recordFailure(userId); throw new Error('invalid code'); }
// 2. リプレイ拒否 (RFC 6238 §5.2 の MUST NOT) // 一致したステップを atomic に記録する。すでにあれば拒否 const first = await claimStep(userId, step); if (!first) throw new Error('replayed code');
// 3. 成功したので時計のずれを覚えておく (§6) const expected = Math.floor(Date.now() / 1000 / 30); if (step !== expected) await saveDrift(userId, step - expected);
await clearFailures(userId); return true;}claimStep は atomic でなければならない。 「あるか確認してから書く」だと、
同じコードを同時に 2 回投げられると両方通る。
一意制約を張った INSERT か、Redis の SET NX を使う。
運用で押さえる点
Section titled “運用で押さえる点”共有秘密の保存
Section titled “共有秘密の保存”| 方法 | 評価 |
|---|---|
| 平文 | 不可。 DB 漏洩で全員分のコードを生成できる |
| ハッシュ | 不可能。 検証のたびに HMAC の計算が要る |
| アプリ層で暗号化(KMS / envelope encryption) | これを使う |
| DB の透過的暗号化のみ | 不十分。DB へのアクセス権があれば読める |
パスワードと違ってハッシュにできないのが TOTP の構造的な弱点。
Digest 認証でサーバーが H(A1) を持たざるを得なかったのと同じで、
検証側が生成もできる方式には必ずこの性質がある。
端末の紛失は必ず起きる。 対策を用意せずに必須化すると、 サポートコストが跳ね上がる。
| 手段 | 注意 |
|---|---|
| リカバリコード(使い捨て 8〜10 個) | 1 回使ったら消す。 ハッシュして保存する |
| 予備の認証アプリを登録させる | 同じ共有秘密を複数端末に入れることになる |
| 本人確認を経た手動リセット | ここが最も攻撃される。 ソーシャルエンジニアリングの標的 |
リカバリコードはパスワードと同じ扱いで保存する(ハッシュ + ソルト)。 使い捨てなので、こちらはハッシュにできる。
ログに残してはいけない値
Section titled “ログに残してはいけない値”| 値 | 扱い |
|---|---|
共有秘密 / otpauth:// URI |
絶対に残さない。 URI 全体が秘密 |
| 入力された 6 桁 | 残さない。 30 秒有効なので即座に悪用できる |
| 一致したステップ番号 | 残してよい(リプレイ判定に必要) |
| 失敗回数 | 残す |
QR コードの画像も秘密。スクリーンショットを添付したサポート問い合わせが チケットシステムに残る、というのが現実に起きる事故。
HMAC-SHA-1 は危殆化していない
Section titled “HMAC-SHA-1 は危殆化していない”「SHA-1 が壊れているから TOTP は危ない」は誤り。
SHA-1 で問題になったのは衝突耐性(同じハッシュになる 2 つの入力を作れる)で、 これが効くのは署名や証明書の用途。HMAC は衝突耐性に依存しない構成なので、 HMAC-SHA-1 は現在も実用上安全とされている。
RFC 6238 §1.2 が SHA-256 / SHA-512 を許しているのは MAY(任意のオプション)で、
SHA-1 を非推奨にしてはいない。そして algorithm パラメータを無視するアプリが多いので、
変えようとしても効かない。
引っかかりやすいポイント
Section titled “引っかかりやすいポイント”- TOTP は HOTP の時刻版。アルゴリズム本体は同一(RFC 6238 は RFC 4226 を呼ぶ)
-
T = (現在の Unix 時刻 - T0) / X。既定はT0 = 0、X = 30 秒、割り算は切り捨て - カウンタは 8 バイトのビッグエンディアンに詰める
- dynamic truncation のオフセットは ハッシュの最後のバイトの下位 4 bit
- そこから 4 バイト取り、最上位ビットを落とす(31 bit にする)
- マスクの理由は符号あり / なしの mod 計算の混乱を避けるため。強度のためではない
- 最後に
mod 10^Digit - 共有秘密は 128 bit 以上が MUST、160 bit が RECOMMENDED
- 桁数は 6 桁が下限。8 桁も規格上は正しい(RFC 6238 の検証ベクタは 8 桁)
- RFC 6238 の検証ベクタは モードごとに共有秘密の長さが違う
- 時刻ずれの許容は “at most one time step” が RECOMMEND。MUST ではない
- 一度成功したコードの再利用は MUST NOT。一致したステップを記録する
- リプレイ記録は atomic に。確認してから書くと同時投入で両方通る
- 6 桁は 19.93 bit。レート制限が無いと総当たりで抜ける
- NIST は 64 bit 未満の出力にレート制限を SHALL、連続失敗は 100 回以内
- TOTP にフィッシング耐性はない(NIST SP 800-63B-4 §3.2.5 が SHALL NOT)
- SMS OTP は現行の NIST でも RESTRICTED。TOTP と同等ではない
- NIST SP 800-63B の現行版は -4(2025-07-31)。2020 年版を引かない
-
otpauth://は RFC ではない。algorithm/digits/periodは無視されうる - 共有秘密はハッシュにできない。暗号化して保存する
- HMAC-SHA-1 は危殆化していない。SHA-1 の衝突耐性の話とは別
TOTP と HOTP の違いを 1 行で。
カウンタの作り方だけが違う。 HOTP は使うたびに増える整数、
TOTP は時刻から計算した整数(T = (現在の Unix 時刻 - T0) / X)を使う。
アルゴリズム本体(HMAC → dynamic truncation → mod 10^Digit)は同一で、
RFC 6238 は RFC 4226 の HOTP() をそのまま呼んでいる。
TOTP のカウンタ `T` の式と既定値を書け。
T = (Current Unix time - T0) / X ※ 切り捨て既定は T0 = 0(Unix epoch)、X = 30 秒(RFC 6238 §4)。
T は HOTP のカウンタとして 8 バイトのビッグエンディアンに詰める。
dynamic truncation の手順を説明せよ。
RFC 4226 §5.3。HMAC-SHA-1 の出力 20 バイトに対して:
- 最後のバイト(
String[19])の下位 4 bitをオフセットとする(0〜15) - その位置から 4 バイト
Pを取り出す Pの 最上位ビットを落とす(& 0x7f)= 31 bit にするmod 10^Digit
オフセットが毎回変わるので、ハッシュの固定位置を狙った解析ができない。
最上位ビットをマスクする理由は。
符号付きと符号なしの mod 計算の混乱を避けるため(RFC 4226 §5.3)。
The reason for masking the most significant bit of P is to avoid confusion about signed vs. unsigned modulo computations.
符号付き 32 bit 整数しか持たない言語で負の数になるのを防ぐ、 実装の相互運用性のための措置。「31 bit にして強度を上げている」は誤り。
共有秘密の長さの要件は。
RFC 4226 §4 の R6:
The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits.
128 bit が MUST の下限、160 bit(= HMAC-SHA-1 の出力長)が推奨。 160 bit は Base32 でちょうど 32 文字になり、パディングも要らない。
TOTP の桁数は 6 桁と決まっているか。
決まっていない。6 桁は下限。
- RFC 4226 §4 R4 — “The HOTP value must be at least a 6-digit value”
- NIST SP 800-63B-4 — “at least six decimal digits (or equivalent)”
- RFC 6238 Appendix B の検証ベクタは 8 桁
ただし otpauth:// の digits を無視するアプリがあるので、
実務では 6 桁から動かせない。
時刻ずれの許容窓は仕様でどう定められているか。
RFC 6238 §5.2 は “We RECOMMEND that at most one time step is allowed as the network delay” — つまり RECOMMEND であって MUST ではない。
窓を広げると総当たりの成功率がその倍数で上がる。広げる代わりに、 §6 に従って利用者ごとの時計のずれを記録して補正するほうがよい。
一度使ったコードを再度受け付けてよいか。
MUST NOT(RFC 6238 §5.2)。
The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP
30 秒間同じコードが表示される以上、これを守らないと 盗み見られたコードがそのまま通る。実装は「(利用者 ID, 一致したステップ)を atomic に記録して、二度目を拒否する」。
6 桁の TOTP は何 bit 相当か。総当たりへの防御は何か。
log2(10^6) = 19.93 bit。 セッション ID に OWASP が求める 64 bit の
3 分の 1 以下しかない。
守っているのは検証側のレート制限。RFC 4226 §7.2 の式:
Sec = s * v / 10^Digit窓 3 ステップ・100 回試行・6 桁なら 3 * 100 / 10^6 = 0.03%。
NIST SP 800-63B-4 は 64 bit 未満の出力にレート制限を SHALL とし、
連続失敗を 100 回以内に制限するよう求めている。
TOTP はフィッシングに強いか。
強くない。 NIST SP 800-63B-4 §3.2.5:
Authenticators that involve manual entry of an authenticator output (e.g., out-of-band and OTP authenticators) SHALL NOT be considered phishing-resistant
偽サイトが利用者と本物のサーバーの間に立つ(AiTM)と、入力された 6 桁を 30 秒以内に本物へ転送するだけで認証が通り、セッション Cookie を奪える。 利用者から見て何もおかしくない。
フィッシング耐性を持つのは WebAuthn / パスキーで、 理由は署名の対象にアクセス先のオリジンが含まれるから。
SMS OTP と TOTP は同等か。
同等ではない。 NIST SP 800-63B-4 §3.1.3.3 は今も PSTN 経由の out-of-band 認証を restricted としており、使う前に “device swap, SIM change, number porting” などのリスク指標を考慮すべき(SHOULD)としている。
SIM スワップ・番号ポーティング・SS7 というTOTP には無い攻撃面がある。 どちらもフィッシング耐性は無い点は共通。
サーバー側は共有秘密をどう保存すべきか。なぜハッシュにできないのか。
アプリ層で暗号化して保存する(KMS などで鍵を管理)。
ハッシュにできないのは、検証のたびに HMAC を計算し直す必要があるから。
検証側が生成もできる方式は必ずこうなる(Digest 認証の
H(A1) と同じ構造)。
DB が漏れたら全員分のコードを生成できるので、 パスワードのハッシュより保護の優先度が高い。
`otpauth://` URI は標準化されているか。
されていない。 Google Authenticator の wiki による de facto 標準で、 RFC にはなっていない(IETF に individual draft はある)。
しかも algorithm / digits / period は
wiki 自身が「Google Authenticator は無視する」と明記している。
既定(SHA-1 / 6 桁 / 30 秒)から外すと利用者のアプリによっては認証が通らない。
HMAC-SHA-1 を使い続けて問題ないか。
問題ない。 SHA-1 で危殆化したのは衝突耐性で、それが効くのは署名や証明書の用途。 HMAC は衝突耐性に依存しない構成なので、HMAC-SHA-1 は現在も実用上安全とされる。
RFC 6238 §1.2 が SHA-256 / SHA-512 を認めているのは MAY(任意)で、
SHA-1 を非推奨にはしていない。そもそも algorithm を無視するアプリが多いので、
変えても効かないことが多い。
リプレイ判定の記録は何をキーにすべきか。
(利用者 ID, 実際に一致した時間ステップ)。 受信時刻ではない。
窓(T-1 / T / T+1)を使うと、受信時刻から計算したステップと
実際に一致したステップがずれる。受信時刻で記録すると、
1 ステップずれたコードを 2 回使えてしまう。
記録は atomic に行う(一意制約付き INSERT か Redis の SET NX)。
「確認してから書く」だと同時投入で両方通る。
NIST SP 800-63B の現行版はどれか。
SP 800-63B-4(2025 年 7 月 31 日発行)。 旧版の SP 800-63B(2020 年 3 月 2 日)を supersede している。
日本語の解説記事は 2017 年版(2020 年 errata)を引いているものがまだ多い。 AAL の定義もフィッシング耐性の扱いも -4 で書き直されているので、 引用元を確認する。
OCRA (RFC 6287) は HOTP に何を足したものか。
入力をカウンタ以外にも広げた一般化。
a generalization of HOTP with variable data inputs not solely based on an incremented counter and secret key values
サーバーが送るチャレンジ・時刻・PIN 検証値・トランザクション内容を入力にできるので、 **「この振込内容に対する承認コード」**のような取引ごとの署名が作れる。 時刻同期にも依存しない。金融系のハードウェアトークン向けで、Web アプリではまず使わない。
- RFC 4226 — HOTP: An HMAC-Based One-Time Password Algorithm
- RFC 6238 — TOTP: Time-Based One-Time Password Algorithm
- RFC 6287 — OCRA: OATH Challenge-Response Algorithm
- RFC 4648 — The Base16, Base32, and Base64 Data Encodings
- NIST SP 800-63B-4 — Digital Identity Guidelines: Authentication and Authenticator Management
- Key Uri Format (google/google-authenticator wiki)
- oath-toolkit (oathtool)