WebAuthnのsignCountが示すもの: カウンタの比較と複製の疑いのシグナル

WebAuthn Level 3に沿って、signCountが何を数えるのか、0のままの場合、RPが行う比較の正確な条件、値が小さいか同じときになぜ証拠ではなくシグナルなのか、BE・BSフラグとの関係を整理します。

webauthnpasskeyauthenticationsecurity

WebAuthnでログインを検証するサーバー(Relying Party、以下RP)は、認証器が返すauthenticator dataの中でsignCountという4バイトの値を受け取ります。これは、認証器が複製されたことにRPが気づけるようにするための署名カウンタです。実際に扱うと、いくつか疑問が出てきます。値がずっと0で届くのはおかしいのか、保存済みの値より小さいか同じ値が届いたらログインを拒否すべきか、同期されるクレデンシャルなら別の見方が必要か。

この記事はW3C RecommendationであるWeb Authentication Level 3(2026年8月25日版)を根拠にしており、2026-10-15に確認しました。CTAPのようなFIDO Allianceの仕様や、特定のプラットフォーム・メーカーのカウンタの挙動は根拠に使っていません。そのため「どの認証器がどんな値を送るか」は扱いません。以下では、仕様の要求(SHOULD、MUSTなど)と、仕様がRPのポリシーに委ねている部分を分けて書きます。

authenticator dataの中の位置

§6.1によると、authenticator dataは37バイト以上のバイト配列で、先頭部分は次の順序で固定されています。

フィールド 長さ 内容
rpIdHash 32バイト RP IDのSHA-256ハッシュ
flags 1バイト UP(ビット0)、UV(ビット2)、BE(ビット3)、BS(ビット4)、AT(ビット6)、ED(ビット7)
signCount 4バイト 署名カウンタ、32ビット符号なしbig-endian整数

0から数えると、signCountは33番目から36番目のバイトです。符号なし32ビットの値なので、最大値は2^32 − 1です。保存先のカラムを符号付き32ビット整数にすると、2^31以上の値を格納できません。この最後の点は仕様の文言ではなく、フィールドの定義から導かれる実装上の注意です。

登録と認証でカウンタがどう動くか

§6.1.1は、認証器は署名カウンタを実装すべきである(SHOULD)としています。MUSTではないので、実装していない認証器も仕様の範囲内です。カウンタはクレデンシャルごとに持つことも、認証器全体で1つだけ持つこともできます。

登録(authenticatorMakeCredential、§6.3.2)では、新しいクレデンシャルの最初のカウンタ値は認証器の種類によって決まります。

  • クレデンシャルごとのカウンタをサポートしていれば、カウンタを新しく割り当てて0で初期化します。
  • グローバルカウンタをサポートしていれば、そのグローバルカウンタの実際の値を使います。
  • U2Fデバイスなら0にします。U2Fデバイスはカウンタをサポートしていても、クレデンシャル作成時には値を返さないためです。
  • カウンタをサポートしていなければ0にし、その後も0のままです。

認証(authenticatorGetAssertion、§6.3.3)が成功するたびに、認証器はカウンタを「何らかの正の値だけ」増やします。1ずつ増えるという規定はないので、RPが受け取る値の間に間隔があっても、それ自体はおかしなことではありません。カウンタを実装していない認証器は0を送り続けます。

RP側では、登録時(§7.1)にauthenticator dataのsignCountをcredential recordに保存し、以後の認証のたびにその値と新しい値を比較します。§4の定義どおり、credential recordのsignCountは、そのクレデンシャルを使ったceremonyから得た最新のカウンタ値です。

RPが行う比較の正確な条件

§7.2の該当ステップをそのまま書くと次のとおりです。

  1. authData.signCountと保存済みのcredentialRecord.signCountのどちらかが0でなければ、下の比較を行います。両方とも0なら、この比較ステップは実行されません。
  2. 新しい値が保存値より大きければ、署名カウンタは有効です。
  3. 新しい値が保存値以下なら、認証器が複製されたかもしれないというシグナルですが、証拠ではありません。

いくつかのケースを表にまとめます。

保存値 新しい値 結果
0 0 比較しない(カウンタを使わない認証器で見られる形)
0 7 有効
41 42 有効
41 50 有効(増分の大きさは決まっていない)
41 41 複製の疑いのシグナル
41 12 複製の疑いのシグナル
41 0 複製の疑いのシグナル(保存値が0でないので比較対象)

最後の行は見落としがちです。新しい値だけを見て「0ならカウンタ非対応なので飛ばす」と判断すると、以前は0でない値を送っていたクレデンシャルが急に0を送ってきた場合を見逃します。仕様の条件は、2つの値のどちらかが0でなければ比較する、というものです。

小さいか同じ値は証拠ではなくシグナル

§7.2は、保存値以下の値が届いたときに考えられる原因として、3つを例に挙げています。

  • クレデンシャルの秘密鍵のコピーが2つ以上存在し、並行して使われている。
  • 認証器が誤動作している。
  • RPがassertionの応答を、認証器で生成された順序とは異なる順序で処理している競合状態(race condition)がある。

§6.1.1はさらに、カウンタの不一致を検出しても、今処理している操作が複製された認証器によるものか元の認証器によるものかはわからない、と書いています。複製が実際に起きていたとしても、たった今ログインした側が攻撃者だと断定する根拠はないということです。

そのため、仕様は対応をRPに委ねています。§7.2によれば、この場合に保存済みのsignCountを新しい値で更新するかどうか、認証ceremonyを失敗とするかどうかは、RPごとに決めること(Relying Party-specific)です。RPは自らの運用特性を評価し、この情報をリスクスコアに反映すべきです(should)。§6.1.1も、リスク許容度や、値が増えないことに正当な理由がありうる運用上の要因を考慮して、状況に応じて対処するよう求めています。§6.1.1は、保護手段が限られた認証器ほど複製の検出が重要になるとも付け加えています。

仕様が「不一致なら必ず拒否せよ」とは言っていない点は、ポリシーを作るときに重要です。誤動作や処理順序の問題でも同じ結果になりうるので、カウンタの不一致だけでアカウントを自動的にロックするポリシーは、正当なユーザーを締め出すおそれがあります。記録を残し、ほかのリスクシグナルと合わせて判断し、必要なら追加の確認を求める、といった段階的な対応を検討する価値があります。この段落の推奨は仕様の要求ではなく、上の記述から導いた判断です。

比較ステップの疑似コード

以下は、カウンタの比較部分だけを示すポリシー例としての疑似コードです。完全なWebAuthnの検証手順ではありません。署名の検証、challenge・origin・RP IDハッシュの確認、UP・UVフラグの確認など、§7.2のほかのステップはどれも省略できず、下のコードはそれらのステップをすでに通過しているという前提でのみ意味を持ちます。

# ポリシー例としての疑似コード。§7.2のほかの検証ステップをすべて通過した後にのみ実行する。
stored = credentialRecord.signCount
new    = authData.signCount              # 32ビット符号なしbig-endian整数

if new == 0 and stored == 0:
    result = "not_compared"              # 仕様上、比較ステップを実行しない
elif new > stored:
    result = "valid"
else:
    result = "signal"                    # 複製の可能性のシグナル、証拠ではない

update = true
if result == "signal":
    log_risk_signal(credentialId, stored, new)
    action = rp_policy(credentialId, risk_context)
    # 例: 記録して許可、追加認証を要求、レビュー待ち、ceremonyを失敗にする
    if action == "fail":
        return fail_ceremony()
    update = action.updates_count        # 更新するかどうかもRPのポリシー

if update:
    credentialRecord.signCount = new

クレデンシャルごとのカウンタとグローバルカウンタ

§6.1.1は、認証器はクレデンシャルごとのカウンタを実装すべきである(SHOULD)としています。カウンタの値が複数のRPの間で共有されると、ユーザーを結びつける相関識別子(correlation handle)として使われうるからです。グローバルカウンタも許されますが(MAY)、ユーザーのプライバシーの面では望ましさが劣ると書かれています。同じ節は、ハードウェアの故障などでカウンタが誤って減らないようにすることも(SHOULD)認証器に求めています。

RPの立場で覚えておくべきなのは、どちらの方式でも、受け取った値は「直前の値より大きいか」だけで判断するということです。増分の大きさや連続性は比較条件に入っていません。

BE・BSフラグが示すことと示さないこと

Level 3にはバックアップに関する2つのフラグがあります。同期されるクレデンシャルを扱うときにカウンタと混同しやすいので、ここで一緒に見ておきます。

  • BE(Backup Eligibility、ビット3)。 クレデンシャルを作成した認証器が、作成時点でバックアップの可否を決めます。§6.1.3によると、この値はauthenticatorMakeCredentialで設定され、変わってはなりません(MUST NOT)。BEが1ならmulti-device credential、0ならsingle-device credentialと呼ばれます。
  • BS(Backup State、ビット4)。 現在バックアップされているかを示し、時間とともに変わりえます。BEが0でBSが1という組み合わせは許されず、§7.2はRPにこれを確認させています。

§4の用語定義は、バックアップ可能なクレデンシャルが、ピアツーピア同期、クラウド同期、ローカルネットワーク同期、手動のインポート・エクスポートといった仕組みによって、生成した認証器以外の認証器に存在するようになりうると説明しています。これは仕様が定義するバックアップなので、クレデンシャルが複数のデバイスにあること自体を複製攻撃と同一視する根拠にはなりません。

これらのフラグが示さないこともあります。

  • BEやBSの値からは、そのクレデンシャルのカウンタが0かどうか、どう動くかはわかりません。この記事で読んだ節(§6.1.1、§6.1.3、§7.2)には、2つのフラグとsignCountを結びつける規則はありません。「同期クレデンシャルのカウンタは常に0」と断定する根拠も、この仕様には見当たりません。
  • BSが1から0に変わることについて、§6.1.3は、ユーザーがバックアップサービスを無効にしたり、バックアップサービスに問題が起きたりした結果でありうると説明し、ほかの認証手段を確認するよう案内することを勧めています。複製のシグナルとしては説明していません。

BEは別の問題です。§7.2は、RPがバックアップ状態を業務ロジックやポリシーに使う場合、保存済みのbackupEligibleと今回のBEの値が一致するかを確認させています。変わってはならない値が変わったということなので、この確認はカウンタの比較とは別の検証ステップです。

確認すること

  • 比較の条件が「2つの値のどちらかが0でなければ」になっているかを確認します。新しい値だけを見て0を飛ばすと、0でなかったカウンタが0に戻った場合を見逃します。
  • 増分を1と仮定しているコードがないかを見ます。
  • signCountの保存カラムが、符号なし32ビットの値をすべて格納できるかを確認します。
  • 小さいか同じ値が届いたときに何をするか、保存値を更新するかをポリシーとして明文化します。仕様はこの判断をRPに委ねています。
  • 同じクレデンシャルのassertionを複数のサーバーが並行して処理するなら、処理順序の入れ替わりで生じる不一致をどう見分けるかを考えておきます。
  • カウンタの比較コードが、署名・challenge・origin・RP ID・UP/UVの検証の代わりになっていないかを確認します。

参考資料

2026-10-15に確認しました。

ブログへ