Passkey (WebAuthn) 登録フローとPublicKeyCredentialの検証手順
Security WebAuthn Authentication Frontend
結論
チャレンジ生成 → 生体認証呼び出し → サーバーでの clientDataJSON 3大要素検証の順で実装します。
// W3C WebAuthn Level 2 公式仕様:ブラウザ側登録呼び出し
async function registerPasskey(challengeBuffer: ArrayBuffer) {
const credential = await navigator.credentials.create({
publicKey: {
challenge: challengeBuffer,
rp: { name: "Example App", id: "example.com" },
user: {
id: new Uint8Array([1, 2, 3, 4]),
name: "[email protected]",
displayName: "山田 太郎",
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }], // ES256 (ECDSA P-256)
authenticatorSelection: { userVerification: "preferred" },
timeout: 60000,
},
});
return credential;
}
W3C WebAuthn公式仕様:サーバー側検証の必須チェックリスト
W3C WebAuthn Level 2 規格(w3.org/TR/webauthn-2)の規定通り、サーバー(Relying Party)がブラウザから受信した PublicKeyCredential を検証する際、以下の項目を順番にチェックしなければなりません。
| 検証項目 | チェック内容 | 防御するセキュリティ攻撃 |
|---|---|---|
1. type の検証 | clientDataJSON.type === "webauthn.create" であること | ログイン用(webauthn.get)レスポンスの流用防止 |
2. challenge の検証 | セッション保存したチャレンジと完全一致&ワンタイム消費 | リプレイ攻撃(盗聴データの再利用)の完全防止 |
3. origin の検証 | clientDataJSON.origin === "https://example.com" であること | フィッシングサイト(似せドメイン)からの不正登録防止 |
実際に起こる事故:チャレンジ使い回しと Origin 検証モレ
事故1: チャレンジのワンタイム無効化モレによるリプレイ攻撃
サーバー側で一回利用した challenge をセッションから即座に破棄(Clear)しない実装をしている場合、攻撃者がネットワーク上で盗聴した過去の登録レスポンス(clientDataJSON+署名)をそのまま再送信する リプレイ攻撃 を許し、第三者による勝手な鍵追加が発生します。
事故2: origin 検証漏れによるフィッシング成功
偽のフィッシングサイト(https://examp1e.com)経由でユーザーが生体認証を行った際、バックエンドで clientDataJSON.origin の完全一致チェックを怠っていると、フィッシングサイト上で生成された公開鍵が本物サーバーへそのまま登録される重大事故 が発生します。
安全な実装手順
- サーバー側で暗号学的に安全なランダムバイト列(
crypto.randomBytes(32))を生成し、ユーザーセッションへ一次保存する - フロントエンドで
navigator.credentials.create()を呼び出し、Touch ID / Face ID 生体認証を実行する - バックエンドへレスポンスを送信し、
clientDataJSONをデコードしてtype,challenge,originの3つを厳格に照合・検証した後に公開鍵をDBへ保管する