パスワードなし認証を実現する「WebAuthn (PassKey)」についてがっつり解説する

  •  
 
トビウオ に投稿

目次

※事前にざっくり解説記事に目を通しておくことをおすすめします。

  • 全体の手順を押さえましょう
  • 認証情報を登録する処理について
    • ステップ1:RPからチャレンジを受け取る
    • ステップ2:クライアントの認証器に登録する
    • ステップ3:認証器から認証情報を受け取る
    • ステップ4:RPに認証情報を送信し、RP側で検証
  • 認証情報を用いて認証する処理について
    • ステップ5:RPからチャレンジを受け取る
    • ステップ6:クライアントの認証器で認証する
    • ステップ7:認証器から認証情報を受け取る
    • ステップ8:RPに認証情報を送信し、RP側で検証
  • 実装を楽にするためのライブラリ
  • おわりに

全体の手順を押さえましょう

ざっくり解説記事でも書きましたが、WebAuthn認証は、「認証情報を登録する」フェーズと「認証情報に基づき認証する」フェーズとに大別されます。

image5image6

図におけるステップ2やステップ6で「認証器」にアクセスし、ステップ4やステップ8で「サーバー (Relying PartyまたはRPと呼ぶ)」に検証させます。その間を受け持つ「クライアント」は Web ブラウザとJavaScriptで処理します。

自力でクライアント・サーバーを実装する際は、どのようなデータがやりとりされ、どのような手順でAPIを呼び出し、また検証するかを理解することが重要です。実装する際は、この記事だけでなく、MDNにおける解説記事WebAuthn APIの仕様書も参照することをお勧めします。

補足:
Win32 APIs for WebAuthn standardAndroid FIDO2 APIのように、OS固有のAPIにアクセスすることで、クライアントを実装することもできます。ただ今回は、W3Cによって標準化された、Web ブラウザとJavaScriptで処理されるWebAuthn APIを説明します。

認証情報を登録する処理について

image8 (MDNにおけるWebAuthnの説明ページ より引用)

ステップ1:RPからチャレンジを受け取る

RPは内部で「チャレンジ」と呼ばれるランダムなバイト列を生成し、それをWebブラウザに送信します。特にプロトコルは指定されていませんので、Webサイト内に埋め込んでも、REST APIの応答としても、RFC 2549を用いても構いません。W3Cの仕様書によると、暗号学的に安全な乱数で生成し、16バイト以上にすることが推奨されています。

このチャレンジを送る際は、どのユーザーに対する登録を行いたいかの情報がクライアントから送られているはずですので (ステップ0)、送るチャレンジとユーザー情報とを、紐付けて保存しておきます。

また、クライアントを複数のRPに対応させる際は、クライアント側で「RPの情報」を決め打ちにできませんので、「RPID」「RPの名前」「RPが対応する電子署名アルゴリズム」も送っておくと親切でしょう。詳しくは、MDNのPublicKeyCredentialCreationOptionsのページに書かれています。

ステップ2:クライアントの認証器に登録する

ステップ1でチャレンジ等を受け取ったので、それらを元に認証器に情報を渡します。渡すためのAPIはnavigator.credentials.createです。値は非同期のPromiseで返ってきますので、thenしたりawaitしたりなどして処理します。

const credentials = await navigator.credentials.create({
  // PublicKeyCredentialCreationOptions型のデータ
});

例えばMDNに載っているサンプルについて、引用しつつ詳細に解説すると次のようになるでしょう。

const publicKey = {
 // RPから送られてきたチャレンジコード
  challenge: new Uint8Array([117, 61, 252, 231, 191, 241, ...]),
  // RPのIDは「acme.com」で、その名前は「ACME Corporation」。
  // RP IDは、そのRPのドメイン名でなければならない。
  // WebAuthn認証はローカル環境かSSL/TLS接続でないと使えないので、
  // 開発中は「localhost」を使うのが通例。
  rp: { id: "acme.com", name: "ACME Corporation" },
  user: {
    // ユーザー情報。ユーザーIDは64バイトまで使用できる
    id: new Uint8Array([79, 252, 83, 72, 214, 7, 89, 26]),
    name: "jamiedoe",
    displayName: "Jamie Doe"
  },
  // どのような署名アルゴリズムが使えるか。例えばこの場合は「ES256」
  pubKeyCredParams: [ {type: "public-key", alg: -7} ]
}

navigator.credentials.create({ publicKey })

ステップ3:認証器から認証情報を受け取る

navigator.credentials.create()の戻り値はPromise<PublicKeyCredential> | null型です。正常に処理できている場合はnullになりませんので、awaitして返ってくるPublicKeyCredential型に着目すればOKです。このうち認証に必要なのは、idプロパティとresponse.attestationObjectプロパティとresponse.clientDataJSONプロパティです。

ただ、PublicKeyCredentialの型定義は少々ややこしくて……responseプロパティとか……。JavaScriptより型に厳密なTypeScriptで書く場合、型を適宜キャストしたりして乗り切ってください。

ステップ4:RPに認証情報を送信し、RP側で検証

ステップ1でプロトコルが指定されていなかったように、ステップ4や、後述するステップ5・ステップ8もプロトコルが指定されてません。仮にREST APIで実装する場合、attestationObjectclientDataJSONはバイナリデータですので、送りやすいようにBase64エンコードなどしておきましょう。

クライアントから渡される情報の中で、idは単に認証情報を識別するためのキーに過ぎません。そのため、データを読み取って検証に活用するのは、attestationObjectclientDataJSONになります。

clientDataJSONはただのJSON文字列なのでパースすると、例えば次のようなデータが得られるでしょう。MDNに書かれている仕様を読みながら、「challengeがステップ1で生成したチャレンジと一致するか」「originがRPサーバーそのものか」を確認してください。

{
  "challenge": "mc84jrv89gjvMr8jvGB",
  "origin": "https://example.com:8080",
  "type": "webauthn.create"
}

attestationObjectCBOR形式でエンコードされていますので、別途ライブラリを用意してデコードしましょう。デコード後のバイナリを読み取る方法については、次のようなW3Cの仕様書を参照してください。

頑張ってデコードすると、attestationObjectには次のような情報が含まれています。

  • fmtattStmtがどのような形式で書かれているか示す文字列
  • attStmt:署名情報などが含まれるバイナリデータ
  • authData:認証器の情報が含まれるバイナリデータ

authDataは最低でも37バイト以上あり、MDNに書かれた仕様を読みながらデコードすることで、「RPのIDについてのハッシュ値」「ユーザーがちゃんと認証処理したかを示すフラグ」「公開鍵情報」などが含まれています。これらも検証に利用できますし、idと紐付けしておけば、WebAuthn認証の認証処理に利用可能です。

なお、attStmtのフォーマットはfmtの値によって変わりますが……全部を実装するのは大変ですので、外部のライブラリに頼るか、W3Cの仕様を読んで頑張りましょう。

認証情報を用いて認証する処理について

image9 (MDNにおけるWebAuthnの説明ページ より引用)

ステップ5:RPからチャレンジを受け取る

ステップ1と同様に、リプレイ攻撃防止のためのチャレンジをRPから受け取ります。ステップ1と異なり、RPは「どのユーザーに対する認証を行いたいか」をクライアントから送られているはずですので、ユーザー情報に対応した認証ID (ステップ3やステップ4におけるidプロパティのこと) も、チャレンジと併せて送る必要があります。

ステップ6:クライアントの認証器で認証する

ステップ2と同様に、認証器に情報を渡します。渡すためのAPIはnavigator.credentials.getです。値は非同期のPromiseで返ってきますので、thenしたりawaitしたりなどして処理します。

const credentials = await navigator.credentials.get({
  // PublicKeyCredentialRequestOptions型のデータ
});

こちらについても、MDNに載っているサンプルについて、引用しつつ詳細に解説すると次のようになるでしょう。

const publicKey = {
  // RPから送られてきたチャレンジコード
  challenge: new Uint8Array([139, 66, 181, 87, 7, 203, ...]),
  // RPのIDは「acme.com」
  rpId: "acme.com",
  allowCredentials: [{
    type: "public-key",
    // ステップ3で生成した認証情報のID
    id: new Uint8Array([64, 66, 25, 78, 168, 226, 174, ...])
  }],
  // WebAuthnのユーザー認証を必ず行わせたい場合は「required」
  // 必ず行わせなくても問題ない場合は「preferred」
  // むしろ行わせたくない場合は「discouraged」
  userVerification: "required",
}

navigator.credentials.get({ publicKey })

補足:
userVerificationを省略すると、preferred指定した扱いになります (MDNの記述)。一見すると乱暴に見えますが、これはWebAuthn未対応デバイスへの配慮と言えます。「Webauthn認証が使えないデバイスは認証を通さない、もしくはパスワード認証など他で代用する」サイトの場合はrequiredで問題ありませんが、例えば「WebAuthn認証が通らない場合でも、代替となるダイアログをクリックすればOKとする」場合はpreferredかなと。

ステップ7:認証器から認証情報を受け取る

  • navigator.credentials.get()の戻り値はPromise<PublicKeyCredential> | null型です。navigator.credentials.create()の戻り値と同じ……かと思いきや、なんと一部のプロパティの型が違うといった罠があります。MDNにも記載があるように、拾える情報が次のように変更されています。
メソッド名 拾える情報
navigator.credentials.create() idattestationObjectclientDataJSON
navigator.credentials.get() idauthenticatorDataclientDataJSONsignature

ステップ8:RPに認証情報を送信し、RP側で検証

ステップ4と同様に、認証に必要な情報をRPに送信します。authenticatorDataclientDataJSONsignatureはバイナリデータですので、送りやすいようにBase64エンコードなどしておきましょう。

clientDataJSONの検証はステップ4と同様です。

authenticatorDataauthDataと同様のフォーマットで定義されています。「RPのIDについてのハッシュ値」「ユーザーがちゃんと認証処理したかを示すフラグ」「公開鍵情報」などを読み取り、それぞれに矛盾がないかを検証していきます。詳しい手順はW3Cの仕様書に沿って行うとよいでしょう。

実装を楽にするためのライブラリ

……以上の実装をフルスクラッチするのは大変ですので、サードパーティーのライブラリを使うのが一般的です。事前に調べたサンプルを挙げておきますが、以前調査した人による、WebAuthnライブラリの一覧ページも参考になるでしょう。

クライアントサイド

ライブラリ 対応言語 対応フレームワーク Fork Star
SimpleWebAuthn TypeScript - 129 1.5k
Android WebAuthn Authenticator Library Java Android 20 110
WebAuthnKit-iOS Swift iOS 30 102

サーバーサイド

ライブラリ 対応言語 対応フレームワーク Fork Star
WebAuthn4J Java - 70 433
WebAuthn4J Spring Security Java Spring Framework 46 190
java-webauthn-server Java - 142 465
SimpleWebAuthn TypeScript - 129 1.5k
fido2-lib JavaScript Node.js 120 406
webauthn-ruby Ruby Ruby on Rails 54 652
webauthn Golang - 69 760
Wax Elixir - 17 186
py_webauthn Python - 167 851
django-allauth Python Django 3k 9.5k
webauthn-framework .NET - 54 409
webauthn-server PHP - 12 51

おわりに

ざっくり解説記事でも書いたように、わざわざ複雑な実装を手書きしなくとも、IDaaSOIDCを活用すれば、WebAuthn (などを用いた安全な認証) を手軽に享受できます。

しかし、あえてフルスクラッチで実装することで、ただテキストを読むだけではよく分からなかった、ロジックの詳細をしっかり学ぶことができました。

なお、今回の記事のために作成したコードを、サンプルコードとしてGitHubで配布します。

https://github.com/ipride-jp/webauthn-study

コメントを追加

プレーンテキスト

  • HTMLタグは利用できません。
  • 行と段落は自動的に折り返されます。
  • ウェブページのアドレスとメールアドレスは自動的にリンクに変換されます。
CAPTCHA
この質問はあなたが人間の訪問者であるかどうかをテストし、自動化されたスパム送信を防ぐためのものです。
画像
WebAuthnによる登録処理 by MDN
WebAuthnによる認証処理 by MDN