<p><!--META
{
  "title": "FIDO2/WebAuthnによるパスワードレス認証のセキュリティモデルと対策",
  "primary_category": "セキュリティ",
  "secondary_categories": ["認証","Web技術"],
  "tags": ["FIDO2","WebAuthn","パスワードレス","セキュリティモデル","公開鍵暗号"],
  "summary": "FIDO2/WebAuthnによるパスワードレス認証の脅威モデル、攻撃シナリオ、プロトコルレベルおよびRPサーバーサイドの対策、運用上の注意点をセキュリティエンジニアの視点から解説。",
  "mermaid": true,
  "verify_level": "L0",
  "tweet_hint": {"text":"FIDO2/WebAuthnのセキュリティモデルと対策について、脅威モデル、攻撃チェーン、具体的なコード例、運用の落とし穴まで解説しました。パスワードレス認証導入の際に役立つ情報です。","hashtags":["#FIDO2","#WebAuthn","#セキュリティ"]},
  "link_hints": ["https://www.w3.org/TR/webauthn-3/","https://fidoalliance.org/fido2/","https://owasp.org/www-project-web-security-testing-guide/v42/4-Web_Application_Security_Testing/09-Client-side_Testing/11-Testing_for_WebAuthn_Vulnerabilities"]
}
-->
本記事は<strong>Geminiの出力をプロンプト工学で整理した業務ドラフト(未検証)</strong>です。</p>
<h1 class="wp-block-heading">FIDO2/WebAuthnによるパスワードレス認証のセキュリティモデルと対策</h1>
<p>FIDO2/WebAuthnは、公開鍵暗号方式を利用してパスワードに依存しないセキュアな認証メカニズムを提供する標準規格です。従来のパスワード認証が抱えるフィッシング、ブルートフォース攻撃、認証情報漏洩といった脆弱性からの脱却を目指しますが、その導入と運用にはFIDO2/WebAuthn特有の脅威モデルと対策の理解が不可欠です。本記事では、実務家のセキュリティエンジニアの視点から、FIDO2/WebAuthnのセキュリティモデル、攻撃シナリオ、検出/緩和策、および運用上の注意点について解説します。</p>
<h2 class="wp-block-heading">脅威モデルと攻撃シナリオ</h2>
<p>FIDO2/WebAuthnは、設計段階で多くのセキュリティ脅威を考慮しています。主要な脅威モデルとそれに基づく攻撃シナリオを以下に示します。</p>
<ul class="wp-block-list">
<li><p><strong>フィッシング</strong>: ユーザーが正規サイトと誤認する偽サイトで認証を試みる攻撃です。WebAuthnは、Originバインディングという仕組みにより、登録時に公開鍵が特定のWebサイト(Origin)に紐付けられるため、このOrigin情報が検証されない限り認証器は署名を行いません。これにより、偽サイトでの認証を原則として防止します。</p>
<ul>
<li><strong>シナリオ</strong>: 攻撃者は正規サイトに酷似したフィッシングサイト(例: <code>example.com</code> の代わりに <code>examp1e.com</code>)を構築し、ユーザーを誘導します。ユーザーが偽サイトで認証を試みると、認証器は偽サイトのOrigin(<code>examp1e.com</code>)を検出し、正規のOrigin(<code>example.com</code>)と一致しないため、認証を拒否します。</li>
</ul></li>
<li><p><strong>中間者攻撃(Man-in-the-Middle, MitM)</strong>: 攻撃者がクライアントとRP(Relying Party: サービス提供者)サーバー間の通信を傍受・改ざんする攻撃です。</p>
<ul>
<li><strong>シナリオ</strong>: 攻撃者がユーザーとRPサーバー間に割り込み、通信を中継・改ざんしようとします。FIDO2/WebAuthnは、TLS/SSLによる通信経路の保護と、WebAuthnプロトコル自体がOriginバインディングを利用することで、偽のRPに署名済み認証アサーションが渡ることを防ぎます。</li>
</ul></li>
<li><p><strong>リプレイ攻撃</strong>: 攻撃者が過去に取得した認証応答(署名済みアサーション)を再利用して認証を試みる攻撃です。</p>
<ul>
<li><strong>シナリオ</strong>: 攻撃者が正規の認証フロー中に傍受した認証アサーションを記録し、後日それをRPサーバーに送信して認証を試みます。FIDO2/WebAuthnでは、RPサーバーが生成する<strong>チャレンジ</strong>と呼ばれる一意のランダム値と、認証器が保持する<strong>認証カウンタ(signCount)</strong>を検証することで、リプレイ攻撃を防ぎます。</li>
</ul></li>
<li><p><strong>認証器の盗難/紛失</strong>: 認証器(物理デバイスやソフトウェアパスキー)が盗難または紛失した場合の脅威です。</p>
<ul>
<li><strong>シナリオ</strong>: 認証器が盗難され、攻撃者がそれを物理的に入手します。FIDO2認証器は、PIN、生体認証(指紋、顔)などによる<strong>ユーザー検証(User Verification, UV)</strong>を必須とするため、認証器が盗まれてもユーザー検証なしには秘密鍵を操作できません。しかし、ユーザー検証を突破された場合、不正利用のリスクがあります。</li>
</ul></li>
<li><p><strong>バックエンドサーバー侵害(RPサーバー)</strong>: RPサーバーが侵害され、保存されているユーザー情報や公開鍵などが漏洩する脅威です。</p>
<ul>
<li><strong>シナリオ</strong>: RPサーバーのデータベースが侵害され、ユーザーの公開鍵、認証カウンタ、登録された認証器のIDなどが漏洩します。FIDO2/WebAuthnでは、秘密鍵は認証器内に安全に保管され、RPサーバーには決して送信されないため、サーバー侵害によって秘密鍵が直接漏洩することはありません。しかし、漏洩した公開鍵情報を元に、攻撃者が他の攻撃を試みる可能性があります。</li>
</ul></li>
</ul>
<h2 class="wp-block-heading">FIDO2/WebAuthnにおける攻撃チェーン</h2>
<p>一般的な攻撃チェーンにFIDO2/WebAuthnの防御機構を組み込むと、攻撃がどのように緩和されるかを可視化できます。</p>
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid"><pre class="mermaid">
graph TD
    A["攻撃者"] -->|フィッシングサイト構築| B("偽装RPサイト")
    B -->|ユーザーを誘導| C{"ユーザーが偽サイトにアクセス"}
    C -->|認証要求を偽装| D["偽RPサイトがWebAuthn認証フローを開始"]
    D --X 不正なOrigin/RpId --> E{"認証器による保護: Origin/RpId不一致で認証拒否"}
    E -->|Origin/RpIdが一致した場合 (例: 認証器の脆弱性) | F("認証情報窃取を試行")
    F -->|チャレンジ再利用/signCount無視| G{"RPサーバーの防御: チャレンジ/signCount検証で拒否"}
    G -->|成功した場合 (RPサーバーの検証不備)| H["正規RPサイトへの不正ログイン"]
    subgraph FIDO2/WebAuthnによる防御層
        I["正規RPサイト"] -->|ユニークなチャレンジ発行| J{"WebAuthn認証フロー"}
        J -->|Origin/RpId検証| K["認証器"]
        K -->|ユーザー検証 (PIN/生体認証)| L["秘密鍵で署名"]
        L -->|署名付きアサーション| I
        I -->|署名検証, チャレンジ検証, signCount検証| M("認証成功")
    end
    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#f9f,stroke:#333,stroke-width:2px
    style F fill:#f9f,stroke:#333,stroke-width:2px
    style H fill:#f9f,stroke:#333,stroke-width:2px
    style E fill:#ada,stroke:#333,stroke-width:2px
    style G fill:#ada,stroke:#333,stroke-width:2px
    style K fill:#ada,stroke:#333,stroke-width:2px
    style L fill:#ada,stroke:#333,stroke-width:2px
    style M fill:#ada,stroke:#333,stroke-width:2px
</pre></div>
<h2 class="wp-block-heading">検出と緩和策</h2>
<h3 class="wp-block-heading">認証プロトコルレベルの対策</h3>
<p>FIDO2/WebAuthnは、以下のプロトコルレベルの機構によりセキュリティを担保します。</p>
<ol class="wp-block-list">
<li><p><strong>フィッシング耐性</strong>:</p>
<ul>
<li><p><strong>Originバインディング</strong>: 認証器は、認証リクエストの発生元(Origin)が、秘密鍵の登録時に紐付けられたOriginと一致するかを厳密に検証します。不一致の場合、認証は行われません。これはフィッシングサイトからの認証を防止する最も強力な防御機構です。</p></li>
<li><p><strong>ユーザーの存在証明(User Presence, UP)</strong>: 認証操作の際に、物理的なボタン操作や生体認証など、ユーザーが認証器の前に実際に存在することを確認します。これにより、バックグラウンドでの不正な認証を防ぎます [W3C – WebAuthn Level 3, 2024年3月5日, W3C]。</p></li>
</ul></li>
<li><p><strong>中間者攻撃耐性</strong>:</p>
<ul>
<li><p><strong>TLS/SSL</strong>: クライアントとRPサーバー間の通信は常にTLS/SSLで保護され、通信の機密性と完全性が確保されます。</p></li>
<li><p><strong>Originバインディング</strong>: 前述の通り、認証アサーションが正規のRPサーバー以外の第三者に渡ることを防ぎます。</p></li>
</ul></li>
<li><p><strong>リプレイ攻撃耐性</strong>:</p>
<ul>
<li><p><strong>チャレンジ-レスポンス</strong>: RPサーバーは認証ごとに一意で予測不可能な<strong>チャレンジ</strong>を生成し、認証アサーションに署名するよう認証器に要求します。RPサーバーは受け取ったアサーション内のチャレンジが、自身が発行したものと一致し、かつ一度も使われていないことを検証します [W3C – WebAuthn Level 3, 2024年3月5日, W3C]。</p></li>
<li><p><strong>認証カウンタ(signCount)</strong>: 認証器は認証を行うたびに内部カウンタ(signCount)をインクリメントし、その値を認証アサーションに含めてRPサーバーに送信します。RPサーバーは、保存している過去のsignCountよりも現在のsignCountが必ず大きいことを検証します。これにより、古いアサーションのリプレイを防ぎます [OWASP – WSTG WebAuthn, 2023年10月11日, OWASP]。</p></li>
</ul></li>
<li><p><strong>認証器のセキュリティ</strong>:</p>
<ul>
<li><p><strong>秘密鍵の保護</strong>: FIDO2認証器(ハードウェアセキュリティキー、TPM、Secure Enclaveなど)は、秘密鍵をセキュアな領域に保管し、決して外部にエクスポートしません。鍵操作は認証器内部で行われます。</p></li>
<li><p><strong>ユーザー検証(User Verification, UV)</strong>: PIN、指紋、顔認証などの生体認証により、認証器の正当な所有者であることを確認します。これにより、認証器が盗難されても容易に不正利用されることはありません [FIDO Alliance – FIDO2, 最新, FIDO Alliance]。</p></li>
</ul></li>
</ol>
<h3 class="wp-block-heading">RPサーバーサイドの実装における注意点(誤用例と安全な代替)</h3>
<p>FIDO2/WebAuthnのセキュリティを最大限に活かすためには、RPサーバー側の実装がプロトコル仕様に厳密に従う必要があります。誤った実装は深刻な脆弱性を招きます。</p>
<ul class="wp-block-list">
<li><p><strong>署名検証</strong>:</p>
<ul>
<li><p><strong>誤用例</strong>: 受信した認証アサーションの署名が検証されない、または不適切に検証される。</p></li>
<li><p><strong>安全な代替</strong>: 認証器から送られてきた公開鍵を用いて、アサーションの署名を暗号的に検証する。これはWebAuthnライブラリが通常自動的に行いますが、その結果を必ず確認すること。</p></li>
</ul></li>
<li><p><strong>チャレンジ検証</strong>:</p>
<ul>
<li><p><strong>誤用例</strong>: RPサーバーが発行したチャレンジが、受信したアサーション内のチャレンジと一致しない、または使い回される。</p></li>
<li><p><strong>安全な代替</strong>: RPサーバーは、認証リクエストごとに<strong>ユニークで十分なエントロピーを持つチャレンジ</strong>を生成し、セッションに紐付けて保存する。アサーション受信時には、そのチャレンジが現在有効なものと一致し、かつ一度しか使用されていないことを厳密に検証する。
<div class="codehilite">
<pre data-enlighter-language="generic"># RPサーバーサイドでのチャレンジ生成(安全な代替)</pre></div></p></li>
</ul>
<p><span class="kn">import</span><span class="w"> </span><span class="nn">os</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">base64</span></p>
<p><span class="k">def</span><span class="w"> </span><span class="nf">generate_challenge</span><span class="p">():</span>
<span class="w"> </span><span class="sd">“””ユニークでランダムなチャレンジを生成する”””</span>
<span class="n">challenge_bytes</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">urandom</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span> <span class="c1"># 32バイト = 256ビットのチャレンジ</span>
<span class="k">return</span> <span class="n">base64</span><span class="o">.</span><span class="n">urlsafe_b64encode</span><span class="p">(</span><span class="n">challenge_bytes</span><span class="p">)</span><span class="o">.</span><span class="n">decode</span><span class="p">(</span><span class="s1">‘utf-8’</span><span class="p">)</span><span class="o">.</span><span class="n">rstrip</span><span class="p">(</span><span class="s1">‘=’</span><span class="p">)</span></p>
<p><span class="c1"># 認証フロー</span></p>
<p><span class="c1"># 1. ユーザーが認証を開始</span></p>
<p><span class="n">challenge</span> <span class="o">=</span> <span class="n">generate_challenge</span><span class="p">()</span></p>
<p><span class="c1"># challengeをセッションに保存(例: session[‘webauthn_challenge’] = challenge)</span></p>
<p><span class="c1"># クライアントにchallengeを送信</span></p>
<p><span class="c1"># 2. クライアントからの認証アサーションを受信</span></p>
<p><span class="n">received_challenge_b64</span> <span class="o">=</span> <span class="o">…</span> <span class="c1"># クライアントから受信したチャレンジ</span>
<span class="n">stored_challenge_b64</span> <span class="o">=</span> <span class="o">…</span> <span class="c1"># セッションに保存していたチャレンジ</span></p>
<p><span class="c1"># チャレンジが一致し、かつ一度だけ使用されたことを検証</span></p>
<p><span class="k">if</span> <span class="n">received_challenge_b64</span> <span class="o">!=</span> <span class="n">stored_challenge_b64</span><span class="p">:</span>
<span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">“Challenge mismatch or replay attack detected.”</span><span class="p">)</span></p>
<p><span class="c1"># sessionからchallengeを削除し、再利用を防ぐ</span>
</p></li>
<li><p><strong>Origin/RpId検証</strong>:</p>
<ul>
<li><p><strong>誤用例</strong>: 受信したアサーション内の<code>clientDataJSON.origin</code>や<code>authenticatorData.rpIdHash</code>が、RPサーバーの期待する値と一致しないにもかかわらず認証を許可する。</p></li>
<li><p><strong>安全な代替</strong>: RPサーバーは、<code>clientDataJSON.origin</code>が自身のOriginと一致すること、および<code>authenticatorData.rpIdHash</code>が自身のRP IDのハッシュと一致することを厳密に検証する。
<div class="codehilite">
<pre data-enlighter-language="generic"># RPサーバーサイドでのOrigin/RpId検証(安全な代替)</pre></div></p></li>
</ul>
<p><span class="c1"># 例: py_webauthnライブラリを使用した場合の検証(簡略化)</span></p>
<p><span class="kn">from</span><span class="w"> </span><span class="nn">webauthn</span><span class="w"> </span><span class="kn">import</span> <span class="n">verify_authentication_response</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">webauthn.helpers.cose</span><span class="w"> </span><span class="kn">import</span> <span class="n">COSEAlgorithmIdentifier</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">webauthn.helpers.structs</span><span class="w"> </span><span class="kn">import</span> <span class="p">(</span>
<span class="n">AttestedCredentialData</span><span class="p">,</span>
<span class="n">CredentialPublicKey</span><span class="p">,</span>
<span class="n">AuthenticatorData</span><span class="p">,</span>
<span class="n">CollectedClientData</span><span class="p">,</span>
<span class="n">AuthenticationCredential</span><span class="p">,</span>
<span class="n">PublicKeyCredentialDescriptor</span><span class="p">,</span>
<span class="n">AuthenticationResponse</span><span class="p">,</span>
<span class="p">)</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">hashlib</span></p>
<p><span class="c1"># … 前提として、ユーザーのcredential_id, public_key, sign_countがDBに保存されているとする …</span></p>
<p><span class="k">def</span><span class="w"> </span><span class="nf">verify_webauthn_assertion</span><span class="p">(</span>
<span class="n">authentication_response</span><span class="p">:</span> <span class="n">AuthenticationResponse</span><span class="p">,</span>
<span class="n">rp_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
<span class="n">expected_origin</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
<span class="n">stored_challenge</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
<span class="n">stored_credential_id</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span>
<span class="n">stored_public_key_pem</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="c1"># 例: PEM形式の公開鍵</span>
<span class="n">stored_sign_count</span><span class="p">:</span> <span class="nb">int</span>
<span class="p">):</span>
<span class="w"> </span><span class="sd">“””</span>
<span class="sd">    WebAuthn認証アサーションを検証する。</span></p>
<p><span class="sd">    – authentication_response: クライアントから受信したAuthenticationResponseオブジェクト</span></p>
<p><span class="sd">    – rp_id: 期待するRP ID (例: “example.com”)</span></p>
<p><span class="sd">    – expected_origin: 期待するOrigin (例: “https://example.com”)</span></p>
<p><span class="sd">    – stored_challenge: セッションに保存されたチャレンジ</span></p>
<p><span class="sd">    – stored_credential_id: ユーザーに紐づくCredential ID (bytes)</span></p>
<p><span class="sd">    – stored_public_key_pem: ユーザーに紐づく公開鍵 (PEM形式)</span></p>
<p><span class="sd">    – stored_sign_count: ユーザーに紐づく前回のsignCount</span>
<span class="sd">    “””</span></p>
<p><span class="c1"># 1. 公開鍵のロード(実際のライブラリではより複雑)</span></p>
<p><span class="c1"># ここではPEM形式からの変換例。実際のライブラリはより抽象化されている。</span></p>
<p><span class="c1"># credential_public_key = CredentialPublicKey.from_pem(stored_public_key_pem)</span></p>
<p><span class="c1"># 2. RP IDハッシュの検証</span></p>
<p><span class="c1"># ライブラリが内部でrpIdHashとrp_idを比較するはず</span></p>
<p><span class="c1"># 3. ClientDataJSON.originの検証</span></p>
<p><span class="c1"># ライブラリが内部でcollected_client_data.originとexpected_originを比較するはず</span></p>
<p><span class="c1"># 4. チャレンジの検証</span></p>
<p><span class="c1"># ライブラリが内部でcollected_client_data.challengeとstored_challengeを比較するはず</span></p>
<p><span class="c1"># 5. 署名と認証器データの検証</span></p>
<p><span class="c1"># 通常、ライブラリのverify関数がこれらの検証を一括で行う</span></p>
<p><span class="n">verified_response</span> <span class="o">=</span> <span class="n">verify_authentication_response</span><span class="p">(</span>
<span class="n">credential</span><span class="o">=</span><span class="n">authentication_response</span><span class="p">,</span>
<span class="n">expected_challenge</span><span class="o">=</span><span class="n">stored_challenge</span><span class="p">,</span>
<span class="n">expected_origin</span><span class="o">=</span><span class="n">expected_origin</span><span class="p">,</span>
<span class="n">rp_id</span><span class="o">=</span><span class="n">rp_id</span><span class="p">,</span>
<span class="n">credential_public_key</span><span class="o">=</span><span class="n">stored_public_key_pem</span><span class="p">,</span> <span class="c1"># または適切な公開鍵オブジェクト</span>
<span class="n">stored_sign_count</span><span class="o">=</span><span class="n">stored_sign_count</span><span class="p">,</span>
<span class="n">require_user_verification</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="c1"># ユーザー検証必須の場合</span>
<span class="n">require_user_presence</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="c1"># ユーザー存在証明必須の場合</span>
<span class="p">)</span></p>
<p><span class="c1"># 6. signCountの検証</span></p>
<p><span class="c1"># ライブラリが内部で行うが、検証結果から新しいsignCountを取得し、DBを更新する必要がある</span></p>
<p><span class="k">if</span> <span class="n">verified_response</span><span class="o">.</span><span class="n">new_sign_count</span> <span class="o"><=</span> <span class="n">stored_sign_count</span><span class="p">:</span>
<span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">“Sign count did not increment. Replay attack or faulty authenticator.”</span><span class="p">)</span></p>
<p><span class="c1"># 認証成功。DBのsignCountをverified_response.new_sign_countで更新</span></p>
<p><span class="k">return</span> <span class="kc">True</span>
</p>
<ul>
<li><strong>計算量とメモリ</strong>: 上記の検証処理は主に公開鍵暗号の署名検証とハッシュ計算に依存し、O(1)の計算量で完了します。メモリ使用量も少なく、数ミリ秒で実行されます。</li>
</ul></li>
<li><p><strong>認証カウンタ(signCount)の検証</strong>:</p>
<ul>
<li><p><strong>誤用例</strong>: <code>authenticatorData.signCount</code>を保存せず、または受信した<code>signCount</code>が保存されている値以下でも認証を許可する。</p></li>
<li><p><strong>安全な代替</strong>: RPサーバーは、各ユーザーの各認証器について最新の<code>signCount</code>を安全に保存し、認証アサーションを受信するたびに、<strong>受信した<code>signCount</code>が保存されている<code>signCount</code>よりも厳密に大きい</strong>ことを検証する。検証後、DBの<code>signCount</code>を新しい値に更新する。</p></li>
</ul></li>
<li><p><strong>鍵/秘匿情報の取り扱い</strong>:</p>
<ul>
<li><p><strong>公開鍵と秘密鍵の分離</strong>: FIDO2/WebAuthnのセキュリティモデルの根幹は、公開鍵はRPサーバーに保存されるが、秘密鍵は決して認証器から外部に出ない点です。RPサーバーが侵害されても秘密鍵は流出しません。</p></li>
<li><p><strong>公開鍵の保存と保護</strong>: RPサーバーは、ユーザーの公開鍵、Credential ID、最新のsignCountなどをデータベースに保存します。これらの情報は、ユーザーを識別し、認証を検証するために不可欠です。</p>
<ul>
<li><p><strong>最小権限</strong>: これらの情報にアクセスできるのは、WebAuthn認証を処理するモジュールのみに限定すべきです。</p></li>
<li><p><strong>暗号化</strong>: データベース内の公開鍵情報は、可能であればAES-256などの強力なアルゴリズムで暗号化して保存することが望ましいです。</p></li>
<li><p><strong>バックアップ</strong>: 公開鍵情報はユーザー認証の根幹となるため、適切なバックアップ戦略が必要です。</p></li>
</ul></li>
</ul></li>
</ul>
<h2 class="wp-block-heading">運用対策</h2>
<p>FIDO2/WebAuthnを安全に運用するためには、プロトコルレベルの対策だけでなく、組織的な運用体制も重要です。</p>
<ul class="wp-block-list">
<li><p><strong>鍵/秘匿情報のローテーション</strong>:</p>
<ul>
<li>FIDO2の秘密鍵は認証器内に固定されるため、従来のパスワードのようにサーバーがローテーションを強制する仕組みはありません。しかし、認証器の紛失やセキュリティ懸念がある場合、ユーザーは<strong>既存の認証器を解除し、新しい認証器を登録する(公開鍵の再登録)</strong>ことで、実質的な鍵ローテーションを行います。RPは、ユーザーが容易に認証器を管理できるインターフェースを提供すべきです。</li>
</ul></li>
<li><p><strong>最小権限の原則</strong>:</p>
<ul>
<li>WebAuthn認証を処理するRPサーバーコンポーネントやデータベースへのアクセスは、最小限の必要な権限のみを与えるべきです。例えば、公開鍵情報が保存されたデータベースは、他の機密情報とは分離し、アクセス制御を厳格化します。</li>
</ul></li>
<li><p><strong>監査とログ</strong>:</p>
<ul>
<li><p>認証成功/失敗、認証器の登録/解除、<code>signCount</code>の異常な値(減少、増分なし)など、WebAuthn関連のすべてのイベントを詳細にログに記録し、監査可能な状態を保ちます。異常なイベントはセキュリティアラートとして監視システムに連携します。</p></li>
<li><p><strong>検出遅延</strong>: 不正アクセスや異常な認証試行をリアルタイムで検出するために、ログの集約と監視は必須です。検出遅延はインシデント対応に大きな影響を与えるため、ログの迅速な処理と分析が求められます。</p></li>
</ul></li>
<li><p><strong>認証器のライフサイクル管理</strong>:</p>
<ul>
<li><p><strong>紛失/盗難時の対応</strong>: ユーザーが認証器を紛失または盗難された場合、迅速にその認証器を無効化できるリカバリフローが必要です。バックアップ認証器や、多要素認証による本人確認プロセスを確立することが推奨されます。</p></li>
<li><p><strong>リカバリ方法</strong>: 認証器が利用できない場合のリカバリ方法は、セキュリティと利便性のトレードオフが生じやすい「現場の落とし穴」です。SMS認証やEメール認証など、他の認証手段を用いるリカバリフローは、FIDO2のフィッシング耐性を弱める可能性があるため、慎重に設計し、複数のリカバリオプション(例: バックアップパスキー、リカバリコード、既存のデバイスによる復旧)を提供することが望ましいです [Google Developers – Passkeys, 最新, Google]。</p></li>
</ul></li>
<li><p><strong>誤検知/検出遅延/可用性トレードオフ</strong>:</p>
<ul>
<li>厳格すぎるセキュリティ対策は、正当なユーザーのアクセスを妨げる(誤検知、可用性の低下)可能性があります。例えば、<code>signCount</code>のわずかな不一致でも認証を拒否すると、稀な同期ずれでユーザーがロックアウトされるかもしれません。しかし、緩すぎるとセキュリティリスクが高まります。これらのバランスを慎重に検討し、リスクベースのアプローチで運用ポリシーを策定することが重要です。</li>
</ul></li>
</ul>
<h2 class="wp-block-heading">まとめ</h2>
<p>FIDO2/WebAuthnは、パスワードレス認証の強力なフレームワークを提供し、多くの従来型攻撃からの防御に優れています。しかし、そのポテンシャルを最大限に引き出すためには、プロトコル仕様への厳密な準拠、RPサーバー側の安全な実装、そして強固な運用体制が不可欠です。チャレンジ、Origin、RpId、signCountなどの各要素の検証を怠ると、フィッシング耐性やリプレイ攻撃耐性が失われ、脆弱なシステムになりかねません。実務のセキュリティエンジニアは、これらの脅威と対策を深く理解し、常に最新のベストプラクティスに従ってシステムを設計・運用することが求められます。</p>
本記事はGeminiの出力をプロンプト工学で整理した業務ドラフト(未検証)です。
FIDO2/WebAuthnによるパスワードレス認証のセキュリティモデルと対策
FIDO2/WebAuthnは、公開鍵暗号方式を利用してパスワードに依存しないセキュアな認証メカニズムを提供する標準規格です。従来のパスワード認証が抱えるフィッシング、ブルートフォース攻撃、認証情報漏洩といった脆弱性からの脱却を目指しますが、その導入と運用にはFIDO2/WebAuthn特有の脅威モデルと対策の理解が不可欠です。本記事では、実務家のセキュリティエンジニアの視点から、FIDO2/WebAuthnのセキュリティモデル、攻撃シナリオ、検出/緩和策、および運用上の注意点について解説します。
  
脅威モデルと攻撃シナリオ
FIDO2/WebAuthnは、設計段階で多くのセキュリティ脅威を考慮しています。主要な脅威モデルとそれに基づく攻撃シナリオを以下に示します。
- フィッシング: ユーザーが正規サイトと誤認する偽サイトで認証を試みる攻撃です。WebAuthnは、Originバインディングという仕組みにより、登録時に公開鍵が特定のWebサイト(Origin)に紐付けられるため、このOrigin情報が検証されない限り認証器は署名を行いません。これにより、偽サイトでの認証を原則として防止します。 - 
- シナリオ: 攻撃者は正規サイトに酷似したフィッシングサイト(例: example.comの代わりにexamp1e.com)を構築し、ユーザーを誘導します。ユーザーが偽サイトで認証を試みると、認証器は偽サイトのOrigin(examp1e.com)を検出し、正規のOrigin(example.com)と一致しないため、認証を拒否します。
 
- 中間者攻撃(Man-in-the-Middle, MitM): 攻撃者がクライアントとRP(Relying Party: サービス提供者)サーバー間の通信を傍受・改ざんする攻撃です。 - 
- シナリオ: 攻撃者がユーザーとRPサーバー間に割り込み、通信を中継・改ざんしようとします。FIDO2/WebAuthnは、TLS/SSLによる通信経路の保護と、WebAuthnプロトコル自体がOriginバインディングを利用することで、偽のRPに署名済み認証アサーションが渡ることを防ぎます。
 
- リプレイ攻撃: 攻撃者が過去に取得した認証応答(署名済みアサーション)を再利用して認証を試みる攻撃です。 - 
- シナリオ: 攻撃者が正規の認証フロー中に傍受した認証アサーションを記録し、後日それをRPサーバーに送信して認証を試みます。FIDO2/WebAuthnでは、RPサーバーが生成するチャレンジと呼ばれる一意のランダム値と、認証器が保持する認証カウンタ(signCount)を検証することで、リプレイ攻撃を防ぎます。
 
- 認証器の盗難/紛失: 認証器(物理デバイスやソフトウェアパスキー)が盗難または紛失した場合の脅威です。 - 
- シナリオ: 認証器が盗難され、攻撃者がそれを物理的に入手します。FIDO2認証器は、PIN、生体認証(指紋、顔)などによるユーザー検証(User Verification, UV)を必須とするため、認証器が盗まれてもユーザー検証なしには秘密鍵を操作できません。しかし、ユーザー検証を突破された場合、不正利用のリスクがあります。
 
- バックエンドサーバー侵害(RPサーバー): RPサーバーが侵害され、保存されているユーザー情報や公開鍵などが漏洩する脅威です。 - 
- シナリオ: RPサーバーのデータベースが侵害され、ユーザーの公開鍵、認証カウンタ、登録された認証器のIDなどが漏洩します。FIDO2/WebAuthnでは、秘密鍵は認証器内に安全に保管され、RPサーバーには決して送信されないため、サーバー侵害によって秘密鍵が直接漏洩することはありません。しかし、漏洩した公開鍵情報を元に、攻撃者が他の攻撃を試みる可能性があります。
 
FIDO2/WebAuthnにおける攻撃チェーン
一般的な攻撃チェーンにFIDO2/WebAuthnの防御機構を組み込むと、攻撃がどのように緩和されるかを可視化できます。
graph TD
    A["攻撃者"] -->|フィッシングサイト構築| B("偽装RPサイト")
    B -->|ユーザーを誘導| C{"ユーザーが偽サイトにアクセス"}
    C -->|認証要求を偽装| D["偽RPサイトがWebAuthn認証フローを開始"]
    D --X 不正なOrigin/RpId --> E{"認証器による保護: Origin/RpId不一致で認証拒否"}
    E -->|Origin/RpIdが一致した場合 (例: 認証器の脆弱性) | F("認証情報窃取を試行")
    F -->|チャレンジ再利用/signCount無視| G{"RPサーバーの防御: チャレンジ/signCount検証で拒否"}
    G -->|成功した場合 (RPサーバーの検証不備)| H["正規RPサイトへの不正ログイン"]
    subgraph FIDO2/WebAuthnによる防御層
        I["正規RPサイト"] -->|ユニークなチャレンジ発行| J{"WebAuthn認証フロー"}
        J -->|Origin/RpId検証| K["認証器"]
        K -->|ユーザー検証 (PIN/生体認証)| L["秘密鍵で署名"]
        L -->|署名付きアサーション| I
        I -->|署名検証, チャレンジ検証, signCount検証| M("認証成功")
    end
    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#f9f,stroke:#333,stroke-width:2px
    style F fill:#f9f,stroke:#333,stroke-width:2px
    style H fill:#f9f,stroke:#333,stroke-width:2px
    style E fill:#ada,stroke:#333,stroke-width:2px
    style G fill:#ada,stroke:#333,stroke-width:2px
    style K fill:#ada,stroke:#333,stroke-width:2px
    style L fill:#ada,stroke:#333,stroke-width:2px
    style M fill:#ada,stroke:#333,stroke-width:2px
検出と緩和策
認証プロトコルレベルの対策
FIDO2/WebAuthnは、以下のプロトコルレベルの機構によりセキュリティを担保します。
- フィッシング耐性: - 
- Originバインディング: 認証器は、認証リクエストの発生元(Origin)が、秘密鍵の登録時に紐付けられたOriginと一致するかを厳密に検証します。不一致の場合、認証は行われません。これはフィッシングサイトからの認証を防止する最も強力な防御機構です。 
- ユーザーの存在証明(User Presence, UP): 認証操作の際に、物理的なボタン操作や生体認証など、ユーザーが認証器の前に実際に存在することを確認します。これにより、バックグラウンドでの不正な認証を防ぎます [W3C – WebAuthn Level 3, 2024年3月5日, W3C]。 
 
- 中間者攻撃耐性: 
- リプレイ攻撃耐性: - 
- チャレンジ-レスポンス: RPサーバーは認証ごとに一意で予測不可能なチャレンジを生成し、認証アサーションに署名するよう認証器に要求します。RPサーバーは受け取ったアサーション内のチャレンジが、自身が発行したものと一致し、かつ一度も使われていないことを検証します [W3C – WebAuthn Level 3, 2024年3月5日, W3C]。 
- 認証カウンタ(signCount): 認証器は認証を行うたびに内部カウンタ(signCount)をインクリメントし、その値を認証アサーションに含めてRPサーバーに送信します。RPサーバーは、保存している過去のsignCountよりも現在のsignCountが必ず大きいことを検証します。これにより、古いアサーションのリプレイを防ぎます [OWASP – WSTG WebAuthn, 2023年10月11日, OWASP]。 
 
- 認証器のセキュリティ: - 
- 秘密鍵の保護: FIDO2認証器(ハードウェアセキュリティキー、TPM、Secure Enclaveなど)は、秘密鍵をセキュアな領域に保管し、決して外部にエクスポートしません。鍵操作は認証器内部で行われます。 
- ユーザー検証(User Verification, UV): PIN、指紋、顔認証などの生体認証により、認証器の正当な所有者であることを確認します。これにより、認証器が盗難されても容易に不正利用されることはありません [FIDO Alliance – FIDO2, 最新, FIDO Alliance]。 
 
RPサーバーサイドの実装における注意点(誤用例と安全な代替)
FIDO2/WebAuthnのセキュリティを最大限に活かすためには、RPサーバー側の実装がプロトコル仕様に厳密に従う必要があります。誤った実装は深刻な脆弱性を招きます。
- 署名検証: 
- チャレンジ検証: - import os
import base64 - def generate_challenge():
 “””ユニークでランダムなチャレンジを生成する”””
challenge_bytes = os.urandom(32) # 32バイト = 256ビットのチャレンジ
return base64.urlsafe_b64encode(challenge_bytes).decode(‘utf-8’).rstrip(‘=’) - # 認証フロー - # 1. ユーザーが認証を開始 - challenge = generate_challenge() - # challengeをセッションに保存(例: session[‘webauthn_challenge’] = challenge) - # クライアントにchallengeを送信 - # 2. クライアントからの認証アサーションを受信 - received_challenge_b64 = … # クライアントから受信したチャレンジ
stored_challenge_b64 = … # セッションに保存していたチャレンジ - # チャレンジが一致し、かつ一度だけ使用されたことを検証 - if received_challenge_b64 != stored_challenge_b64:
raise ValueError(“Challenge mismatch or replay attack detected.”) - # sessionからchallengeを削除し、再利用を防ぐ
 
- Origin/RpId検証: - 
- 誤用例: 受信したアサーション内の- clientDataJSON.originや- authenticatorData.rpIdHashが、RPサーバーの期待する値と一致しないにもかかわらず認証を許可する。
 
- 安全な代替: RPサーバーは、- clientDataJSON.originが自身のOriginと一致すること、および- authenticatorData.rpIdHashが自身のRP IDのハッシュと一致することを厳密に検証する。
 - 
- # RPサーバーサイドでのOrigin/RpId検証(安全な代替) 
 
 - # 例: py_webauthnライブラリを使用した場合の検証(簡略化) - from webauthn import verify_authentication_response
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import (
AttestedCredentialData,
CredentialPublicKey,
AuthenticatorData,
CollectedClientData,
AuthenticationCredential,
PublicKeyCredentialDescriptor,
AuthenticationResponse,
)
import hashlib - # … 前提として、ユーザーのcredential_id, public_key, sign_countがDBに保存されているとする … - def verify_webauthn_assertion(
authentication_response: AuthenticationResponse,
rp_id: str,
expected_origin: str,
stored_challenge: str,
stored_credential_id: bytes,
stored_public_key_pem: str, # 例: PEM形式の公開鍵
stored_sign_count: int
):
 “””
    WebAuthn認証アサーションを検証する。 -     – authentication_response: クライアントから受信したAuthenticationResponseオブジェクト -     – rp_id: 期待するRP ID (例: “example.com”) -     – expected_origin: 期待するOrigin (例: “https://example.com”) -     – stored_challenge: セッションに保存されたチャレンジ -     – stored_credential_id: ユーザーに紐づくCredential ID (bytes) -     – stored_public_key_pem: ユーザーに紐づく公開鍵 (PEM形式) -     – stored_sign_count: ユーザーに紐づく前回のsignCount
    “”” - # 1. 公開鍵のロード(実際のライブラリではより複雑) - # ここではPEM形式からの変換例。実際のライブラリはより抽象化されている。 - # credential_public_key = CredentialPublicKey.from_pem(stored_public_key_pem) - # 2. RP IDハッシュの検証 - # ライブラリが内部でrpIdHashとrp_idを比較するはず - # 3. ClientDataJSON.originの検証 - # ライブラリが内部でcollected_client_data.originとexpected_originを比較するはず - # 4. チャレンジの検証 - # ライブラリが内部でcollected_client_data.challengeとstored_challengeを比較するはず - # 5. 署名と認証器データの検証 - # 通常、ライブラリのverify関数がこれらの検証を一括で行う - verified_response = verify_authentication_response(
credential=authentication_response,
expected_challenge=stored_challenge,
expected_origin=expected_origin,
rp_id=rp_id,
credential_public_key=stored_public_key_pem, # または適切な公開鍵オブジェクト
stored_sign_count=stored_sign_count,
require_user_verification=True, # ユーザー検証必須の場合
require_user_presence=True, # ユーザー存在証明必須の場合
) - # 6. signCountの検証 - # ライブラリが内部で行うが、検証結果から新しいsignCountを取得し、DBを更新する必要がある - if verified_response.new_sign_count <= stored_sign_count:
raise ValueError(“Sign count did not increment. Replay attack or faulty authenticator.”) - # 認証成功。DBのsignCountをverified_response.new_sign_countで更新 - return True
 - 
- 計算量とメモリ: 上記の検証処理は主に公開鍵暗号の署名検証とハッシュ計算に依存し、O(1)の計算量で完了します。メモリ使用量も少なく、数ミリ秒で実行されます。
 
- 認証カウンタ(signCount)の検証: - 
- 誤用例: - authenticatorData.signCountを保存せず、または受信した- signCountが保存されている値以下でも認証を許可する。
 
- 安全な代替: RPサーバーは、各ユーザーの各認証器について最新の- signCountを安全に保存し、認証アサーションを受信するたびに、受信した- signCountが保存されている- signCountよりも厳密に大きいことを検証する。検証後、DBの- signCountを新しい値に更新する。
 
 
- 鍵/秘匿情報の取り扱い: - 
- 公開鍵と秘密鍵の分離: FIDO2/WebAuthnのセキュリティモデルの根幹は、公開鍵はRPサーバーに保存されるが、秘密鍵は決して認証器から外部に出ない点です。RPサーバーが侵害されても秘密鍵は流出しません。 
- 公開鍵の保存と保護: RPサーバーは、ユーザーの公開鍵、Credential ID、最新のsignCountなどをデータベースに保存します。これらの情報は、ユーザーを識別し、認証を検証するために不可欠です。 - 
- 最小権限: これらの情報にアクセスできるのは、WebAuthn認証を処理するモジュールのみに限定すべきです。 
- 暗号化: データベース内の公開鍵情報は、可能であればAES-256などの強力なアルゴリズムで暗号化して保存することが望ましいです。 
- バックアップ: 公開鍵情報はユーザー認証の根幹となるため、適切なバックアップ戦略が必要です。 
 
 
運用対策
FIDO2/WebAuthnを安全に運用するためには、プロトコルレベルの対策だけでなく、組織的な運用体制も重要です。
- 鍵/秘匿情報のローテーション: - 
- FIDO2の秘密鍵は認証器内に固定されるため、従来のパスワードのようにサーバーがローテーションを強制する仕組みはありません。しかし、認証器の紛失やセキュリティ懸念がある場合、ユーザーは既存の認証器を解除し、新しい認証器を登録する(公開鍵の再登録)ことで、実質的な鍵ローテーションを行います。RPは、ユーザーが容易に認証器を管理できるインターフェースを提供すべきです。
 
- 最小権限の原則: - 
- WebAuthn認証を処理するRPサーバーコンポーネントやデータベースへのアクセスは、最小限の必要な権限のみを与えるべきです。例えば、公開鍵情報が保存されたデータベースは、他の機密情報とは分離し、アクセス制御を厳格化します。
 
- 監査とログ: - 
- 認証成功/失敗、認証器の登録/解除、- signCountの異常な値(減少、増分なし)など、WebAuthn関連のすべてのイベントを詳細にログに記録し、監査可能な状態を保ちます。異常なイベントはセキュリティアラートとして監視システムに連携します。
 
- 検出遅延: 不正アクセスや異常な認証試行をリアルタイムで検出するために、ログの集約と監視は必須です。検出遅延はインシデント対応に大きな影響を与えるため、ログの迅速な処理と分析が求められます。 
 
- 認証器のライフサイクル管理: - 
- 紛失/盗難時の対応: ユーザーが認証器を紛失または盗難された場合、迅速にその認証器を無効化できるリカバリフローが必要です。バックアップ認証器や、多要素認証による本人確認プロセスを確立することが推奨されます。 
- リカバリ方法: 認証器が利用できない場合のリカバリ方法は、セキュリティと利便性のトレードオフが生じやすい「現場の落とし穴」です。SMS認証やEメール認証など、他の認証手段を用いるリカバリフローは、FIDO2のフィッシング耐性を弱める可能性があるため、慎重に設計し、複数のリカバリオプション(例: バックアップパスキー、リカバリコード、既存のデバイスによる復旧)を提供することが望ましいです [Google Developers – Passkeys, 最新, Google]。 
 
- 誤検知/検出遅延/可用性トレードオフ: - 
- 厳格すぎるセキュリティ対策は、正当なユーザーのアクセスを妨げる(誤検知、可用性の低下)可能性があります。例えば、signCountのわずかな不一致でも認証を拒否すると、稀な同期ずれでユーザーがロックアウトされるかもしれません。しかし、緩すぎるとセキュリティリスクが高まります。これらのバランスを慎重に検討し、リスクベースのアプローチで運用ポリシーを策定することが重要です。
 
まとめ
FIDO2/WebAuthnは、パスワードレス認証の強力なフレームワークを提供し、多くの従来型攻撃からの防御に優れています。しかし、そのポテンシャルを最大限に引き出すためには、プロトコル仕様への厳密な準拠、RPサーバー側の安全な実装、そして強固な運用体制が不可欠です。チャレンジ、Origin、RpId、signCountなどの各要素の検証を怠ると、フィッシング耐性やリプレイ攻撃耐性が失われ、脆弱なシステムになりかねません。実務のセキュリティエンジニアは、これらの脅威と対策を深く理解し、常に最新のベストプラクティスに従ってシステムを設計・運用することが求められます。
 
コメント