コンテンツにスキップ

TOTP と HOTP

TOTP と HOTP — RFC 6238 / RFC 4226

  • 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” と明記している。 パスワード漏洩には強いが、偽サイトへの入力には無力。
文書 位置づけ この記事で扱う範囲
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:// の書式

「要素」は 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 が価値を持つのは、「知識」とは独立に盗む必要があるから。

変わるのはカウンタの作り方だけ。 ハッシュも切り出し方も同じ。

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 のほうが安全に見えるが、根拠は「有効期間が短い」ことだけ。 アルゴリズムの強度は同じ。

実値で全段追う。 共有秘密は 20 バイト(160 bit)の乱数とする。 すべて手元で再現できる。

サーバーが乱数を生成し、QR コードで利用者の認証アプリへ渡す

形式
生バイト(16 進) 4a7c1f93e05b28d6a1f4370c9e58bd2617ac40f3(20 バイト = 160 bit)
Base32(RFC 4648) JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHT(32 文字)
ターミナルウィンドウ
# 20 バイトの乱数を Base32 にする
openssl rand 20 | base32
# JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHT

Base32 を使うのは、大文字と数字だけで手入力できるから。 A-Z2-7 の 33 文字(= を含む)で 1 文字 5 bit。 20 バイト = 160 bit はちょうど 32 文字になり、パディングが要らない

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 = 59633333

T は HOTP のカウンタとして 8 バイトのビッグエンディアンに詰める。

59633333 -> 0x00000000038DEEB5

30 秒のあいだ T は変わらない。 だから同じコードが 30 秒間表示される。

HMAC-SHA-1(K, C) = 986fb9860648f89691632816cfd3e97e75635e4c
(20 バイト)

ここが 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 にすることで強度を上げている」と説明している記事があるが違う。

毎回ハッシュの違う場所から切り出す。 固定位置から取ると、 その 4 バイトだけを狙った解析ができてしまう。

前後の時間ステップも計算してみると、切り出し位置が毎回変わっているのが分かる。

ステップ コード
T - 1 = 59633332 066024
T = 59633333 287934
T + 1 = 59633334 677107

連続する 3 つのコードに何の関係もない。 1 つ漏れても次は予測できない。

opensslxxd だけで組める。 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 338314
254676 287922 162583 399871 520489

TOTP にするなら、カウンタを時刻から作るだけ。

ターミナルウィンドウ
totp() { hotp "$1" $(( $(date +%s) / 30 )); }
totp "$KEY"

自分の実装が正しいかはこの表で確かめる。 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 桁と決まっているわけではない(後述)。

「任意の長さでよい」は誤り。 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 の読み取りに失敗した利用者が 自分のアカウントから締め出される

認証アプリの画面。6 桁のコードと残り時間のカウントダウンが並ぶ

利用者が見るのはこれだけ。 残り時間の輪は「30 秒で変わる」を可視化したもので、 仕様上の要素ではない。

otpauth://totp/devnote:taro@example.com
?secret=JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHT
&issuer=devnote
&algorithm=SHA1
&digits=6
&period=30

Google 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 のときのみ必須

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 は、認証が成功したときにその端末のずれを記録しておくことを勧めている。 利用者ごとにオフセットを覚えておけば、窓を広げずに済む。

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, 成功したステップ番号)を記録して、 同じ組み合わせを二度受け付けなければよい。窓を使ったなら 実際に一致したステップを記録する(受信時刻ではない)。

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 を入れたことで安心してしまう。

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

偽サイトが利用者と本物のサーバーの間に入るだけで、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 / パスキー。 署名の対象にアクセスしているサイトのオリジンが含まれるので、 偽サイト向けの署名は本物のサーバーで検証に落ちる。

方式 要素 フィッシング耐性 サーバーが持つ秘密 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 “使ってよいケースと使ってはいけないケース”
  • サーバーと端末の時計が NTP で合っている(ずれを窓で吸収しない)
  • 共有秘密が 128 bit 以上(推奨 160 bit)の乱数である
  • 共有秘密を暗号化して保存している(平文で持たない)
  • リプレイを拒否している(成功したステップを記録する)
  • レート制限がある(連続失敗 100 回以内で止める)
  • リカバリコードを発行し、端末紛失に備えている
  • フィッシング耐性は無いと理解したうえで採用している

ひとつでも欠けたら、TOTP を入れた意味が大きく減る。 特に 4 つ目と 5 つ目が欠けると、6 桁の数字を総当たりされるだけになる。

TOTP が成立する 6 つの条件を 1 枚にまとめた復習カード

試験前に見返すならこの 1 枚。 コーラルで示したレート制限は、 無いとアルゴリズムの強度が無関係になる唯一の項目。

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
ケース 成立する理由 一緒にやること
パスワード認証への 2 要素追加 パスワードリスト攻撃と DB 漏洩の両方を止められる リカバリコード。レート制限
管理画面・SSH の踏み台 利用者が限られ、端末を用意させられる できれば WebAuthn。TOTP は移行期間の措置
WebAuthn を主とする構成の代替手段 端末を持たない利用者の受け皿になる SMS より TOTP を代替に置く
社内システムでコストをかけずに強化 サーバー側の実装が小さく、ライブラリで済む 共有秘密の暗号化。時計の同期
オフライン環境 通信が不要。時計さえ合っていれば動く 時計のずれの記録(RFC 6238 §6)
ケース できないこと
フィッシング対策として導入する NIST が SHALL NOT で否定。AiTM で素通りする
レート制限なしで運用する 6 桁 = 19.93 bit。総当たりで抜ける
リプレイを許す RFC 6238 §5.2 の MUST NOT 違反
共有秘密を平文で保存する DB が漏れたら全員分のコードを生成できる
共有秘密を 80 bit などにする RFC 4226 §4 の MUST(128 bit 以上)違反
digitsperiod を既定から変える 主要アプリが無視するので認証が通らなくなる
パスワードの代わりに使う TOTP 単独は AAL1 相当。多要素にならない
リカバリ手段を用意せずに必須化する 端末紛失で全員が締め出される
種別
認証アプリ Google Authenticator、Microsoft Authenticator、Authy、1Password、Bitwarden
サーバー側ライブラリ otplib / speakeasy(Node.js)、pyotp(Python)、rotp(Ruby)
CLI oathtoolbrew install oath-toolkit
SaaS Auth0、Okta、Keycloak(いずれも標準機能として持つ)
ターミナルウィンドウ
# oathtool で検証する(自作実装の答え合わせに使う)
oathtool --totp -b JJ6B7E7ALMUNNIPUG4GJ4WF5EYL2YQHT

ライブラリを使うのが正解だが、何をやっているかは押さえておく。

import crypto from 'node:crypto';
const STEP = 30; // RFC 6238 の既定 X
const 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 つ。

  1. 比較は timingSafeEqual === は先頭から違いが出た位置でタイミングが変わる
  2. 戻り値をステップ番号にする。 リプレイ判定に「受信時刻」ではなく 実際に一致したステップを使う
  3. drift を引数にして、利用者ごとの時計ずれを記録して補正する(RFC 6238 §6)
  4. 入力の桁数を先に検証する。 timingSafeEqual は長さが違うと例外を投げる
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 を使う。

方法 評価
平文 不可。 DB 漏洩で全員分のコードを生成できる
ハッシュ 不可能。 検証のたびに HMAC の計算が要る
アプリ層で暗号化(KMS / envelope encryption) これを使う
DB の透過的暗号化のみ 不十分。DB へのアクセス権があれば読める

パスワードと違ってハッシュにできないのが TOTP の構造的な弱点。 Digest 認証でサーバーが H(A1) を持たざるを得なかったのと同じで、 検証側が生成もできる方式には必ずこの性質がある。

端末の紛失は必ず起きる。 対策を用意せずに必須化すると、 サポートコストが跳ね上がる。

手段 注意
リカバリコード(使い捨て 8〜10 個) 1 回使ったら消す。 ハッシュして保存する
予備の認証アプリを登録させる 同じ共有秘密を複数端末に入れることになる
本人確認を経た手動リセット ここが最も攻撃される。 ソーシャルエンジニアリングの標的

リカバリコードはパスワードと同じ扱いで保存する(ハッシュ + ソルト)。 使い捨てなので、こちらはハッシュにできる。

扱い
共有秘密 / otpauth:// URI 絶対に残さない。 URI 全体が秘密
入力された 6 桁 残さない。 30 秒有効なので即座に悪用できる
一致したステップ番号 残してよい(リプレイ判定に必要)
失敗回数 残す

QR コードの画像も秘密。スクリーンショットを添付したサポート問い合わせが チケットシステムに残る、というのが現実に起きる事故。

「SHA-1 が壊れているから TOTP は危ない」は誤り。

SHA-1 で問題になったのは衝突耐性(同じハッシュになる 2 つの入力を作れる)で、 これが効くのは署名や証明書の用途。HMAC は衝突耐性に依存しない構成なので、 HMAC-SHA-1 は現在も実用上安全とされている。

RFC 6238 §1.2 が SHA-256 / SHA-512 を許しているのは MAY(任意のオプション)で、 SHA-1 を非推奨にしてはいない。そして algorithm パラメータを無視するアプリが多いので、 変えようとしても効かない。

  • TOTP は HOTP の時刻版。アルゴリズム本体は同一(RFC 6238 は RFC 4226 を呼ぶ)
  • T = (現在の Unix 時刻 - T0) / X。既定は T0 = 0X = 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 バイトに対して:

  1. 最後のバイト(String[19])の下位 4 bitをオフセットとする(0〜15)
  2. その位置から 4 バイト P を取り出す
  3. P最上位ビットを落とす& 0x7f)= 31 bit にする
  4. 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 / periodwiki 自身が「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 アプリではまず使わない。