課題とスコープ
パスワードログインが既に存在するWebサイトを所有しており、Passkeyを追加したいと考えています。対応ブラウザでは、ユーザーはわかりやすいフォールバックおよびリカバリー経路を維持しつつ、プラットフォームのロック解除またはセキュリティキーを使用して認証できる必要があります。navigator.credentials.create()、navigator.credentials.get()、チャレンジ、RP ID、オリジン検証、クレデンシャルのライフサイクル境界を含め、フロントエンドとサーバーの連携を設計してください。
WebAuthnは、ブラウザと認証器(authenticator)間の公開鍵認証APIです。秘密鍵は認証器内に保持され、Webサイトは公開鍵を保存します。フロントエンドはサーバーのオプションを取得し、ブラウザAPIを呼び出し、結果をシリアライズして状態を描画します。サーバーは署名を検証し、チャレンジを消費して、クレデンシャルをアカウントに紐付ける必要があります。
面接官がテストしていること
優れた回答では、登録とサインインを2つの短いステートフルなフローに分離します。サーバーが予測不可能な1回限りのチャレンジを作成し、フロントエンドが認証器を呼び出し、サーバーがセッションを作成する前に返されたチャレンジ、オリジン、RP ID、署名、およびカウンターを検証します。また、「Face IDが表示された」ことをプロトコルの成功と見なすのではなく、HTTPSセキュアコンテキスト、ユーザーによるキャンセル、タイムアウト、デバイス移行、複数のクレデンシャル、およびリカバリーについても網羅します。
面接官は、条件付き自動入力がUX機能であり、サーバー検証の代替ではないことを理解しているかどうかに注目します。また、認証器がその状態を更新できるように、サーバーのクレデンシャルを削除した後にWebAuthnシグナルAPIを呼び出すべき理由について質問する場合もあります。
最初に明確にすべき質問
サポート対象クライアントとアカウントポリシー
対象ブラウザ、モバイルおよびデスクトッププラットフォーム、同期される発見可能なクレデンシャル(discoverable credentials)を許可するかどうか、パスワードやセキュリティキーが引き続き利用可能かどうかを確認します。これらの選択は、residentKey、userVerification、クレデンシャル選択、ヘルプテキストに影響します。
ドメインとデプロイトポロジー
本番環境およびログインサブドメイン、iframe、リバースプロキシ、テナント固有のドメインを確認します。RP IDは、現在のオリジンに対して有効な関連ドメインである必要があります。本番環境に移行するプレビュードメインは、推測によって設定を安全に再利用することはできません。
リカバリーと高リスクアクション
すべての認証器を紛失した場合にユーザーがどのように制御を証明するか、またリセットによってセッションが取り消されるか、ユーザーに通知されるか、高リスクアクションが遅延されるかを確認します。リカバリーはIDライフサイクルの一部であり、フロントエンドの「パスワードを使用」リンクだけに矮小化することはできません。
30秒の回答フレームワーク
「登録とサインインはどちらも、サーバーからの予測不可能な1回限りのチャレンジから始まります。フロントエンドはオプションをWebAuthnに渡します。登録中、サーバーはチャレンジ、オリジン、RP ID、アテステーションポリシー、公開鍵を検証してから、クレデンシャルIDをアカウントに紐付けます。サインイン中、セッションを作成する前にアサーション署名、チャレンジ、RP ID、オリジン、署名カウンターを検証します。フロントエンドは、非対応、キャンセル、タイムアウト、拒否の各状態を区別します。条件付き調停(Conditional mediation)により自動入力を提供できますが、プロトコルの正確性に必須ではありません。デバイスを紛失した場合は、リスク制御されたリカバリーを経由し、古いセッションを取り消し、新しいクレデンシャルの登録を可能にします。」
ステップバイステップの解決策
ステップ1: サーバーにチャレンジを作成させる
登録またはサインインページは、まずサーバーにフローの作成を要求します。サーバーは少なくとも16バイトのランダムなチャレンジデータを生成し、そのハッシュ、アカウント、目的、有効期限、消費状態を保存します。フロントエンドでこれを生成または再利用してはなりません。そうしないと、古いアサーションが別のフローに移送される可能性があります。サーバーは現在のRP向けのpublicKeyオプションを返し、フロントエンドはセキュリティ上機密性の高いフィールドを書き換えません。
ステップ2: 登録の設計
HTTPSセキュアコンテキストで、フロントエンドはnavigator.credentials.create()を呼び出します。オプションには、RP、ユーザーID、表示名、受け入れ可能な公開鍵アルゴリズム、ユーザー検証プリファレンス、発見可能なクレデンシャルのポリシーが含まれます。Promiseが解決された後、フロントエンドはクレデンシャルID、クライアントデータ、アテステーションレスポンス、必要な拡張機能をシリアライズしてサーバーに送信します。秘密鍵が認証器から外部に出ることはありません。
ステップ3: サーバーでの登録検証
サーバーは、チャレンジがフローと一致すること、オリジンが許可されていること、RP IDハッシュが正しいこと、署名と公開鍵アルゴリズムがポリシーを満たしていることを確認します。プライバシーと互換性の目標に基づいてアテステーションを検証するかどうかを決定します。成功すると、公開鍵、クレデンシャルID、アカウント、署名カウンター、および必要なデバイスラベルのみを保存します。生のオブジェクト全体や識別可能なデバイスデータを永久に保持するべきではありません。
ステップ4: サインインと条件付き自動入力の設計
サインインの場合、サーバーは新しいチャレンジを作成し、フロントエンドはそれを使用してnavigator.credentials.get()を呼び出し、アサーションを送信します。サーバーは、クレデンシャルIDからアカウントを解決する前に、チャレンジ、オリジン、RP ID、署名、カウンターを検証します。条件付き調停を使用すると、ユーザーがユーザー名フィールドを操作した後にページが発見可能なクレデンシャルを要求できるため、ブラウザはPasskeyを自動入力に表示できます。これによりディスカバリーのUXは変わりますが、検証の契約は変わりません。
ステップ5: フロントエンドの状態と失敗の処理
キャンセル、タイムアウト、非対応ブラウザ、ブロックされた権限ポリシー、サーバーによる拒否は、ユーザーが理解できる個別の状態にする必要があります。AbortControllerは、ユーザーが離脱した際や再度クリックした際に保留中の呼び出しをキャンセルできるため、古いPromiseが現在の状態を上書きするのを防ぎます。フロントエンドは署名、クレデンシャルID、内部サーバーエラーを表示すべきではなく、不完全な登録を成功としてマークしてはなりません。
ステップ6: リカバリー、失効、クレデンシャル管理
アカウント設定にはクレデンシャルのラベル、作成時刻、最終使用時刻を一覧表示し、クレデンシャルを削除できるようにする必要があります。サーバーがそれを削除した後、その後の未知のクレデンシャルIDによってPublicKeyCredential.signalUnknownCredential()がトリガーされる場合があります。サインインが成功すると、signalAllAcceptedCredentials()を使用してサーバーで引き続き受け入れられているIDを同期できます。すべての認証器を紛失した場合は、パスワードに加えてリスクチェック、確認済みメール、サポートレビューなどのリスク制御されたリカバリーを使用します。古いセッションを取り消し、新しいPasskeyの登録を要求します。
ステップ7: 検証と進化
さまざまなブラウザ、プラットフォーム認証器、同期クレデンシャル、セキュリティキー、キャンセル、タイムアウト、不正なオリジン、期限切れおよび重複したチャレンジ、カウンターの異常、リカバリー後の古いクレデンシャルをテストします。User-Agent文字列から推測するのではなく、機能を検出し、サーバーからポリシーを受け取ります。新しい拡張機能はフォールバックUIを備えたオプションであるべきであり、未知の拡張機能によってコアなサーバー検証が変更されてはなりません。
質の高い回答例
私はPasskeyを、サーバー主導の公開鍵認証フローとして扱います。ユーザーが登録を開始すると、フロントエンドはサーバーに1回限りのチャレンジとpublicKeyオプションを要求し、HTTPS経由でWebAuthnを呼び出します。サーバーのみがチャレンジ、オリジン、RP ID、署名、アルゴリズム、および必要なアテステーションを検証し、クレデンシャルID、公開鍵、カウンター、アカウント紐付けを保存します。秘密鍵は認証器内に残ります。
サインインは別のサーバーチャレンジから始まります。フロントエンドはnavigator.credentials.get()を呼び出し、アサーションを送信し、サーバーはセッションを作成する前にその署名、チャレンジ、オリジン、RP ID、カウンターを検証します。条件付き調停がサポートされている場合は、ユーザー名フィールドの操作後にPasskey自動入力を提供しますが、同一のサーバー検証に従います。
フロントエンドは、非対応、キャンセル、タイムアウト、拒否の各状態を区別し、ページが放棄されたときに保留中の呼び出しをキャンセルします。アカウント設定でクレデンシャルを一覧表示および削除します。削除後、WebAuthnシグナルによりブラウザが未知のクレデンシャルを提案するのを防ぎます。デバイスを紛失した場合は、リスク制御されたリカバリーが必要となり、古いセッションを取り消し、ユーザーに通知し、代替クレデンシャルを登録します。テストではクロスプラットフォーム認証器、不正なオリジン、期限切れまたは重複したチャレンジ、カウンター異常、ブラウザフォールバック、リカバリー後の古いクレデンシャルをカバーし、UXの変更によってプロトコル検証が決して弱まらないようにします。
よくある間違い
- 間違い: チャレンジをフロントエンドで生成する、またはページの定数に配置する。 → なぜ失敗するか: 攻撃者が古いアサーションをリプレイできる可能性があり、サーバーはフローの鮮度(freshness)を確立できません。 → 修正方法: サーバーで生成し、一時的に保存し、1回のみ消費して、アカウントおよび目的に紐付けます。
- 間違い: Promiseが解決した時点でユーザーをサインインさせる。 → なぜ失敗するか: ブラウザオブジェクトは、サーバーがオリジン、RP ID、署名を検証したことの証明にはなりません。 → 修正方法: サーバーでアサーション検証を完了し、フロントエンドに構造化された結果を描画させます。
- 間違い: User-Agent文字列でサポートを検出する。 → なぜ失敗するか: ブラウザのバージョン、プラットフォーム認証器、権限ポリシーが異なります。 → 修正方法: 機能APIとサーバーポリシーを使用し、明示的なパスワードまたはセキュリティキーのルートを提供します。
- 間違い: デバイスの状態を同期せずにデータベースのクレデンシャルを削除する。 → なぜ失敗するか: 認証器はクレデンシャルが存在すると信じ込み、使用不可能なオプションを提供し続けます。 → 修正方法: 適切な認証済み状態でWebAuthnシグナルAPIを呼び出し、再登録パスを提供します。
フォローアップの質問と回答
フォローアップ1: RP IDを任意のAPIドメインにできないのはなぜですか?
RP IDは現在のオリジンに関連するドメインである必要があり、ブラウザとサーバーの両方でチェックされます。無関係な値を指定すると作成が失敗するか、信頼スコープが拡大してしまいます。マルチテナントドメインの場合は、信頼できる各ドメインを明示的に構成します。クライアントから提供された任意の文字列を決して受け入れてはなりません。
フォローアップ2: ユーザーはスマートフォンで登録しましたが、デスクトップにはキーがありません。どうすればよいですか?
同期された発見可能なクレデンシャルが許可されている場合、ブラウザとクレデンシャルマネージャーによって、デバイス間で同じアカウントのPasskeyが提供されることがあります。それ以外の場合は、クロスデバイスQRフローまたはセキュリティキーを提供します。フロントエンドでは、デバイスがそのクレデンシャルを使用できないことを示し、アクションを提供する必要があります。サーバーはオリジンを含む同一のチャレンジおよびアサーションルールを引き続き検証します。
フォローアップ3: カウンターのロールバックが発生した場合、直ちにアカウントをロックすべきですか?
まず、同期されたプラットフォームクレデンシャル、バックアップ復元、および真のクローニングリスクを区別します。コンテキストなしで永久にロックするのではなく、追加の検証を要求してユーザーに通知できるリスクシグナルとして異常を扱います。価値の高いアクションには、別の登録済みクレデンシャルまたはサポートレビューを要求し、ポリシー調整のために決定を記録します。
フォローアップ4: リカバリーはPasskeyのセキュリティを弱めますか?
リカバリーが侵害されている可能性のある電子メールリンクのみに依存している場合は弱まります。階層化されたリスクチェック、セッションの取り消し、クーリング期間、通知、高リスクアクションの遅延を使用します。リカバリー後、新しいクレデンシャルを登録し、古いものを削除します。ユーザーがアクセスを失うことは絶対にないと約束するのではなく、可用性とアカウント乗っ取り(Account Takeover)のトレードオフを説明します。