⛑️

WebAuthnを用いたパスワードレス認証(パスキー認証)についてのメモ

に公開


WebAuthnを用いたパスワードレス認証を実装したくなったので、理解のためにその周辺の情報をまとめたメモを備忘のため作成しました。"WebAuthn認証"と"パスキー認証"や、"Resident Key"と"Discoverable Credential"など、異なる用語で同じ内容を指すことがあったり、概要だけの記事や断片的な詳細情報が記載された記事はあっても要点をまとめたような記事が見つからなかったため、概要と簡単なコードの関係性を把握するのにもなかなか苦労しました。

簡易的ですが要点を概要から実際のコードまでを繋げるような形にまとめたつもりなので、そのあたりの把握をしようとしている方の参考になれば幸いです。

前提

予備知識

アクター

  • ユーザー (User)
  • 認証器 (Authenticator)
    • 端末内蔵型 (Platform Authenticator)
    • 外部接続型 (Roaming Authenticator)
  • ブラウザ (User Agent / WebAuthn Client)
  • アプリケーション (Relying Party / RP)
    • フロント、サーバー共にRPだが、主にサーバーを指すことが多い

認証器について

認証器(認証器デバイス)は2種類の鍵ペアを持つ。一つは変わる事のない認証器固有の証明用鍵ペア。もう一つは各サービスに登録するたびに生成される認証用鍵ペア。

RPIDについて

アプリケーションを識別するRPID(Relying Party ID)には一般的にドメインが用いられる。

Attestation Object

新しく作成された認証用公開鍵を含む認証器データ(authenticator data)と、その認証情報が本物の認証器によって作成されたことを証明する認証器の証明用秘密鍵で署名された証明データ(attestation statement)を含むオブジェクトで、以下要素が含まれる。

  • authData: 認証用公開鍵、RPID hash、署名カウンター等を含む認証器データ
  • fmt: attestation statementのフォーマット情報 ("packed", "tpm"など)
  • attStmt: attestation statementで、認証器の真正性を検証するために使用される

WebAuthnの位置付け

位置付けまとめ

一般的に"FIDO2認証"や"WebAuthn認証"、"パスキー"、"パスキー認証"という用語はFIDO2仕様であるWebAuthnとCTAPを用いた認証を指す。WebAuthnはW3Cが正式に勧告したWeb認証仕様で、Webブラウザと認証を必要とするWebサーバ間の通信仕様である。

特に"パスキー"という用語は、認証器の情報(秘密鍵とメタデータ)をデバイス間で同期する実装を指すことが多い。例えば、Apple KeyChainで鍵を管理すると、MacとiPhone両方で同じ鍵を用いて認証できる。厳密に区別して呼称する場合は、認証情報を同期する場合は"同期パスキー"、同期しない場合は"デバイス固定パスキー"と呼ぶ。

パスキー認証は、パスワードの入力を省略するパスワードレス認証としても、パスワードと併用する多要素認証としても利用できる。

順序立てた位置付け理解

FIDO Alliance (Fast IDentity Online Alliance)

生体認証などを利用した新しいオンライン認証技術の標準化を目指して2012年7月に発足した非営利の標準化団体、業界団体。
Wikipedia

パスワードへの過度の依存を減らすための認証標準という、焦点を絞った使命を持つオープンな業界団体である。 FIDO アライアンスは、認証およびデバイス認証の標準の開発、使用、および準拠を促進する。
FIDOアライアンス

パスワードやSMS OTPよりも安全で、消費者や従業員にとって使いやすく、サービスプロバイダーが導入と管理が容易なパスキーを使用したフィッシング耐性のあるサインインのオープンスタンダードで、認証の性質を変えようとしています。 また、このアライアンスは、クラウドおよびIoT環境で動作するコネクテッドデバイスのセキュリティと効率性を確保するために、安全なデバイスオンボーディングの基準を提供します。
FIDOアライアンス

FIDO

FIDO(ファイド)は、Fast IDentity Online(素早いオンライン認証)の略語で、従来のパスワード認証に代わる認証技術の1つ。FIDO2に基づくパスキーが広く使われている。
業界団体であるFIDO Allianceによって規格の策定と普及推進が行われている。
Wikipedia

FIDO 1.0

FIDO 1.0仕様の技術として Universal Authentication Framework (UAF) と Universal Second Factor (U2F) がオープンな規格として仕様公開されている。
Wikipedia

FIDO2

「以下の2つ」とは Web Authentication(WebAuthn)Client to Authenticator Protocols(CTAP) を指す。

FIDO2は、上記のUAFとU2Fを統合した仕様で、以下の2つから構成される。
Wikipedia

「統合した仕様」とあるが、Claudeによるとそれぞれのユースケースを統合しただけで、仕様としては完全に新しい仕様として再設計されているとのこと。

FIDO2に基づくものとしてパスキーがある。
Wikipedia

WebAuthn

W3C(World Wide Web Consortium)が正式に勧告したWeb認証仕様。Webブラウザと、認証を必要とするWebサーバ間の通信仕様である。FIDO認証のサポートを可能にするためにブラウザやプラットフォームに組み込まれている標準的なウェブAPIを定義している。
Wikipedia

CTAP

FIDO認証器とクライアント間の通信を制御する通信仕様。2019年3月4日にウェブ標準として承認された。これに伴い、Universal Second Factor (U2F) は CTAP1 に改名された。
2025年7月14日 - CTAP 2.2 制定
Wikipedia

パスキー

パスキー(英: passkey)は認証技術の一つであり、公開鍵暗号に加え生体認証やデバイスPINなどを組み合わせ、アカウントの身元を確認する技術である。従来のパスワードによる認証の欠点を克服すべくFIDOアライアンスとW3Cによって策定された。
仕組みとしては、公開鍵暗号方式で認証を行うための秘密鍵とメタデータの組み合わせで、FIDO認証資格情報(FIDO credentials)とも称される。パスキーを用いた認証をパスキー認証と称する。
Wikipedia

認証の方法としては以下の3パターンがある。

  1. ユーザー名およびパスワードの入力を省略して、パスキーだけで認証
  2. ユーザー名は入力するが、パスワードの入力を省略して、パスワードレス認証としてパスキー認証
  3. ユーザー名とパスワードを入力して、多要素認証としてパスキー認証
    Wikipedia

同期パスキー

同期パスキー(synced passkey)は、Apple、Googleなどのサービスが提供するApple AccountやGoogle アカウントなどのユーザーアカウントに紐付けて、同一アカウント配下の複数端末でユーザーが同じパスキーを使えるようにしたものである。
Wikipedia

デバイス固定パスキー

デバイス固定パスキー(device-bound passkey)は、同期パスキーとは違いFIDO認証資格情報が端末に紐づいており、クラウドでの同期などができない。
Wikipedia

Credential Management API (Web API)

パスキー認証だけでなく、パスワードやフェデレーション認証、OTP認証を含む統合的な資格情報管理が可能なWeb API。この実現を担うインターフェースがCredentialsContainerで、コード上ではnavigator.credentialsとしてアクセスできる。Credentialタイプとして以下が定義されている。

  • Password: PasswordCredential
  • Federated identity: IdentityCredential (旧FederatedCredential)
  • One-time password (OTP): OTPCredential
  • Web Authentication: PublicKeyCredential

CredentialsContainercreateメソッドやgetメソッドではそれぞれに対応した引数が存在するが、パスキー認証で使用するのはPublicKeyCredentialに対応するpublicKey引数だけのため、その他は使用しない。

パスキー認証について

パスキー認証のフローパターン

従来型

"Non-Resident Key"と呼ばれる場合もある。ユーザー名の入力は必須。

  • 認証器に保存する内容
    • Credential IDと秘密鍵のみ
  • 認証フロー
    1. ユーザーがユーザー名を入力
    2. フロントがサーバーにユーザー名を送信
    3. サーバーがユーザー名に紐つくCredential ID全てとチャレンジを返信
    4. 認証器がCredential IDを突合して秘密鍵を特定
    5. 認証器が秘密鍵でチャレンジから署名を作成
    6. フロントが署名と使用したCredential IDをサーバーに送信
    7. サーバーが公開鍵で署名を検証
navigator.credentials.create({
  publicKey: {
    // ...
    authenticatorSelection: {
      // omit residentKey, or "discouraged"
    },
    // ...
  }
});

Discoverable Credential

"Resident Key"と呼ばれる場合もある。また、Discoverable Credentialの実現方法には通常UIとConditional UIの2パターン存在する。

  • 認証器に保存する内容
    • Credential IDと秘密、他のユーザー情報を含む完全な情報
  • 認証フロー
    1. フロントがパスワードレス認証要求を送信
    2. サーバーがRPIDとチャレンジを返信
    3. 認証器がRPIDから秘密鍵を特定
    4. 認証器が秘密鍵でチャレンジから署名を作成
    5. フロントが署名とユーザー情報(User Handle)、Credential IDをサーバーに送信
    6. サーバーが公開鍵で署名を検証、User HandleとCredential IDからユーザーを特定
navigator.credentials.create({
  publicKey: {
    // ...
    authenticatorSelection: {
      residentKey: "required", // if "preferred", fallback to "discouraged" when not support
      requireResidentKey: true, // omit this line when residentKey is not "required"
    },
    // ...
  }
});

通常UI

"パスキーでログイン"ボタンなど、明示的な認証アクションとして実装する場合に有効。
ボタン押下時にnavigator.credentials.get()を実行するとブラウザのモーダルダイアログが表示され、ユーザーの応答待ちになる。

navigator.credentials.get({
  publicKey: { ... }
  // omit mediation, or "optional", "required" etc.
});

Conditional UI

ログインページでユーザー名フィールドと併用して、シームレスな体験を提供したい場合に有効。
ユーザー名入力フィールドにフォーカスすると、パスワードマネージャーの候補と同じようにオートフィル候補として保存済みのパスキーが表示される。オートフィル候補からパスキーを選択した時点で認証フローが開始される。

navigator.credentials.get()はページ読み込み時等に実行する。"ユーザー名入力フィールド"は<input type="text" autocomplete="username webauthn" />のようにautocomplete属性に"username webauthn"を指定することで対象要素としてブラウザが認識する。

navigator.credentials.get({
  publicKey: { ... },
  mediation: "conditional" // for Conditional UI
});

Discoverable Credentialフロー

登録時


img_signup_flow
登録時のフロー図

認証時


img_login_flow
認証時のフロー図

具体的な構造・コードと実装

登録時

認証器への登録

const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
  challenge: Uint8Array.fromBase64(randomBase64StringFromServer),
  rp: {
    name: "My Application",
    id: "sub.example.com", // optional, default is domain of origin
  },
  user: {
    id: Uint8Array.fromHex("123e4567-e89b-12d3-a456-426614174000".replaceAll("-", "")), // "id" value is also called "userHandle"
    name: "user@mail.example.com",
    displayName: "user",
  },
  pubKeyCredParams: [ // place by priority
    { alg: -8, type: "public-key" }, // Ed25519
    { alg: -7, type: "public-key" }, // ES256
    { alg: -257, type: "public-key" } // RS256
  ],
  attestation: "none", // optional, default is "none"
  attestationFormats: [], // optional, default is []
  authenticatorSelection: { // optional
    authenticatorAttachment: "cross-platform", // optional, default includes both "platform" and "cross-platform"
    requireResidentKey: false, // deprecated optional, default is false; if residentKey is "required", should be true
    residentKey: "preferred", // optional, default is "discouraged"
    userVerification: "preferred", // optional, default is "preferred"
  },
  excludeCredentials: { // optional
    id: Uint8Array.fromBase64(credentialIdBase64StringFromServer),
    type: "public-key",
    transports: ["usb", "ble", "nfc"] // optional
  },
  timeout: 60000 // optional
};

const credential: PublicKeyCredential = await navigator.credentials.create({
  publicKey: publicKeyCredentialCreationOptions
});

サーバーへ渡す情報

console.log(credential);
// ----------
{
  id: "ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...", // Credential ID
  rawId: ArrayBuffer(59), // bytes of id
  response: { // response from authenticator
    clientDataJSON: ArrayBuffer(121),
    attestationObject: ArrayBuffer(306),
  },
  type: "public-key",
  clientExtensionResults: { // metadata from user agent (as client)
    credProps: {
      rk: true // if residentKey is "preferred", the server knows here credential is discoverable or not
    }
  }
}
// ----------

const attestation: AuthenticatorAttestationResponse = credential.response;
console.log(attestation);
// if deserialize attestation, structure should be as below
// ----------
{
  clientDataJSON: {
    type: "webauthn.create",
    challenge: "base64url encoded challenge",
    origin: "https://sub.example.com",
    crossOrigin: false
  },
  attestationObject: {
    fmt: "packed",
    attStmt: {
      alg: -8,
      sig: "byte string of attestation signature",
      x5c: [
        "attestnCert bytes of Subject, Public Key, Issuer, Signature", // "Public Key" is a authenticator device's own public key
        "intermediateCACert(bytes)", // optional
        "rootCACert(bytes)" // optional
      ]
    },
    authData: "bytes of rpIdHash(32B),flags(1B),signCount(4B),attestedCredentialData,extensions"
      // attestedCredentialData is bytes of aaguid(16B),credentialIdLength(2B),credentialId,credentialPublicKey
  }
}
// ----------

データ構造定義

その他

user.idに何を指定するか

それだけでは人が個人を特定できない情報を指定する。具体的には実名や電話番号等ではなく、サーバー側でユーザー識別に用いているランダム文字列やUUIDを指定する。

residentKey: "preferred"の場合の登録方法通知

認証器がDiscoverable Credentialとして登録したかどうかはPublicKeyCredentialに含まれるclientExtensionResults.credPropsで判別する。この中にrkプロパティが存在し、trueであればDiscoverable Credentialとして登録されている。コード中でclientExtensionResultsを取得するにはメソッドを使用する。上記コードではcredential.getClientExtensionResults()で取得可能。

サーバー側での認証器証明用公開鍵の取得

基本的にサーバーは送信されてきたPublicKeyCredential中のattestationObject.attStmt.x5cに含まれる公開鍵から署名検証をする。外部情報に頼る必要はない。

サーバー側での認証器検証

送信されてきた情報での署名検証だけだと、信用できる相手(認証器)ではない場合にセキュリティホールとなってしまう。そこをカバーするための手段としてFIDO Allianceが認証器について一元管理し、外部情報として提供している。
金融機関や企業内部での認証など、厳格な管理が必要になる場合は認証器の制限やそのようなサービスを用いる等して、認証器の検証を厳密に実施する。そこまで厳格にする必要がない一般のWebサービスレベルであればそこまでの検証をしないことも多い。

認証時

認証器の情報取得

const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
  challenge: Uint8Array.fromBase64(randomBase64StringFromServer),
  rpId: "sub.example.com", // optional, default is current origin's domain
  allowCredentials: [ // optional, if discoverable credential, allowCredentials should be omitted
    {
      id: Uint8Array.fromBase64(credentialIdBase64StringFromServer),
      type: "public-key",
      transports: ["usb", "ble", "nfc"], // optional
    }
  ],
  userVerification: "preferred", // optional, default is "preferred"
  timeout: 60000, // optional
}

const credential: PublicKeyCredential = await navigator.credentials.get({
  publicKey: publicKeyCredentialRequestOptions,
  mediation: "silent" // optional, default is "options"; if "conditional", will be Conditonal UI mode
});

サーバーへ渡す情報

console.log(credential);
// ----------
{
  id: "ADSUllKQmbqdGtpu4sjseh4cg2TxSvrbcHDTBsv4NSSX9...", // Credential ID
  rawId: ArrayBuffer(59), // bytes of id
  response: { // AuthenticatorAssertionResponse
    authenticatorData: ArrayBuffer(191),
    clientDataJSON: ArrayBuffer(118),
    signature: ArrayBuffer(70),
    userHandle: ArrayBuffer(10), // same with user.id at registeration
  },
  type: "public-key"
}
// ----------

データ構造定義

その他

認証器に登録したユーザー情報の変更

user.nameuser.displayNameで登録した情報は以下のようにSignal APIを使用することで更新することが可能。ただし現時点でFireFoxは対応していない。

if (PublicKeyCredential.signalCurrentUserDetails) {
  await PublicKeyCredential.signalCurrentUserDetails({
    rpId: "example.com",
    userId: "M2YPl-KGnA8", // base64url-encoded user ID
    name: "a.new.email.address@example.com", // username
    displayName: "Maria Sanchez",
  });
}

サーバー側でのカウンター検証によるクローン検知

クローン検知用の情報としてsignCountauthenticatorDataに含まれており、認証器の証明用秘密鍵で署名されている。signCountは署名操作を実施する度にインクリメントされ、署名データに含まれて送信されてくるため、サーバー側で前回のsignCountと比較することでクローンされた認証器の可能性を察知することが可能。ただし、プラットフォーム認証器も含む一部の認証器はサポートされておらず、認証器の証明用秘密鍵まで侵害されている場合は検知できない。

@simplewebauthn/browserは何のために使用するか

バイナリを含むデータ変換やブラウザによって微妙に異なる挙動を吸収するため。

参考文献

Discussion