はじめに
ハローワールド
2019年3月5日、Web Authentication(以下WebAuthn))が勧告化されました(W3CとFIDO Alliance - パスワード不要の安全なログインが勧告化に)。
前々から興味があった話題でしたが、これを期に調べてみようということで、本記事は調べた事柄についてのまとめ(というか雑記)となります。
私自身は、WebAuthnについて本気出して調べ出したのはここ数日ということもありますので、本記事には間違いが多くある可能性もあります。間違いに関する指摘、批判や意見、質問等大歓迎でございます。
パパっと指紋認証をやって新時代の幕開けを肌で感じたい、という方は試してみように飛びましょう。
環境
本記事内のすべてのサイト、プログラムを試した環境は以下となります
- macOS Mojave
- Google Chrome 72.0
- MacBook Pro 13-inch 2017
- 認証デバイス: MacbookProにくっついてる指紋認証デバイス
WebAuthnとは
WebAuthnは一言で言うなら、生体認証を行うためFIDO2と呼ばれる仕様の構成要素であり、クライアントとサーバー間の仕様を決めるものです。
詳しくWebAuthnを知るためには、まずFIDOを知らなければなりません。
FIDOとは
FIDOとは、より安全な認証技術の標準を策定するために設立された団体のFIDO Alliace(ファイドアライアンス)、またはこの団体が策定した標準技術であるFIDO、FIDO2のことを指します。特に脈略もなく出てきた場合は、後者のFIDOやFIDO2のことを指している場合が多いです。
このFIDO、FIDO2というものはパスワードを使わない、いわゆる“生体認証”を用いたオンライン認証技術の仕様です。
FIDOを利用することで、生体認証を用いたパスワードレス、または二段階認証でのセキュリティの強化が期待されます。
指紋認証を始めとしたデバイスとクライアントアプリケーションやブラウザとの通信、そしてブラウザからWebアプリケーションとの通信でのデータの仕様などが主に纏まっています。
FIDO2におけるWebAuthn
FIDO2では、大雑把に分けるとCTAP2(Client to Authenticator Protocol)とWebAuthnと呼ばれる2つの仕様に分かれます。
何がどの部分の仕様を担っているかは下の図と表を見ればなんとなくわかっていただけると思います。
|CTAP2 | WebAuthn
---|-----|----------
仕様の範囲 | クライアントから認証デバイス | サーバーからクライアント(ブラウザ)
策定団体 | FIDO Alliance | W3C(とFIDO)
すなわち、WebAuthnとはFIDO2におけるWebアプリケーションとクライアント、細かく言うならブラウザとを繋ぐ仕様です。
今のところsafari以外の主要ブラウザはWebAuthnに対応しているみたいです。また、iPhoneは全く対応していないようです。
試してみよう
WebAuthnを利用したデモサイトはWebAuthn Demo
とGoogle先生で調べてみると、結構出てきます。
おすすめのデモサイトはこちらです。
WebAuthnのデータフローをアニメーションでわかりやすく示してくれるので、見ていて楽しいです。
使い方は、name
に好きな名前を入力して、register
ボタンを押すだけ!
また、私も勉強のために作っていたデモサイトが以下になります。いろんな情報が表示されるので、他のデモサイトに比べて画面がうるさいです。
https://github.com/sa2taka/WebAuthnDemo
使い方は簡単。Dockerがインストールされてる端末で以下を実行します。
$ git clone https://github.com/sa2taka/WebAuthnDemo
$ cd WebAuthnDemo
$ docker-compose up
そしてlocalhost:8080
にアクセスします。
情報量が凄まじいですが、実際に必要なのは上のUserName
とその下の登録
と認証
ボタンです。
ちなみに、なんでわざわざdockerを使って、nginxサーバーを立ててlocalhostにアクセスするかというと、WebAuthn自体がhttpsまたはlocalhostの環境以外で動かないような設計になっているためです。最初は普通にローカルファイルにアクセスして実行しようとして30分ぐらいなぜ動かないのかわかりませんでした...。
UserName
に適当な入力を入れて(実は入力しなくても良い)、登録
ボタンを押すと以下のようなものが現れると思います(Chromeでは)。
私はMacbookProにくっついてる指紋認証デバイスを使用するので、内蔵センサーをクリックします。
そうすると、本人確認が求められます。YubiKeyの場合は多分違いますが、Macbookの内蔵センサーの場合は、指紋認証以外にパスワードを使用して認証することもできます。指がない人も安心ですね!
すると、様々な情報が表示されます。
この情報については後でまとめます。
今度は認証
ボタンをクリックしてみましょう。
今度は、デバイスの選択画面を飛ばして、いきなり認証に入ります。
認証も同じようにやると値が埋まります...が、パット見idが一緒だなぁぐらいしかわからんと思います。
WebAtuhnについての雑記
今回はWebAuthnを利用して、登録、認証のフローを簡易的に再現しました。当然、本番では登録や認証の結果を用いて改ざんのチェックや証明書の検証などを行わなければなりません。が、本記事ではそういった細かい部分の説明は抜きにして(何より実装が面倒くさい)、WebAuthnを利用したときの流れを詳しくまとめてみます。
WebAuthnでは、上記のデモでも示したように、「登録」と「認証」の2つのフローがあります。
実際の流れは非常に似ているのですが、細かい部分で異なっているため、2つのフローという目線で分割して、それぞれ詳説していきます。
用語説明
用語 | 説明 |
---|---|
RP(Relying Party) | WebAuthnを利用して、ユーザーの登録や認証を行うエンティティ |
Authenticator | 認証を行うエンティティ。本記事では、認証デバイスと表記していたもの |
登録フロー
登録の大まかな流れ
登録のフローは以下の様になっています。
画像はW3CのWebAuthn公式文章より転載しております。
- チャレンジ(後術)、ユーザーの情報、RPの情報などを表したPublicKeyCredentialCreateionOptionsを生成し、それを引数にnavigator.credentials.createメソッドを叩きます
- 受け取ったチャレンジ、ユーザー情報、RP情報などを基にAutenticatorへと送信します
- Authenticatorによる認証、データの処理
- Authenticatorからブラウザへ情報を送信します
- Authenticatorから受け取った情報をRPに送信します
- データの改ざんのチェックや検証を行い、正常であればデータを保存します
WebAuthnのフローではあるのですが、RPの実装者で意識する必要があるのは1, (5, )6の部分です。
フローについて詳しく調べていきましょう。
1. navigator.credentials.createメソッドを叩く
navigator.credentialsとは、実際にはWebAuthnとは違うものではあり、Credential Managementに関するインターフェースらしいです。
Credential Managementは上記ドラフトの概要曰く、
This specification describes an imperative API enabling a website to request a user’s credentials from a user agent, and to help the user agent correctly store user credentials for future use.
ガバ意訳
この仕様書は、Webサイトがユーザーエージェントからユーザーの資格情報を要求し、資格情報を正しく保存できる必須のAPIを説明しています
WebAuthnの例で言うなら、Webサイトからユーザーエージェント(ブラウザ等)を通してAuthenticatorに認証を託すためのAPI、って感じですかね。わかりません。
オプションについて
このメソッドは一つのオプションを渡します。
私のプログラムではこんな感じになってます。
以下、オプションの各項目について説明しますが、コメントのみで理解できそうな部分は省きます。
const credentialCreationOptions = {
'challenge': challenge, // 登録時、認証時毎回違う値を入れる
'rp': { // Relying Partyの情報、nameのみ必須、この他に、iconというメンバーもつけられる
'id': 'localhost', // 私の環境ではlocalhost以外だと失敗した。省略する場合はドメインになるが、省略しない場合もchromeではドメインに強制されるようだ
'name': 'localhost webAuthn Demo'
},
'user': { // 認証のユーザーの情報、全て必須。この他に、iconというメンバーもつけられる
'id': strToBin(name),
'name': name,
'displayName': name
},
'pubKeyCredParams': [ // 必須、
{ 'type': 'public-key', 'alg': -7 },
{ 'type': 'public-key', 'alg': -257 }
],
timeout: 60000, // 必須ではない。タイムアウトまでの時間[ミリ秒]
attestation: 'direct' // 認証に関するオプション
// その他にもメンバーが選択できるけどここでは特に説明しません
// といっても残りの一つは拡張用のものを除いてひとつなんですどね...
}
challenge
詳しくはWebAuthnの仕様書の説明を見ればわかると思いますが、リプレイ攻撃の対策のために利用される値で、認証フローでも同様に毎回ランダムな値を入れる必要があります。
曰く16byte以上のデータであることが望ましいと書いてありますので、今回のプログラムでは32byteのランダムな値を入れています。ちなみに、ページ上に表示しているのはBase64でエンコードしたデータです。
上記の理由のためchallengeは必須の項目です。
pubKeyCredParams
利用できる鍵の種類をここで指定します。
typeは鍵の種類ですが、現在は'public-key'
、つまり公開鍵しか選択できません。
algは鍵のアルゴリズムです。数字はこちらを基に入れます。-7はECDSA w/ SHA-256、-257はRSASSA-PKCS1-v1_5 w/ SHA-256ですって。
attestation
正直私もよくわかりませんが、3つの値を取ります。
- none(デフォルト)
- indirect
- direct
こちらの値により戻り値のattestationDataの値に変化があり、Authenticatorが本当に信頼されたものなのかの認証を行う方法も変わってくるみたいですが、正直わからないため、そのデータに関してはYahooのテックブログに任せます。
noneはRPがAuthenticatorの認証について関心がない場合、indirectはRPはAuthenticatorの認証したいが、AttestationData自体はクライアントが好きなようにしていいよ。directはAttestationDataをAuthenticatorから直接取得したいよ、って場合に選択する感じなんですかね。わからないです、私には。
実際にここの値を変えながらいろいろやってみると、noneの時だけ「読み取りを許可しますか?」というような文が出てこなくなります。
5. Authenticatorから受け取った情報をRPに送信
ここでは、Authenticatorの情報を受け取りますが、どのようなデータを受け取るか少し見てみましょう。
- id: Authenticatorが作成した公開鍵のIDをBase64エンコードしたもの
- rawId: Authenticatorが作成した公開鍵のIDの生データ
- type: データのタイプ。現在はpublic-keyのみ
- response: 公開鍵などのデータ
- response.clientDataJSON: Authenticatorに渡した情報をJSONシリアライズ化したもの
- response.attestationObject: 公開鍵、Authenticatorの情報などをCBORエンコードしたもの
CBORエンコードという聞き慣れない(と思われる)言葉が出てきましたが、一応RFC7049で規定されている物で、バイナリで表現するJSONみたいなやつです(雑すぎる説明)。
データの詳しい説明は6.にて。
6.データのチェック
データのチェックは5.で受け取ったデータを基に行っていきます。と言っても詳しいことは解説しない(できない)ので、軽く触っていくだけにします。
また、データのチェックに当たり、MDS(MetaData Service)で参照できるAuthenticatorの情報とも比較する必要があるらしいです(参考文章)。
clientDataJSON
受け取ったデータの中のclientDataJSONは先程も述べたように、渡した情報が主に載っています。
clientDataJsonの一例は以下になります。
{
"challenge": "MPd7okv4TINipDpKO1cfv7fBNOYAKDTe0QCzZu0KinM",
"new_keys_may_be_added_here": "do not compare clientDataJSON against a template. See https://goo.gl/yabPex",
"origin": "http://localhost:8080",
"type": "webauthn.create"
}
challenge
これは登録時に送ったchallengeがそのまま戻ってきます。このchallengeが送る前と同じであることを確認するのが一つのチェック項目ですね。
実際のフローでは、このchallengeとシステム側のidを紐づけて、データベースに保存しておいて認証を行う、といったフローが必要でしょうね。
origin
これは字のごとくだと思います。
今回の環境ではnginxを8080に(わざと)フォワーディングしてやっているので、localhost:8080というoriginが取れてますね。
type
これはパッと見て分かる通り、データのタイプを表してます。createメソッドを発行した場合はwebauthn.create
。getメソッドの場合は同様にwebauthn.get
が戻って来ます。
new_keys_may_be_added_here
突然メンバーが増えてちょっと初見時驚きましたが、
do not compare clientDataJSON against a template. See https://goo.gl/yabPex
clientDataJsonをテンプレートと比較するなよ(新しい鍵がきっと増えるから)。https://goo.gl/yabPexを見な
という親切なメッセージがランダムで表示される(毎回は表示されない)ようです。テンプレートってなんのことかわかりませんが、多分テンプレート文字列に各情報を埋め込んで、文字列で比較するなよ、ってことなんでしょうか。
attestationObject
attestationObjectはAuthenticatorに関する情報が入っています。
attestationObjectの一例は以下になります。
ちなみに元データはCBORエンコードされていますので、どうにかしてCBORデコードしてください。
{
"fmt": "packed",
"attStmt": {
"alg": -7,
"sig": {
"0": 48,
"1": 69,
...
}
},
"authData": {
"rpidHash": {
"0": 73,
"1": 150,
...
},
"flags": {
"up": true,
"uv": true,
"at": true,
"ed": false,
"buffer": {
"0": 69
}
},
"counter": {
"0": 92,
"1": 169,
"2": 217,
"3": 173
},
"aaguid": {
"0": 173,
"1": 206,
...
},
"credentialId": {
"0": 0,
"1": 9,
"2": 44,
...
},
"credentialId_base64": "AAksd8mlJklL2LELiGINEtb02FNjJETwweP5W+V/Ryrt6+HBWS/hn+dozAfNQ8H8HDyW9tim",
"credentialPublicKey": {
"kty": 2,
"alg": -7,
"crv": 1,
"x": "[c8, f5, 7d, b9, 2a, 8, ca, 27, 86, 9b, c9, 7a, ce, 7d, 9a, ca, 2f, 73, 98, 9e, bf, 13, 52, 3, 1d, b6, a0, c8, ee, 3b, 33, 81]",
"y": "[78, 23, e3, 74, ed, 4c, 49, d, 87, 86, 5f, b4, 5d, 4a, 7a, b5, 20, 1c, dc, 32, 88, 27, ad, c3, cc, 3a, 30, 28, 59, 9a, 90, aa]"
}
}
}
なんか...長くない?
fmt
これはAttestation Statement Formatという検証のフォーマットがあり、そのフォーマット形式を表しています。今回はpacked
なので、WebAuthnに最適化されたフォーマット(らしい)です。
attStmt
Attestaation Statementのことを表しており、上記のfmtによってここの値が変化します。
それぞれのフォーマットで認証の方法は異なります。
authData
この中にも検証が必要な様々なデータが入っていますので、一つ一つ軽く触れてみましょう。
authData.rpidHash
これはrpidをsha256ハッシュしたものが入ります。今回の場合はlocalhostをsha256でハッシュ化したものですね。
authData.flags
これは以下の4つのフラグを示しています。
- User Present(UP): ユーザーがいるかいないかを表している(現在は1bitしか使っていないが2bit保有している)
- User Verified(UV): ユーザーが検証されているかどうか
- Attested credential data(AT): Authenticatorが検証済みの信任状データを追加したかどうか
- Extension data included(ED): 拡張データがあるかどうか
今回は上3つがすべてがTrueですね。実際はUPのように将来的に2bitになる可能性や、UVとATの間に3bit空いているのでflagが追加される可能性もあります。
authData.counter
Authenticatorの署名カウンターというものを表しているらしいです。詳細は調べてないのでわからないですが、Authenticatorが認証が成功するたびに適当な正の値が足されていくらしいです。
これをRP側では認証時に保存しておき、次回の認証でこの値と格納した値を比べます。同じAuthenticatorで認証しているはずなのに、この値が格納している値より小さい場合は、複製されたAuthenticatorで有ることがわかり、Authenticatorの複製を検出できる、ということらしいです。
ここからわかることは、認証のデータベースにはAuthenticatorのidやこのcounter値も保存しておかなきゃいけないってことですね(実際にこの部分の認証までは実装していないので不明)。
authData.aaguid
aaguidはAuthenticatorごとに割り振られる16bytesのidです
authData.credentialId
credentialIdは公開鍵ごとに振られるIDです。この値、credentialId_base64と5.で受け取ったidの値を比較するとわかりますが同じ値です。
authData.credentialPublicKey
これは名前の通り公開鍵の情報で、ECDSAの公開鍵の情報です。多分。
これについての情報はCOSEというJSONでいうJOSEをCBOR上でおこなう(一ヶ月前はJSONって言葉以外聞いたことありませんでした)、RFC8152の一部の文章に書いてあるっぽいので気になる方は読んでみてください。
認証フロー
認証フローは以下のようになっています。
登録フローとほとんど変わることがありませんので、変化する部分についてのみ説明を入れていきたいと思います。
1. navigator.credentials.getメソッドを叩く
登録時にはnavigator.credentials.createメソッドを叩きましたが、認証時にはgetメソッドを叩きます。
その時のoptionも違うので下に示します。
const credentialRequestOptions = {
'challenge': challenge,
'allowCredentials': [{ // これ以外にtransports(usb, bluetooth経由などの指定)も指定できる
'type': "public-key",
'id': ids[name]
}]
}
登録時に比べてずいぶんとスッキリしました。
他にもrpId(省略するとドメインが設定)、timeoutなども設定できますが、必須なのは上の3つだけです。
idには、登録時に取得した公開鍵のidを指定します。
5. Authenticatorから受け取った情報をRPに送信, 6.データのチェック
登録と認証では大きな違いこそあまりありませんが、受け取るデータが認証だと若干増えています。
一つがuserHandle, これは、ユーザーidをそのままバイナリ形式にしたものです。ユーザー名がabcd
ならばuserHandleは[0x61, 0x62, 0x63, 0x64]
となるわけですね。
もう一つはsignature。これは、受け取ったclientDataJsonとassertionData(登録時のattestationObjectのようなもの)と公開鍵をつかってデータの認証をするらしいのですが、詳しくは調べてないのでわかりません。ここの部分だけ代わりに記事を書いて...(届かぬ思い)。
まとめ
最後の雑記の部分はざっと走りきってしまいましたが、WebAuthnについて少し踏み込んだ知識がついたと思います。
Yahooのテックブログを始めとして、この記事より深いところに突っ込んだ日本語記事は多いとまでは言えませんが充分あると思います。
なので、この記事で気になった方は、より詳しく調べてみていただけると幸いです。
ちなみにあまり新しい記事は引っかからないので、昔と今で変化した部分や、記事ごとに言ってることが変わっている部分もあるのでそこだけ注意ですね。
以上、乱文長文でございましたが、一読ありがとうございました。