はじめに #
ログインのたびに使う、Google Authenticator や Microsoft Authenticator の 6桁コード。あれは、なぜネット接続なしで端末から出せるのに、サーバ側と一致するんでしょうか。
仕組みを分解してみると、コア計算は Pythonで20行程度 に収まる驚くほど小さなアルゴリズムでした。本記事では、
- MFAという広い文脈の中で TOTP(Time-based One-Time Password) がどこに位置するか
- TOTPの中身(HMAC + 時間窓)の分解
- 20行で自作して Google Authenticator と数字が一致することの確認
- TOTPの強みと限界、Passkey(WebAuthn)への流れ
- Authenticatorアプリ選びでのセキュリティ観点
の順で整理します。
前提: HMACって何?(既知の人はスキップ可)
TOTPの中身に入る前に、計算の中核にいる HMAC を短く押さえます。
HMACは 「共有秘密 + メッセージ」 → 固定長のバイト列(タグ) を出す関数。よく「鍵付きハッシュ」と呼ばれます。
HMAC( 共有秘密 K , メッセージ m ) → タグ(HMAC-SHA256 なら 32バイト)
ただのハッシュ(SHA-256など)との違いはひとつ。
| 入力 | 計算できる人 | |
|---|---|---|
SHA256(m) |
メッセージだけ | 誰でも |
HMAC(K, m) |
メッセージ + 共有秘密 K |
K を知っている人だけ |
HMACはハッシュ関数そのものではなく、ハッシュ関数を決まった手順で2回呼ぶ「使い方のレシピ」。H の部分を SHA-256 に差し替えれば HMAC-SHA256、SHA-1 に差し替えれば HMAC-SHA1 になります。
HMAC(K, m) = H( (K' XOR opad) || H( (K' XOR ipad) || m ) )
↑ ↑
外側用の鍵 内側用の鍵
|| は 連結(バイト列を後ろにくっつける) の意味で、論理ORではありません。2回ハッシュするのは、SHA-1/SHA-256 にある 長さ拡張攻撃 を塞ぐため(単純に H(K || m) だと、K を知らない攻撃者がタグを後ろに伸ばして偽造できてしまう)。
RFC 2104(1997年)で標準化されてから30年近く現役で、
- JWT の HS256 系署名
- AWS Signature v4 のAPIリクエスト署名
- GitHub / Stripe / Slack のWebhook検証
- PBKDF2 によるパスワードからの鍵導出
- TLS 1.2 までのレコード認証、TLS 1.3 の HKDF 内部
など、Web上の身元確認のあちこちで使われています。
押さえるポイントはひとつ。「K を知っている2者だけが、同じメッセージから同じタグを作れる」 という非対称性。この性質が、次章以降の「ネット接続なしでサーバとクライアントが同じ6桁を出せる」しくみの土台になります。
公開鍵署名(RSA/ECDSA)との違い
HMACは 対称鍵(送受信が同じ鍵)。公開鍵署名は 非対称鍵(署名者だけが秘密鍵を持つ)。HMACは「2者間で改竄を防ぐ」、公開鍵署名は「第三者にも『私が署名した』と証明する(否認防止)」と用途が分かれます。HMACのほうが速くて軽いので、TOTP・Webhook・APIキー認証のような2者間用途で使われます。
1. MFAの全体像 — TOTPの位置づけ #
MFA(Multi-Factor Authentication)は、認証要素を 複数のカテゴリ から組み合わせる発想です。代表的な3要素:
| 要素 | 例 |
|---|---|
| 知識(Something you know) | パスワード、PIN |
| 所持(Something you have) | スマホ、ハードウェアキー |
| 生体(Something you are) | 指紋、顔、虹彩 |
パスワードだけだと「知識」一要素。流出・使い回し・フィッシングに弱い。これに 所持 要素を足すのがMFAの基本形です。
主な「所持」要素の比較 #
| 方式 | 仕組み | フィッシング耐性 | オフライン動作 | 備考 |
|---|---|---|---|---|
| SMS OTP | サーバが乱数を生成しSMSで送る | × | × | SIMスワップ攻撃に弱い |
| TOTP(Authenticatorアプリ) | 共有秘密 + 時刻からクライアントとサーバが同じコードを生成 | △ | ◎ | 本記事の主役 |
| Push通知 | サーバから「承認しますか?」が飛ぶ | △〜○ | × | MFA fatigue攻撃あり |
| WebAuthn / Passkey | 公開鍵暗号でドメイン縛りの署名 | ◎ | △ | 署名計算自体はローカルだが、サーバからのchallenge受信が必須なので端末完全オフラインでは動かない |
TOTPは 「ネット不要・サーバ側はライブラリ追加だけ」というデプロイの軽さ で広く使われています。一方、フィッシングサイトに人間が6桁を打ち込んでしまえば突破される(後述)。
2. TOTPの中身 #
TOTP は RFC 6238(HOTP = RFC 4226 の時刻拡張)。構成要素はわずか3つです。
K… 共有秘密(QRコードで配布される)T… カウンタ。floor(unix_time / 30)(30秒窓)digest = HMAC-SHA1(K, T)を末尾4バイトから6桁に圧縮
flowchart LR K["共有秘密 K
(MFA登録時のQRで配布済み)"] --> H Time["現在時刻 → T = floor(t/30)"] --> H H["HMAC-SHA1(K, T)"] --> X["dynamic truncation
→ 31bit 整数"] X --> M["mod 10^6"] M --> Code["6桁コード"]
なぜサーバとクライアントで一致するのか #
ここで言うQRコードは、サービスでMFAを有効化するときに画面に表示される MFA登録用のQRコード(「Authenticatorアプリでスキャンしてください」と促されるあれ)のことです。
MFA登録時のQRコードをスキャンした瞬間に、共有秘密 K をサーバとアプリの両者が持つ 状態になります。あとは
- 入力 = 共有秘密 + 現在時刻
- 出力 = 同じ関数(HMAC-SHA1 → truncation)
なので、時計がだいたい合っていれば一致します。サーバ側は前後1〜2窓を許容して照合するのが普通。
MFA登録用QRコードの中身 #
このQRコードに入っているのは otpauth:// で始まるURIです。
otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Example&period=30&digits=6&algorithm=SHA1
secret… 共有秘密(Base32)period… 時間窓(秒)digits… 桁数algorithm… ハッシュ関数
つまり MFA登録の初回だけ、この鍵をサーバからアプリに渡している だけ。以降の通信は不要で、6桁の照合はオフラインで成立します。
3. 自作してみる(20行) #
Pythonで TOTP を実装し、Google Authenticator と数字が一致することを確認してみます。
import base64, hmac, hashlib, time
def totp(secret_b32: str, t: int | None = None, digits: int = 6, period: int = 30) -> str:
key = base64.b32decode(secret_b32, casefold=True)
counter = (t if t is not None else int(time.time())) // period
msg = counter.to_bytes(8, "big")
digest = hmac.new(key, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F
code = int.from_bytes(digest[offset:offset+4], "big") & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
# RFC 6238 のテストベクタ(K = "12345678901234567890")で確認
SECRET = base64.b32encode(b"12345678901234567890").decode()
print(totp(SECRET, t=59)) # 94287082
print(totp(SECRET, t=1111111109)) # 07081804
コードを行ごとに読む
インポート
すべてPython標準ライブラリ。base64(共有秘密のBase32デコード)、hmac(HMAC計算)、hashlib(SHA-1)、time(UNIX時刻)。追加インストール不要なのがTOTPの軽さを象徴しています。
1. 共有秘密のデコード
key = base64.b32decode(secret_b32, casefold=True)
otpauth:// URIの secret= で渡されるBase32文字列をバイト列に戻します。Base32が選ばれているのは、0/O などの混同が起きにくく手入力しやすいから。casefold=True は大文字小文字を区別しない保険。これがHMACの 鍵 K。
2. カウンタの計算
counter = (t if t is not None else int(time.time())) // period
UNIX時刻を30で割り捨てる。// はPythonの floor除算演算子 で、math.floor(t / period) と等価(だから floor() 関数の呼び出しは出てきません)。30秒ごとに counter が1増えるので、サーバとアプリで時計が合っていれば両者で同じ値になります。
3. カウンタを8バイト big-endian に詰める
msg = counter.to_bytes(8, "big")
counter(整数)を 8バイトのbig-endian(ネットワークバイト順)バイト列 に変換。RFC 4226 が「カウンタは64bit整数」と決めているので8バイト固定。これがHMACに渡す メッセージ m。
4. HMAC-SHA1 を計算
digest = hmac.new(key, msg, hashlib.sha1).digest()
共有秘密 key とメッセージ msg から 20バイトのダイジェスト が出ます。ここまでで「秘密と時刻から確定的なバイト列が出る」部分は完成。残るは20バイトを6桁に縮める処理。
5. Dynamic Truncation(RFC 4226 の仕様)
offset = digest[-1] & 0x0F
code = int.from_bytes(digest[offset:offset+4], "big") & 0x7FFFFFFF
digest[-1] & 0x0F: 最後のバイトの下位4ビット(0〜15)をoffsetに。切り出し位置を入力依存で動かす のが「dynamic」の由来。出力の偏りを避ける工夫digest[offset:offset+4]: そこから4バイト切り出すint.from_bytes(..., "big"): その4バイトをbig-endianの符号なし整数として解釈& 0x7FFFFFFF: 最上位ビットを落として31bit整数に。Javaなど符号付き整数で扱う言語との互換性のためのRFC 4226のレガシー
6. 6桁にトリム
return str(code % (10 ** digits)).zfill(digits)
100万で割った余りで6桁に圧縮。zfill(6) で左を0埋め(結果が 42 なら "000042")。サーバは6桁文字列で比較するので桁を揃えないと一致しません。
テストベクタについて
print(totp(SECRET, t=59)) # 94287082
コメントの 94287082 は RFC 6238 Appendix B の 8桁版テストベクタ。デフォルトの digits=6 で呼ぶと下6桁の 287082 が出るので、RFC通りに完全一致を見たければ totp(SECRET, t=59, digits=8) で呼びます。
Google Authenticator と一致させる手順 #
「サービスがMFA登録時に表示するQRコード」を自分で再現してみる流れです。
- 適当な共有秘密を Base32 で生成(普段はサービス側がランダムに作る部分)
otpauth://totp/...?secret=...のQRコードを作り、Google Authenticator で読み取って登録- 同じ共有秘密を渡した自作スクリプトの出力と、Authenticatorアプリの表示が一致することを確認
QR生成の補足
qrcode ライブラリで otpauth:// URI を直接エンコードすれば、Google Authenticator はそのまま読み取れます。サービス側が登録画面で出しているQRと完全に同じ仕組みです。
4. TOTPの強みと限界 #
強み #
- 依存が少ない: HMAC-SHA1 と時計だけ。サーバはライブラリ追加だけで済む
- オフライン動作: 登録後は通信不要
- 既存パスワードフローへの追加が容易
限界 #
- 共有秘密の漏洩リスク: サーバDBが漏れると全ユーザーのコードが第三者でも生成可能
- フィッシング耐性が低い: 偽サイトに6桁を打ち込めば即座にリレー攻撃される
- デバイス紛失時のリカバリが面倒: バックアップコードや別端末でのセットアップが必要
TOTPからPasskey(WebAuthn)への流れ #
- Passkey はドメイン単位の公開鍵暗号 → フィッシングサイトに署名は出ない
- 共有秘密が存在しないので、サーバDB漏洩で他サイトに被害が広がらない
- 対応サイト・対応OS/ブラウザがあれば、TOTPより 原理的に 強い
「MFA = TOTPで十分」ではなく、「TOTPは導入の軽さで使うが、Passkey対応が広がったら積極的に乗り換える」が今のスタンスです。
5. Authenticatorアプリの選び方 — 1stパーティ vs 3rdパーティ #
TOTPはオープンな標準(RFC 6238)なので、正しく実装すればどのアプリでも動きます。ただし「動く」と「安心して使える」は別の話。Authenticatorアプリは 共有秘密 K をそのまま保管している存在 であり、K が漏れた瞬間に攻撃者は同じ6桁コードを永久に生成できます(ユーザーが秘密を再発行するまで)。
つまりアプリ選びでは、次の3点すべてを信頼することになります。
- アプリ本体 — コードのバグ・悪意のある実装が混じっていないか
- 保管先 — 端末ローカルだけか、クラウドに同期されるか、その暗号化は十分か
- 配布経路 — ストアの正規アプリか、依存ライブラリのサプライチェーンに穴はないか
1stパーティ(Google / Microsoft / Apple)の特徴 #
| アプリ | バックアップ | 備考 |
|---|---|---|
| Google Authenticator | Googleアカウントへ同期(2023〜) | 同期開始時にE2E暗号化なしと指摘され議論になった経緯あり |
| Microsoft Authenticator | Microsoftアカウントで暗号化バックアップ | 業務利用ならEntra ID連携が便利 |
| iCloud Keychain | iCloudで同期 | iOS 15以降、設定アプリにTOTPがネイティブ統合 |
ベンダー信頼度とOS統合が長所。一方で 「そのベンダーのアカウント」が新たな単一故障点 になる点は要注意(Googleアカウントが乗っ取られると同期されたTOTPがまとめて流出する)。
3rdパーティで考えるべきリスク #
| リスク | 具体例 |
|---|---|
| 秘密の外部送信 | 出自不明のアプリがテレメトリに紛れ込ませて送信 |
| クラウド同期の暗号化強度 | Authyは2022年のTwilio侵害で約3,300万件の電話番号が流出。TOTP秘密自体は守られたが攻撃面は増えた |
| ストアでの偽装 | 「Google Authenticator」を騙る偽アプリ。アイコンと名前だけそっくりにする手口 |
| 開発停止・買収 | メンテが止まったアプリで脆弱性が放置される |
最後に #
Authenticatorアプリの6桁コードの正体は、共有秘密 + 現在時刻 + HMAC-SHA1 のたった3つの合成。仕組みを理解すると、TOTPでできること・できないことが見えてきます。
- ネット不要なのは「サーバが送る」のではなく「両者が同じ関数で計算する」から
- フィッシング耐性が低いのは「人間が6桁を打ち込む」前提だから
- Passkeyに置き換わっていく流れは、この限界を解消するため
TOTPの自作はMFA全体のしくみを腹落ちさせる近道した。