Help us understand the problem. What is going on with this article?

Webで指紋認証を行ってみよう【WebAuthnについてのまとめ】

More than 1 year has passed since last update.

はじめに

ハローワールド

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つの仕様に分かれます。
何がどの部分の仕様を担っているかは下の図と表を見ればなんとなくわかっていただけると思います。

FIDOの図の流れと、仕様の関連

CTAP2 WebAuthn
仕様の範囲 クライアントから認証デバイス サーバーからクライアント(ブラウザ)
策定団体 FIDO Alliance W3C(とFIDO)

すなわち、WebAuthnとはFIDO2におけるWebアプリケーションとクライアント、細かく言うならブラウザとを繋ぐ仕様です。

今のところsafari以外の主要ブラウザはWebAuthnに対応しているみたいです。また、iPhoneは全く対応していないようです。

試してみよう

WebAuthnを利用したデモサイトはWebAuthn DemoとGoogle先生で調べてみると、結構出てきます。
おすすめのデモサイトはこちらです。

https://webauthn.me/

WebAuthnのデータフローをアニメーションでわかりやすく示してくれるので、見ていて楽しいです。
使い方は、nameに好きな名前を入力して、registerボタンを押すだけ!

また、私も勉強のために作っていたデモサイトが以下になります。いろんな情報が表示されるので、他のデモサイトに比べて画面がうるさいです。

https://github.com/sa2taka/WebAuthnDemo

使い方は簡単。Dockerがインストールされてる端末で以下を実行します。

$ git clone https://github.com/sa2taka/WebAuthnDemo
$ cd WebAuthnDemo
$ docker-compose up

そしてlocalhost:8080にアクセスします。

WebAuthnのデモページ

情報量が凄まじいですが、実際に必要なのは上の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公式文章より転載しております。

WebAuthn登録のフロー

  1. チャレンジ(後術)、ユーザーの情報、RPの情報などを表したPublicKeyCredentialCreateionOptionsを生成し、それを引数にnavigator.credentials.createメソッドを叩きます
  2. 受け取ったチャレンジ、ユーザー情報、RP情報などを基にAutenticatorへと送信します
  3. Authenticatorによる認証、データの処理
  4. Authenticatorからブラウザへ情報を送信します
  5. Authenticatorから受け取った情報をRPに送信します
  6. データの改ざんのチェックや検証を行い、正常であればデータを保存します

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の一部の文章に書いてあるっぽいので気になる方は読んでみてください。

認証フロー

認証フローは以下のようになっています。
登録フローとほとんど変わることがありませんので、変化する部分についてのみ説明を入れていきたいと思います。

WebAuthn認証のフロー

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のテックブログを始めとして、この記事より深いところに突っ込んだ日本語記事は多いとまでは言えませんが充分あると思います。
なので、この記事で気になった方は、より詳しく調べてみていただけると幸いです。
ちなみにあまり新しい記事は引っかからないので、昔と今で変化した部分や、記事ごとに言ってることが変わっている部分もあるのでそこだけ注意ですね。

以上、乱文長文でございましたが、一読ありがとうございました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away