TOTPは「時刻を使うHOTP」
TOTP(Time-based One-Time Password)はRFC 6238で定義された方式です。その土台はRFC 4226のHOTPで、イベントごとに増えるカウンターの代わりに、現在時刻から求めた値を使います。
TOTP = HOTP(K, T)
K: 認証アプリとサーバーが共有する秘密鍵
T: 現在時刻から求めるタイムカウンター
QRコードによる登録は計算そのものではなく、秘密鍵や桁数、更新間隔、ハッシュ方式などを端末へ安全に設定するための手段です。登録後は認証アプリとサーバーが同じ秘密鍵を持ち、ネットワーク通信をしなくてもそれぞれ同じコードを計算できます。
4ステップの全体像
flowchart LR A[現在時刻] -- "30秒単位" --> B[タイムカウンター T] K[共有秘密 K] --> C[HMAC] B -- "8バイト・ビッグエンディアン" --> C C -- "20バイト / SHA-1" --> D[Dynamic Truncation] D -- "31ビット整数" --> E[mod 10^6] E --> F[6桁コード]
Step 1:現在時刻をカウンターへ変換する
まず、現在のUNIX時間を更新間隔 X で割り、小数点以下を切り捨てます。RFC 6238の既定値は、開始時刻 T0 = 0、更新間隔 X = 30 秒です。
T = floor((UnixTime - T0) / X)
例: UnixTime = 1,725,000,029 のとき
T = floor(1,725,000,029 / 30)
= 57,500,000
同じ30秒枠では T が変わりません。次の境界に達すると T が1増え、以降のHMAC結果も変わります。計算へ渡すときは、この整数を符号なし64ビットのビッグエンディアン、つまり必ず8バイトに変換します。
Step 2:共有秘密を鍵にしてHMACを計算する
8バイトにした T をHMACのメッセージ、共有秘密 K をHMACの鍵として計算します。単に2つの値を文字列結合する処理ではありません。
HS = HMAC-SHA-1(K, T_as_8_bytes)
HMAC-SHA-1なら結果 HS は20バイト(160ビット)です。秘密鍵や時刻が少しでも変われば、結果は大きく変化します。RFC 6238はHMAC-SHA-256とHMAC-SHA-512にも対応していますが、利用側と検証側は同じ方式を選ぶ必要があります。一般的な互換性の既定値はSHA-1です。
なぜHMAC-SHA-2ではないのか
TOTPの仕様であるRFC 6238は、SHA-1だけでなくSHA-2ファミリーのSHA-256とSHA-512も正式にサポートしています。それでも多くのサービスでHMAC-SHA-1が使われ続けている理由は、HMACに対するSHA-1の安全性と、認証アプリとの歴史的な互換性にあります。
HMACではSHA-1の衝突攻撃がそのまま問題にならない
単体のハッシュ関数としてのSHA-1には実用的な衝突攻撃が知られており、新しいデジタル署名や証明書などでは使うべきではありません。しかし、TOTPはデータをSHA-1へ直接入力するのではなく、秘密鍵を使うHMAC-SHA-1を利用します。
通常のSHA-1に対する既知の衝突攻撃から、HMACの秘密鍵を求めたり、有効なTOTPを予測したりできるわけではありません。そのため、SHA-1の衝突耐性が破られたことだけを理由に、HMAC-SHA-1を使うTOTPが直ちに危険になるわけではありません。
さらに、TOTPはHMACの長い出力を最終的に6桁などへ縮めます。オンライン攻撃に対する強さは主にコードの桁数、短い有効時間、試行回数制限によって決まるため、HMAC-SHA-256へ変更するだけで6桁コードの総当たり耐性が大幅に上がるわけではありません。
歴史的な後方互換性が高い
TOTPの土台となるHOTPが2005年に標準化されたとき、HMAC-SHA-1が基準として採用されました。その後に普及した初期の認証アプリやサービスでもSHA-1を前提とする実装が多く、長い間「SHA-1なら幅広い利用環境で動く」という状態が続きました。
サービス側だけをSHA-256へ変更しても、認証アプリ側が同じアルゴリズムに対応し、登録情報からそれを正しく読み取れなければコードは一致しません。そのため、不特定多数の認証アプリを受け入れるサービスでは、現在も互換性を優先してSHA-1を既定値にすることがあります。
| 比較項目 | HMAC-SHA-1を用いたTOTP | HMAC-SHA-256を用いたTOTP |
|---|---|---|
| 仕様上の扱い | 標準的な既定値 | RFC 6238で正式にサポート |
| 実用上の安全性 | TOTP用途では実用上十分とされる | ハッシュ関数としてはより余裕があるが、6桁コードのオンライン総当たり耐性は変わらない |
| アプリの対応 | 幅広い認証アプリが対応 | 古いアプリや実装では非対応の可能性がある |
Step 3:Dynamic Truncationで31ビットを取り出す
HMACの結果をそのまま表示するには長すぎます。そこでHOTPが定めるDynamic Truncation(動的切り詰め)を使い、結果中の位置を毎回動的に決めて4バイトを取り出します。
- HMAC結果の最後の1バイトから、下位4ビットを読む
- 得られた0〜15の値を
offsetとする HS[offset]から連続する4バイトを取り出す- 先頭ビットを0にして、符号なしの31ビット整数にする
offset = HS[19] & 0x0f
binary = ((HS[offset] & 0x7f) << 24)
| (HS[offset + 1] << 16)
| (HS[offset + 2] << 8)
| HS[offset + 3]
SHA-1の結果は20バイトで、offsetの最大値は15です。0始まりの15番目から4バイトを読んでも、使うのは18番目までなので範囲内に収まります。先頭へ 0x7f のマスクを掛けるのは、最上位ビットを落として常に非負の31ビット整数として扱うためです。
Step 4:Modulo演算で6桁へ収める
取り出した31ビット整数を 10の桁数乗 で割り、その余りを採用します。6桁なら 10^6 = 1,000,000 です。
otp = binary mod 1,000,000
code = otpを6桁になるまで左側ゼロ埋め
例: otp = 42 → "000042"
Modulo演算により結果は必ず0〜999999に収まります。先頭のゼロもコードの一部なので、数値ではなく固定長の文字列として扱うのが安全です。8桁なら割る値を 10^8 に変えます。
Pythonで4ステップを実装する
Pythonの標準ライブラリだけで、処理の対応関係を確認できます。実サービスでは自作実装を認証判断に使わず、十分に検証されたTOTPライブラリを利用してください。
from __future__ import annotations
import hashlib
import hmac
import struct
import time
def generate_totp(
secret: bytes,
*,
timestamp: int | None = None,
step: int = 30,
digits: int = 6,
) -> str:
if step <= 0:
raise ValueError("stepは正の整数にしてください")
if digits not in (6, 7, 8):
raise ValueError("digitsは6〜8桁にしてください")
# Step 1: UNIX時間からタイムカウンターを作る
now = int(time.time()) if timestamp is None else timestamp
counter = now // step
counter_bytes = struct.pack(">Q", counter)
# Step 2: 秘密鍵を鍵、カウンターをメッセージにしてHMACを計算
digest = hmac.new(secret, counter_bytes, hashlib.sha1).digest()
# Step 3: Dynamic Truncationで31ビット整数を取り出す
offset = digest[-1] & 0x0F
binary = int.from_bytes(digest[offset:offset + 4], "big") & 0x7FFFFFFF
# Step 4: 指定桁数へ収め、左側を0で埋める
otp = binary % (10 ** digits)
return f"{otp:0{digits}d}"
# RFC 6238 Appendix BのSHA-1テストベクトル
test_secret = b"12345678901234567890"
print(generate_totp(test_secret, timestamp=59, digits=8))
# 94287082
実際のQRコードでは秘密鍵がBase32文字列として格納されることが一般的です。その場合はBase32をデコードして得たバイト列を secret に渡します。Base32の文字列そのものをHMACの鍵にすると、正しいコードにはなりません。
Swagger UIでTOTP APIを試す
このサイトでは、サーバーの環境変数 TOTP_SECRET に文字列として設定したシークレットから、90秒間隔のTOTPを発行するAPIを公開しています。APIの仕様確認とリクエストの実行は、Swagger UI(/api/v1/docs)から行えます。
Swagger UIで GET /api/v1/secret/TOTP を開き、Try it out、Executeの順に選択します。シークレットはサーバー側から読み込むため、リクエスト本文やURLパラメーターは不要です。
レスポンスには、コードの有効期間も含まれます。UNIX時間は開始を含み、終了を含まない区間です。JSTの時刻はISO 8601形式で表示されます。
{
"totp": "114913",
"algorithm": "SHA1",
"digits": 6,
"period": 90,
"valid_for": 90,
"valid_from_unix": 1788134400,
"valid_until_unix": 1788134490,
"valid_from_jst": "2026-08-31T09:00:00+09:00",
"valid_until_jst": "2026-08-31T09:01:30+09:00"
}
TOTP_SECRETの値そのものはレスポンスへ含まれません。ただし、このGETエンドポイントへアクセスできる人は現在のTOTPを取得できます。公開環境には実運用中の認証用シークレットを設定せず、動作確認にはテスト専用の文字列を使用してください。サーバーはどうやって検証するのか
サーバーも保存している共有秘密と現在時刻からTOTPを計算し、受信したコードと比較します。ただし端末とサーバーの時計は完全には一致しないため、現在の時間枠に加えて前後1枠など、限定した範囲を許容する設計があります。
| 検証時の注意 | 理由 |
|---|---|
| 定数時間比較 | hmac.compare_digest() などを使い、通常の文字列比較によるタイミング差を避ける |
| 試行回数制限 | 6桁は100万通りしかないため、レート制限や一時ロックでオンライン総当たりを防ぐ |
| 許容枠を広げすぎない | 前後の時間枠を多く受け付けるほど、有効なコードと攻撃可能な時間が増える |
| 再利用を検知 | 同じ時間枠で一度成功したコードを再び受け付けない設計を検討する |
| 秘密鍵を保護 | 共有秘密が漏れると、攻撃者も任意の時刻のコードを生成できる |
TOTPが守れること、守れないこと
TOTPは秘密鍵をネットワークへ送らず、短時間だけ有効なコードで本人確認を補強できます。一方、表示されたコードそのものは秘密であり、有効時間内にフィッシングサイトへ入力するとリアルタイムに中継される可能性があります。
また、6桁コードだけを見て共有秘密を現実的に逆算することはできませんが、コード自体の候補は100万通りです。安全性はHMACだけでなく、試行回数制限、秘密鍵の保存、時刻同期、再利用防止を含む認証フロー全体で確保します。フィッシング耐性が必要な場面では、パスキーやFIDO2/WebAuthnの利用も検討します。
まとめ
TOTPの6桁コードは、現在時刻を30秒単位のカウンターへ変換し、共有秘密を鍵とするHMACを計算し、Dynamic Truncationで31ビットを抜き出し、Modulo演算で固定桁へ収めることで完成します。端末とサーバーは秘密鍵と時刻を共有しているからこそ、通信なしで同じ結果を再現できます。
仕様の詳細とテストベクトルは、RFC 6238(TOTP) と、切り詰め処理の基礎となる RFC 4226(HOTP) を参照してください。