メインコンテンツまでスキップ

FIDO2 セキュリティ考慮事項


概要

FIDO2/WebAuthn はパスワードに比べて大幅にセキュリティが向上しますが、適切に実装・運用しないとセキュリティ上の問題が発生する可能性があります。

このドキュメントで学べること:

  • FIDO2 が防御できる攻撃と防御できない攻撃
  • signCount によるクローン検出
  • セッション管理との統合
  • 多要素認証との組み合わせ
  • 実装上の注意点

FIDO2 の脅威モデル

FIDO2 が防御できる攻撃

┌─────────────────────────────────────────────────────────────────────────────┐
│ FIDO2 が防御できる攻撃 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【フィッシング攻撃】 ✓ 防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 理由: 署名に RP の origin が含まれるため、偽サイトでは署名が無効 │
│ │
│ 攻撃者: 「偽の銀行サイト fake-bank.com にログインさせよう」 │
│ FIDO2: 署名の origin は "https://fake-bank.com" │
│ RP: origin が "https://real-bank.com" と一致しない → 拒否 │
│ │
│ 【パスワードリスト攻撃】 ✓ 防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 理由: パスワードが存在しないため、流出したパスワードリストは無意味 │
│ │
│ 【ブルートフォース攻撃】 ✓ 防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 理由: 秘密鍵は 256 ビット以上、総当たりは現実的に不可能 │
│ │
│ 【キーロガー】 ✓ 防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 理由: キーボード入力がないため、キーロガーでは認証情報を取得できない │
│ │
│ 【中間者攻撃(MITM)】 ✓ 防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 理由: TLS + origin 検証により、中間者が介入しても署名が無効 │
│ │
│ 【リプレイ攻撃】 ✓ 防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 理由: challenge がランダムで一度きり、同じ署名は再利用不可 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

FIDO2 が防御できない攻撃

┌─────────────────────────────────────────────────────────────────────────────┐
│ FIDO2 が防御できない攻撃 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【セッションハイジャック】 ✗ 防御不可 │
│ ────────────────────────────────────────────────────────────────────────── │
│ FIDO2 は認証の瞬間を保護するが、認証後のセッションは保護しない │
│ → セッショントークンが盗まれると攻撃者がなりすまし可能 │
│ 対策: セキュアなセッション管理(HttpOnly, Secure, SameSite) │
│ │
│ 【認証器の物理的盗難】 ✗ 防御不可 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 認証器を盗まれた場合、攻撃者が認証可能になる可能性 │
│ 対策: UV(ユーザー検証)の要求、PIN/生体認証の設定 │
│ │
│ 【ソーシャルエンジニアリング】 ✗ 防御不可 │
│ ────────────────────────────────────────────────────────────────────────── │
│ ユーザーを騙して正規サイトで認証させる攻撃 │
│ 例: 「アカウント確認のため、ここをクリックしてログインしてください」 │
│ 対策: ユーザー教育、トランザクション確認 │
│ │
│ 【マルウェア(デバイス侵害)】 ✗ 防御不可 │
│ ────────────────────────────────────────────────────────────────────────── │
│ デバイス自体が侵害されている場合、あらゆる保護が無効化される可能性 │
│ 対策: エンドポイントセキュリティ、デバイス信頼度の評価 │
│ │
│ 【クローン攻撃(特定条件下)】 △ 部分的に防御可能 │
│ ────────────────────────────────────────────────────────────────────────── │
│ 認証器の秘密鍵がクローンされた場合 │
│ 対策: signCount によるクローン検出(後述) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

signCount によるクローン検出

signCount とは

┌─────────────────────────────────────────────────────────────────────────────┐
│ signCount の仕組み │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ signCount は、認証器が署名を行うたびにインクリメントされるカウンター │
│ │
│ 【動作】 │
│ 1. 登録時: 認証器が signCount の初期値(通常 0 または 1)を返す │
│ 2. 認証時: 認証器が現在の signCount を返す │
│ 3. RP: 前回の signCount より大きいことを確認 │
│ │
│ 【正常なシーケンス】 │
│ │
│ 登録時 認証1回目 認証2回目 認証3回目 │
│ signCount=0 signCount=1 signCount=2 signCount=3 │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ RP保存: 0 0 < 1 ✓ OK 1 < 2 ✓ OK 2 < 3 ✓ OK │
│ RP保存: 1 RP保存: 2 RP保存: 3 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

クローン検出

┌─────────────────────────────────────────────────────────────────────────────┐
│ クローン検出の仕組み │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【シナリオ: 認証器がクローンされた場合】 │
│ │
│ 正規認証器: signCount = 5 で認証 │
│ RP: signCount = 5 を保存 │
│ │
│ クローン認証器: signCount = 3 で認証を試みる(クローン時点の値) │
│ RP: 保存値 5 > 受信値 3 → 異常を検出! │
│ │
│ 【検出パターン】 │
│ │
│ 時間軸 → │
│ │
│ 正規認証器: ──●────●────●────●────●──→ │
│ count=1 2 3 4 5 │
│ ↓ │
│ クローン作成 │
│ ↓ │
│ クローン: ─────●────●────●──→ │
│ count=3 4 5 │
│ │
│ RP での検出: │
│ - 正規が count=5 で認証 → RP は 5 を保存 │
│ - クローンが count=4 で認証 → 5 > 4 なので異常検出! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

signCount の実装

┌─────────────────────────────────────────────────────────────────────────────┐
│ signCount 検証の実装 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【検証ロジック】 │
│ │
│ if (storedSignCount > 0 || receivedSignCount > 0) { │
│ // どちらかが 0 より大きい場合、signCount をサポートしている │
│ if (receivedSignCount <= storedSignCount) { │
│ // 異常検出: クローンの可能性 │
│ handlePotentialClone(); │
│ } │
│ } │
│ // signCount を更新 │
│ updateStoredSignCount(receivedSignCount); │
│ │
│ 【異常検出時の対応オプション】 │
│ │
│ 1. 認証を拒否 │
│ - 最も安全だが、正当なユーザーをブロックする可能性 │
│ │
│ 2. 警告を出して認証は許可 │
│ - ユーザーに通知、追加の検証を要求 │
│ │
│ 3. ログに記録して監視 │
│ - 認証は許可するが、セキュリティチームに通知 │
│ │
│ 【注意】 │
│ - 一部の認証器は signCount をサポートしていない(常に 0) │
│ - 同期 Passkey は signCount が正確でない場合がある │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

signCount の制限

認証器タイプsignCount サポート備考
ハードウェアキー(YubiKey等)✓ サポート正確にインクリメント
プラットフォーム認証器✓ サポートデバイスごとに管理
同期 Passkey△ 不正確な場合あり同期により複数デバイスで値がずれる
一部の古い認証器✗ 非サポート常に 0

セッション管理との統合

認証後のセッション保護

┌─────────────────────────────────────────────────────────────────────────────┐
│ FIDO2 とセッション管理 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【FIDO2 が保護する範囲】 │
│ │
│ 認証リクエスト 認証処理 認証完了 セッション利用 │
│ │ │ │ │ │
│ ────────┼──────────────┼──────────────┼──────────────┼─────────→ │
│ │ │ │ │ │
│ └──────────────┴──────────────┘ │ │
│ FIDO2 の保護範囲 │ │
│ │ │
│ ここからは RP の責任 │
│ │
│ 【セッション管理のベストプラクティス】 │
│ │
│ 1. セッショントークンの保護 │
│ - HttpOnly: JavaScript からアクセス不可 │
│ - Secure: HTTPS 通信でのみ送信 │
│ - SameSite=Strict または Lax: CSRF 対策 │
│ │
│ 2. セッションの有効期限 │
│ - 適切なタイムアウト設定 │
│ - アイドルタイムアウト │
│ │
│ 3. セッション固定攻撃対策 │
│ - 認証成功後にセッション ID を再生成 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

重要操作時の再認証

┌─────────────────────────────────────────────────────────────────────────────┐
│ Step-up 認証(再認証) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【コンセプト】 │
│ 重要な操作を行う前に、再度 FIDO2 認証を要求する │
│ │
│ 【再認証を要求すべき操作の例】 │
│ - パスワードの変更 │
│ - メールアドレスの変更 │
│ - 新しい認証器の登録 │
│ - 高額の送金・決済 │
│ - アカウント削除 │
│ - 個人情報の表示・変更 │
│ │
│ 【実装例】 │
│ │
│ // セッションに最終認証時刻を保存 │
│ session.lastAuthTime = Date.now(); │
│ │
│ // 重要操作前にチェック │
│ function requireRecentAuth(maxAgeMinutes = 5) { │
│ const elapsed = Date.now() - session.lastAuthTime; │
│ if (elapsed > maxAgeMinutes * 60 * 1000) { │
│ // 再認証を要求 │
│ return redirectToReauth(); │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

多要素認証との組み合わせ

FIDO2 と MFA

┌─────────────────────────────────────────────────────────────────────────────┐
│ FIDO2 は多要素認証か? │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【認証の3要素】 │
│ - 知識(Something you know): パスワード、PIN │
│ - 所持(Something you have): 認証器、スマートフォン │
│ - 生体(Something you are): 指紋、顔 │
│ │
│ 【FIDO2 認証器の要素】 │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ 認証器タイプ │ 所持要素 │ 追加要素 │ │
│ ├─────────────────────────────────────────────────────────────────────┤ │
│ │ セキュリティキー(UV無)│ ✓ │ なし(単一要素) │ │
│ │ セキュリティキー(PIN)│ ✓ │ 知識(PIN)= 2要素 │ │
│ │ プラットフォーム認証器 │ ✓ │ 生体 or PIN = 2要素 │ │
│ │ (Touch ID, Face ID 等) │ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 【結論】 │
│ - UV(User Verification)を要求する FIDO2 認証は、単体で 2要素認証 │
│ - UV なしの場合は単一要素(所持のみ) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

追加要素との組み合わせ

組み合わせセキュリティユーザビリティ用途
FIDO2 のみ(UV あり)一般的なユースケース
FIDO2 + パスワード非常に高移行期間、高セキュリティ
FIDO2 + OTP非常に高追加の保証が必要な場合
FIDO2 + リスクベース認証動的なセキュリティ

userVerification の設定

// 認証リクエストでの userVerification 設定
const options = {
publicKey: {
challenge: challenge,
userVerification: "required" // "required" | "preferred" | "discouraged"
}
};
意味推奨ケース
requiredUV 必須、失敗したら認証拒否高セキュリティ、2FA が必要
preferredUV を試みるが、失敗しても続行一般的なケース
discouragedUV を行わないUV 不要の特殊ケース

実装上の注意点

Challenge の生成

┌─────────────────────────────────────────────────────────────────────────────┐
│ Challenge 生成のベストプラクティス │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【要件】 │
│ - 暗号学的に安全な乱数生成器を使用 │
│ - 最低 16 バイト(128 ビット)以上 │
│ - 一度使用したら破棄(再利用禁止) │
│ - 有効期限を設定(例: 5分) │
│ │
│ 【良い例】 │
│ // Java │
│ SecureRandom random = new SecureRandom(); │
│ byte[] challenge = new byte[32]; │
│ random.nextBytes(challenge); │
│ │
│ // Node.js │
│ const challenge = crypto.randomBytes(32); │
│ │
│ 【悪い例】 │
│ // 予測可能な値 │
│ String challenge = "challenge-" + System.currentTimeMillis(); // NG │
│ │
│ // 短すぎる │
│ byte[] challenge = new byte[8]; // NG: 64ビットは短い │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Origin の検証

┌─────────────────────────────────────────────────────────────────────────────┐
│ Origin 検証の重要性 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【clientDataJSON に含まれる origin】 │
│ { │
│ "type": "webauthn.get", │
│ "challenge": "...", │
│ "origin": "https://example.com", // ← 検証が必要 │
│ "crossOrigin": false │
│ } │
│ │
│ 【検証ルール】 │
│ 1. origin が RP の origin と完全一致することを確認 │
│ 2. スキーム(https://)、ホスト、ポートを含めて検証 │
│ 3. 許可された origin のリストと照合(複数 origin の場合) │
│ │
│ 【注意】 │
│ - サブドメインの自動許可は危険 │
│ NG: *.example.com を許可 │
│ OK: auth.example.com を明示的に許可 │
│ │
│ - ポート番号の違いは別 origin │
│ https://example.com:443 ≠ https://example.com:8443 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

rpId の設定

┌─────────────────────────────────────────────────────────────────────────────┐
│ rpId の設計 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【rpId とは】 │
│ - クレデンシャルが紐づくドメイン │
│ - 登録時に指定し、認証時に一致が必要 │
│ │
│ 【有効な rpId】 │
│ origin: https://auth.example.com │
│ │
│ 有効な rpId: │
│ - "auth.example.com" (完全一致) │
│ - "example.com" (親ドメイン) ← 複数サブドメインで共有する場合 │
│ │
│ 無効な rpId: │
│ - "other.example.com" (兄弟ドメイン) │
│ - "com" (TLD) │
│ - "sub.auth.example.com" (より詳細なサブドメイン) │
│ │
│ 【設計の考慮事項】 │
│ │
│ 1. 単一ドメインの場合 │
│ rpId = origin のホスト名と同じ │
│ │
│ 2. 複数サブドメインで共有する場合 │
│ rpId = 親ドメイン │
│ 例: auth.example.com と app.example.com で共有 │
│ → rpId = "example.com" │
│ │
│ 3. 将来の拡張を考慮 │
│ 最初から親ドメインを rpId にしておくと、後から変更不要 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

エラーハンドリング

エラー原因対応
NotAllowedErrorユーザーが操作をキャンセルユーザーに再試行を促す
InvalidStateError認証器が既に登録済み(登録時)既存クレデンシャルの確認
NotSupportedError認証器がサポートしていない別の認証方法を案内
SecurityErrorセキュリティ要件を満たさないHTTPS 環境か確認
AbortErrorタイムアウト再試行を促す

監視とログ

監視すべきメトリクス

メトリクス説明アラート条件
認証成功率成功 / 試行急激な低下
signCount 異常クローン検出数発生時
UV 失敗率生体認証の失敗急激な上昇
新規登録数期間あたりの登録異常な増加
認証器タイプ分布AAGUID ごとの割合未知の認証器の急増

セキュリティログ

┌─────────────────────────────────────────────────────────────────────────────┐
│ 記録すべきログ項目 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 【認証イベント】 │
│ - タイムスタンプ │
│ - ユーザー ID │
│ - クレデンシャル ID(ハッシュ化推奨) │
│ - 結果(成功/失敗) │
│ - 失敗理由 │
│ - signCount │
│ - IP アドレス │
│ - User-Agent │
│ │
│ 【登録イベント】 │
│ - タイムスタンプ │
│ - ユーザー ID │
│ - AAGUID │
│ - Attestation タイプ │
│ - transports │
│ │
│ 【異常イベント】 │
│ - signCount 異常(クローン疑い) │
│ - 未知の AAGUID │
│ - Attestation 検証失敗 │
│ │
│ 【注意】 │
│ - 秘密鍵、署名データはログに記録しない │
│ - 個人情報の取り扱いに注意(GDPR 等) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

参考リンク