目次
※事前にざっくり解説記事に目を通しておくことをおすすめします。
- 全体の手順を押さえましょう
- 認証情報を登録する処理について
- ステップ1:RPからチャレンジを受け取る
- ステップ2:クライアントの認証器に登録する
- ステップ3:認証器から認証情報を受け取る
- ステップ4:RPに認証情報を送信し、RP側で検証
- 認証情報を用いて認証する処理について
- ステップ5:RPからチャレンジを受け取る
- ステップ6:クライアントの認証器で認証する
- ステップ7:認証器から認証情報を受け取る
- ステップ8:RPに認証情報を送信し、RP側で検証
- 実装を楽にするためのライブラリ
- おわりに
全体の手順を押さえましょう
ざっくり解説記事でも書きましたが、WebAuthn認証は、「認証情報を登録する」フェーズと「認証情報に基づき認証する」フェーズとに大別されます。
図におけるステップ2やステップ6で「認証器」にアクセスし、ステップ4やステップ8で「サーバー (Relying Party
またはRP
と呼ぶ)」に検証させます。その間を受け持つ「クライアント」は Web ブラウザとJavaScript
で処理します。
自力でクライアント・サーバーを実装する際は、どのようなデータがやりとりされ、どのような手順でAPIを呼び出し、また検証するかを理解することが重要です。実装する際は、この記事だけでなく、MDNにおける解説記事やWebAuthn APIの仕様書も参照することをお勧めします。
補足:
Win32 APIs for WebAuthn standardやAndroid FIDO2 APIのように、OS固有のAPIにアクセスすることで、クライアントを実装することもできます。ただ今回は、W3Cによって標準化された、Web ブラウザとJavaScript
で処理されるWebAuthn API
を説明します。
認証情報を登録する処理について
(MDNにおけるWebAuthnの説明ページ より引用)
ステップ1:RPからチャレンジを受け取る
RP
は内部で「チャレンジ」と呼ばれるランダムなバイト列を生成し、それをWeb
ブラウザに送信します。特にプロトコルは指定されていませんので、Web
サイト内に埋め込んでも、REST API
の応答としても、RFC 2549を用いても構いません。W3Cの仕様書によると、暗号学的に安全な乱数で生成し、16バイト以上にすることが推奨されています。
このチャレンジを送る際は、どのユーザーに対する登録を行いたいかの情報がクライアントから送られているはずですので (ステップ0)、送るチャレンジとユーザー情報とを、紐付けて保存しておきます。
また、クライアントを複数のRP
に対応させる際は、クライアント側で「RPの情報」を決め打ちにできませんので、「RP
のID
」「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で実装する場合、attestationObject
とclientDataJSON
はバイナリデータですので、送りやすいようにBase64エンコードなどしておきましょう。
クライアントから渡される情報の中で、id
は単に認証情報を識別するためのキーに過ぎません。そのため、データを読み取って検証に活用するのは、attestationObject
とclientDataJSON
になります。
clientDataJSON
はただのJSON文字列なのでパースすると、例えば次のようなデータが得られるでしょう。MDNに書かれている仕様を読みながら、「challengeがステップ1で生成したチャレンジと一致するか」「originがRPサーバーそのものか」を確認してください。
{
"challenge": "mc84jrv89gjvMr8jvGB",
"origin": "https://example.com:8080",
"type": "webauthn.create"
}
attestationObject
はCBOR形式でエンコードされていますので、別途ライブラリを用意してデコードしましょう。デコード後のバイナリを読み取る方法については、次のようなW3Cの仕様書を参照してください。
- https://www.w3.org/TR/webauthn/#sctn-generating-an-attestation-object
- https://www.w3.org/TR/webauthn/#fig-attStructs
頑張ってデコードすると、attestationObject
には次のような情報が含まれています。
fmt
:attStmt
がどのような形式で書かれているか示す文字列attStmt
:署名情報などが含まれるバイナリデータauthData
:認証器の情報が含まれるバイナリデータ
authData
は最低でも37バイト以上あり、MDNに書かれた仕様を読みながらデコードすることで、「RP
のIDについてのハッシュ値」「ユーザーがちゃんと認証処理したかを示すフラグ」「公開鍵情報」などが含まれています。これらも検証に利用できますし、id
と紐付けしておけば、WebAuthn認証の認証処理に利用可能です。
なお、attStmt
のフォーマットはfmt
の値によって変わりますが……全部を実装するのは大変ですので、外部のライブラリに頼るか、W3Cの仕様を読んで頑張りましょう。
認証情報を用いて認証する処理について
(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() |
id とattestationObject とclientDataJSON |
navigator.credentials.get() |
id とauthenticatorData とclientDataJSON とsignature |
ステップ8:RPに認証情報を送信し、RP側で検証
ステップ4と同様に、認証に必要な情報をRP
に送信します。authenticatorData
とclientDataJSON
とsignature
はバイナリデータですので、送りやすいようにBase64エンコードなどしておきましょう。
clientDataJSON
の検証はステップ4と同様です。
authenticatorData
はauthData
と同様のフォーマットで定義されています。「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 |
おわりに
ざっくり解説記事でも書いたように、わざわざ複雑な実装を手書きしなくとも、IDaaS
やOIDC
を活用すれば、WebAuthn
(などを用いた安全な認証) を手軽に享受できます。
しかし、あえてフルスクラッチで実装することで、ただテキストを読むだけではよく分からなかった、ロジックの詳細をしっかり学ぶことができました。
なお、今回の記事のために作成したコードを、サンプルコードとしてGitHubで配布します。
- 閲覧数 191
コメントを追加