メインコンテンツへスキップ

Authenticatorアプリの仕組み — MFAの中のTOTPを自作する

· loading · loading ·
kiitosu
著者
kiitosu
aws community builder. 画像処理やデバイスドライバ、データ基盤構築からWebバックエンドまで、多様な領域に携わってきました。地図解析や地図アプリケーションの仕組みにも経験があり、幅広い技術を活かした開発に取り組んでいます。休日は草野球とランニングを楽しんでいます。
目次

はじめに
#

ログインのたびに使う、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:alice@example.com?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コード」を自分で再現してみる流れです。

  1. 適当な共有秘密を Base32 で生成(普段はサービス側がランダムに作る部分)
  2. otpauth://totp/...?secret=... のQRコードを作り、Google Authenticator で読み取って登録
  3. 同じ共有秘密を渡した自作スクリプトの出力と、Authenticatorアプリの表示が一致することを確認
QR生成の補足
Pythonなら 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全体のしくみを腹落ちさせる近道した。

参考
#

Reply by Email

関連記事

Claude Codeに教わった `--force-if-includes`
Next.js hydration mismatch 対処法の決定木
· loading · loading
CloudWatch Pipelinesを使ってみた