【Ruby】webauthn-rubyを読んでわかった、パスキー検証の仕組み
はじめに
パスキーの仕組み、なんとなく知っていても、説明しようとすると意外と難しくないでしょうか?
この記事では、パスキーが「何を防いでいるか」という視点で整理しつつ、webauthn-rubyの実装を読んで理解を深めていきます。
パスキーはパスワードに代わる認証方式で、生体認証(Face ID、指紋など)やPINを使ってログインできます。内部的にはWebAuthn(Web Authentication API)という標準仕様に基づいており、公開鍵暗号を使った仕組みになっています。WebAuthnの考え方は言語を問わず共通なので、Ruby以外の方にも参考になれば幸いです。
※この記事で参照するコードは webauthn-ruby v3.4.3のものです。
パスキー認証の仕組み
署名による本人確認
パスキーは公開鍵暗号の「署名」を使った認証です。
公開鍵暗号では、秘密鍵と公開鍵という2つの鍵をペアで使います。秘密鍵は自分だけが持ち、公開鍵は相手に渡します。この2つは数学的に結びついていますが、公開鍵から秘密鍵を逆算することはできません。
「署名」は、この性質を使って本人確認を行う仕組みです。秘密鍵でしか作れない署名を、公開鍵で検証します。署名が正しければ、秘密鍵の持ち主であることを確認できます。
パスワード認証ではパスワードそのものをサーバーに送りますが、パスキーでは秘密鍵がデバイスから出ることはなく、送られるのは署名データだけです。
登録フロー
パスキーを登録する流れです。ざっくり言うと、「鍵ペアを作って、公開鍵をサーバーに預ける」という処理です。
※認証器(Authenticator)は、鍵の生成・保管・署名を担うデバイスやソフトウェアです。スマートフォンのFace ID/Touch ID、ハードウェアキー(YubiKey)などが該当します。
※この記事では「ブラウザ」と表記していますが、スマホアプリ等を含むクライアント全般を指します。
①ブラウザがサーバーに登録開始をリクエスト
②サーバーがchallenge(使い捨てのランダム値)を発行
③ブラウザが認証器に鍵ペア生成を依頼
④認証器がFace IDなどで本人確認し、秘密鍵と公開鍵のペアを生成
⑤認証器が公開鍵をブラウザに返す
⑥ブラウザが公開鍵をサーバーに送信
⑦サーバーが公開鍵を保存
秘密鍵は認証器(デバイス)内に留まり、サーバーには渡りません。
認証フロー
登録済みのパスキーで認証する流れです。「秘密鍵で署名して、本人であることを証明する」という処理です。
- ブラウザがサーバーにログイン開始をリクエスト
- サーバーがchallengeを発行
- ブラウザが認証器に本人確認を要求(署名の作成を依頼)
- 認証器がFace IDなどで本人確認し、秘密鍵で署名を作成
- 認証器が署名をブラウザに返す
- ブラウザが署名データをサーバーに送信
- サーバーが公開鍵で署名を検証
署名が正しければ、秘密鍵を持っている本人だと確認できます。
パスキーはなぜ安全なのか
仕組みがわかったところで、なぜこれが安全と言えるのか?という視点で安全性を見ていきます。
サーバーのデータが漏洩したら?
パスワード認証では、サーバーが攻撃されてパスワード(のハッシュ)が漏洩すると大問題です。
パスキーの場合、サーバーに保存されているのは公開鍵だけです。公開鍵から秘密鍵を逆算することはできないので、漏洩しても認証を突破できません。
通信を盗み見されたら?
攻撃者が通信を盗み見して、過去の署名を手に入れたとします。署名は秘密鍵がないと作れません。ただし、過去の署名は再利用できてしまいます。
もしchallengeが固定だったら、盗んだ署名をそのまま送信すればログインできてしまいます。これがリプレイ攻撃です。
パスキーでは、challengeを毎回変えることでリプレイ攻撃を防いでいます。署名にはchallengeが含まれるため、過去の署名は新しいchallengeでは使えません。
フィッシングサイトに誘導されたら?
攻撃者がフィッシングサイトを作り、ユーザーを誘導したとします。パスワード認証なら、ユーザーが気づかずに入力すれば終わりです。
パスキーでは、認証器に保存されたRP ID(Relying Party ID)との照合により、フィッシングサイトでは認証できません。
パスキーを登録するとき、ドメイン(RP ID)が認証器に保存されます。example.com で登録したパスキーは、RP ID = example.com として認証器内に記録されます。
認証時、ブラウザは現在のドメインからRP IDを決定し、認証器に問い合わせます。認証器は該当するRP IDのパスキーを探しますが、example-login.com に紐付いたパスキーは存在しないため、選択肢に表示されません。
つまり、ユーザーが騙されても技術的に認証できません。
秘密鍵が漏洩したら?
「秘密鍵自体が漏洩したら?」という問題もあります。
例えば:
- デバイスがマルウェアに感染し、秘密鍵がエクスポートされた
- 認証器の実装に脆弱性があり、秘密鍵が流出した
漏洩した秘密鍵があれば、攻撃者は新しいchallengeに対しても正しい署名を作れてしまいます。
sign_countは認証のたびに増加するカウンターで、秘密鍵のクローン(複製)を検知します。
認証器は認証のたびにsign_countを1つ増やし、サーバーに送ります。サーバーは「前回より大きいか」をチェックし、保存します。
秘密鍵がコピーされた場合、正規ユーザーが認証するとサーバー側のカウンターが進みます。攻撃者が後から認証しようとしても、カウンターが追いつかず「前回以下の値」となり、不正を検知できます。
ただし、同期パスキー(iCloudキーチェーン等)ではsign_countは常に0です。複数デバイス間でカウンターを同期できないためです。同期パスキーが主流の今、sign_countは主にセキュリティキーでのみ意味を持つと考えてよいと思います。
とはいえ、sign_countはWebAuthnの仕様として定義されており、webauthn-rubyでも実装されています。仕組みを知っておくと、後述するgemの実装を理解しやすくなります。
同期パスキーはどうやって安全性を確保しているのか
同期パスキーでは、sign_countによる「事後検知」ではなく、複数の仕組みで秘密鍵を保護し、漏洩自体を防ぐアプローチを取っています。
例えば、iCloudキーチェーンでは以下のような保護があります。
| 防御レイヤー | 内容 |
|---|---|
| エンドツーエンド暗号化 | 秘密鍵はデバイス上で暗号化され、Apple/Googleでさえ復号できない |
| アカウント認証 | iCloud/Googleアカウントへのアクセスに2要素認証が必要 |
| デバイス認証 | 新デバイスで同期するには、既存デバイスの画面ロック解除やPINが必要 |
| ブルートフォース対策 | 復旧試行は最大10回まで。超過で永久ロック |
webauthn-rubyではどう実装されているか
ここまでの仕組みが、実際のコードでどう実現されているかを見ていきます。
認証処理の全体像
Railsでwebauthn-rubyを使う場合、認証処理は一般的に次のような流れになります(実装の詳細はアプリの設計により異なります)。
def authenticate
# 1. ブラウザから送られた認証データ(署名など)をパース
webauthn_credential = WebAuthn::Credential.from_get(params)
# 2. DBから保存済みの公開鍵とsign_countを取得
stored_credential = current_user.credentials.find_by!(
webauthn_id: webauthn_credential.id
)
# 3. 各種検証を実行
webauthn_credential.verify(
session[:challenge],
public_key: stored_credential.public_key,
sign_count: stored_credential.sign_count
)
# 4. sign_countを更新
stored_credential.update!(sign_count: webauthn_credential.sign_count)
end
※Chrome + Touch ID の簡単なデモ(認証時)

verify() の中で、前述したchallenge、RP ID、署名、sign_countなどの検証が行われます。
challenge検証(リプレイ攻撃対策)
認証開始時、challengeは SecureRandom.random_bytes で 32バイト(256ビット)の乱数として生成されます。
検証時には OpenSSL.secure_compare を使用しています。その理由は、通常の == 比較では不一致が見つかった時点で処理が終了するため、処理時間の差から正しい値を推測される可能性があるからです。secure_compare は常に全体を比較し、処理時間を一定に保ちます。
origin / RP ID検証(フィッシング対策)
RP IDに加え、originも検証しています。
originは「どのサイトからのリクエストか」を示す値です(例:https://example.com)。RP IDがドメイン単位なのに対し、originはスキーム(https)とポート番号も含みます。originはブラウザが自動で埋め込むため、JavaScriptから改ざんできません。
サーバーは設定済みのRP IDをハッシュ化し、認証器から送られてきた値と一致するか検証します。
両方を検証するのは、出どころが違うからです。
- origin:ブラウザが生成
- RP ID:認証器が生成
攻撃者がどちらか一方を改ざんしても、もう一方の検証で検出できます。
署名の検証
パスキー認証では、以下の2つのデータを結合したものが署名対象になります。
署名対象 = authenticatorData + SHA256(clientDataJSON)
- authenticatorData:認証器が生成(RP IDハッシュ、sign_countなど)
- clientDataJSON:ブラウザが生成(challenge、originなど)
先ほどのchallengeやoriginは、この clientDataJSON に含まれています。署名対象に含まれているため、改ざんすると署名検証が失敗します。
sign_count検証(秘密鍵漏洩の検知)
if authenticator_data.sign_count.nonzero? || stored_sign_count.nonzero?
authenticator_data.sign_count > stored_sign_count
else
true # 両方0なら常にOK
end
基本は authenticator_data.sign_count > stored_sign_count で「前回より大きいか」をチェックしています。
ただし、同期パスキーではsign_countが常に0のため、両方0なら常にOKを返しています。
まとめ
パスキーは「何を防いでいるか」を理解すると、仕組みが見えてきます。webauthn-rubyの実装を読むと、これらの対策が verify() の中で一つずつ検証されていることがわかりました。
WebAuthnは「秘密を送らない」という設計が徹底されています。秘密鍵は認証器から出ず、サーバーに送るのは署名だけ。漏洩しても困らないものだけがネットワークを流れます。
この記事が、パスキーの仕組みを理解する参考になれば幸いです。
最後までお読みいただき、ありがとうございました!
Discussion