セキュリティエンジニアからコードが書ける仕事へ

コキチーズ氏:よろしくお願いします。「パスワードのない未来のためのFirebaseで実装するFIDO2」ということで話していきます。

まず簡単に自己紹介をさせてもらいます。インターネットではコキチーズという名前で活動しています。TwitterとGitHubのIDは@k2wankoでやっています。興味のある人はぜひフォローしてもらえるとうれしいです。

LINEでは、全社員が使う社内システム、SlackやJira、Confluenceの管理をしていたり、社内独自の認証認可基盤の技術アドバイザーとか、いろいろやっていたりします。

去年まではセキュリティエンジニアをやっていて、自社サービスの脆弱性診断やBug Bountyの運営などをやっていました。設計やコードが書ける仕事がしたくて異動したという感じですね。

最近アクティブじゃなくて申し訳ないんですけど、一応Firebase Japan User GroupというFirebaseコミュニティのオーガナイザーもやっています。近いうちにまたイベントをやりたいなと思っているので、Firebaseの話をもっと聞きたいという人はこっちも見てもらえるとうれしいです。

パスワード認証のさまざまなリスク

この発表をしようと思っていた10月時点からいろいろあって、実サービスに取り入れるテーマでの話はできなくなってしまったんですけど、今回は実装を含めた検証をしてみた話をします。

まず、FIDOの話をする前に、従来の認証でよく利用されているパスワード認証についておさらいしたいなと思います。一般的なWebサービスを例に、この図では左側のにっこりマークがユーザで、右側の四角いところがWebサービスだと思ってください。

何かしらのサービスを提供する際、だいたいのユーザを識別するのにパスワード認証を利用することが多いかなと思います。

パスワードは何らかの方法でインターネットを経由してサービス提供者に送って、サービス提供者自身がそのパスワードをハッシュ化する。生の状態で保存することはないと思うんですけど、そのハッシュ化したものを保存して、次回以降そのサービスを提供する際にユーザがパスワードをもう一度送って、登録したユーザと次回以降認証を行うユーザが同一かどうかを認証します。

パスワード認証に関するリスクに関してよく言われていることが、複雑なパスワードだったらいいと思うんですけど、ユーザが覚えやすいような簡単なパスワードだったり、複雑なパスワードだったらメモを残したりしてそのメモが見られてしまったり、あるいはデバイスが盗聴されていてそのパスワードは筒抜けという可能性もあったりします。

パスワードが数文字の単純な文字列で一般的な単語の組み合わせだったりすると、わりと漏れちゃうことがあるかなと思います。最近はいろいろそういうニュースも話題ですが、ユーザ自身が直接悪くなくても他サービスでパスワードの使いまわしをしていた場合、他サービスでパスワードが漏れたときに悪意ある人がそのパスワードを使って認証を試したりということがよくあるかなと思います。

あとこれは一番大変なんですけど、フィッシングサイトというものもあります。本物とそっくりの偽サイトを用意して、そちらにユーザのメールアドレスとかパスワードを入力させて、その盗んだパスワードで本物のサービスの情報を盗ってしまうということがあったりします。パスワード単体だと本当にいろいろなことに起因するリスクがあります。

FIDOの認証の概要とメリット

こういったパスワード以外の認証方式として僕が最近興味があったのが「FIDO」です。ざっくりFIDOの話をしますと、FIDO Allianceが策定、標準化する認証に関する仕様です。

FIDOに対応したデバイスは、どのメーカーが作ったものかに関係なくFIDOで認証を利用できるということで、公開鍵認証方式を利用してデバイスに秘密鍵を保存しておいて、その秘密鍵をネットワークに流さず公開鍵認証方式の署名を使って認証を行おうというものです。

ざっくりなんですけど、まずサービスにユーザー登録をするタイミングでFIDOの公開鍵の登録も行います。サーバ側は登録を行う際にchallengeコードを返してあげて、FIDOのデバイスを持っているユーザはそのデバイスを使ってchallengeコードのサインをしてsignatureを付けてサーバに送り返してあげます。サーバはそのsignatureの検証を行い、その検証が通ればユーザ登録の完了です。

認証のときもだいたい登録と似たようなフローですが、「認証したいです」というユーザが来たらサーバ側がchallengeコードを返してあげて、ユーザは自分の持っているFIDOデバイスでchallengeコードに対して署名してあげて、そのsignatureをサーバに送ってあげて事前に登録したサーバが持っているパブリックキーを使ってsignatureの検証を行います。

そのsignatureが問題なければサインインをしたいユーザ、つまりはレジストレーションを行っているユーザと同一であるということを確認して、アクセストークンなりセッションクッキーなりを発行してあげるというかたちになります。

ということで、FIDOの認証のメリットは、ユーザがパスワードを覚える必要がなく、物理的なデバイスさえ盗まれなければなりすましのリスクが低いというところです。端末によっては、FIDOの生体認証が組み合わせられて、指紋などが一致しないと署名を行えないというより強力なデバイスもあったりします。

FIDO2とWebAuthnについて

FIDO2とWebAuthnについてです。フロントエンドの人はWeb Authenticationを聞いたことがあるかと思いますが、これはブラウザで公開鍵認証を行うための仕様で、Chrome、Firefox、Safariなど主要なブラウザで実装されています。FIDO2と呼ばれるものはだいたいWeb Authenticationとイコールなもので、AndroidネイティブにはなくてFIDO2 APIクライアントというのがあってWebAuthnの実装がされています。

今回僕が作ったサンプルのデモアプリは、このGitHubで公開しています。今回コードをバンバン紹介するんですけど、スライドだけではわかりにくいと思うので、こちらも見てもらえたらいいかなと思います。

(デモが流れる)

作ったアプリのデモは動画の撮影が下手くそで早かったりすると思うんですけど、左のレジスタボタンを押すと、先ほどのサーバに対してリクエストが飛んで、サーバがchallengeコードを返してくれます。そのchallengeコードに対してAndroidのAPIを使って本人確認のダイアログを出してあげる。これは、僕がPixel 4を使って顔認証を行ってチェックが完了したらダイアログが閉じて、認証が成功するとUIDの部分にユーザIDが登録されるというデモです。

時間が掛かって一瞬で消しちゃったんですけど、そのあともう一度サインインボタンを押すともう一度認証が走って、先ほどと同じユーザIDが取得できるという感じのデモです。これはPixel 4なので顔認証でやりましたけど、もう1つ、物理デバイスのYubikeyを使った方法ですね。カメラに映すんですけど、こういった物理デバイスを使った認証方法もPixel 4ではできて、だいたい似たような感じですね。

先ほどの顔認証の部分とは違って、「USBを挿してください」というダイアログを出しての認証も行います。ここはあとで説明するんですけど、プラットフォームを使うかクロスプラットフォームを使うかでこの辺の出しわけができたりします。

Firebaseを使って実装する

実装の話をしていきます。利用するプラットフォームに関しては、AndroidのFirebaseを使います。Firebaseについてざっくりと説明すると、モバイル、Webアプリのバックエンドを提供してくれるサービスで、Googleに2014年に買収され、今はGCPの一部になっています。

Firebaseにもいろいろサービスがあるんですけど、その中で主に3つのサービスを使います。Firebase Authという認証を司るサービス。これは、パスワード認証や、TwitterとかFacebookでのSNS認証、電話番号での認証などのいろいろな認証方式をサポートしています。

今回のFIDOは直接はサポートされていないんですけど、カスタム認証というものを使って自分で検証を行えば、その検証結果をFirebaseの認証として使うこともできます。

サーバに関しては、Cloud Functionsという手軽にコードが実行できるサービスを使います。HTTPのトリガーとしてだけでなくユーザの作成やデータベースの書き込みなどもトリガーにして実行できます。今回はHTTPサーバとしてだけ使うんですけど、ファイルサーバとして利用します。

Firebaseは、実行するコードは主にNode.jsをサポートしています。GCPに関してであれば、Pythonとか他にもいろいろな言語があったりしますね。データベースに関しては、Firestoreですね。ドキュメント指向のデータベースでクライアントから直接読み書きができます。

サーバの変更をリアルタイムにクライアントに同期できるだけでなく、オフラインでの読み書きもできるし、独自のセキュリティルールによってアクセスコントロールも宣言できるので、けっこうすごいデータベースかなと思います。しかもスケールして大規模に使うこともできるし、少ない規模だったら無料で使えちゃうのがいいところですね。今回はchallengeやcredentialの保存に利用します。

今回の実装に参考にさせてもらったのがGoogleが提供しているCodelabです。えーじ(Eiji Kitamura)さんとかAraki(Yuichi Araki)さんとかが作られたものなのかな。これを参考にさせてもらっているので、サーバライブラリとかfido2-libとかを使っています。

ただ、そのままだとAndroidへの対応はあまりできていないので、フォークして検証部門をカスタマイズしたものを使っています。こっちも気になる人はぜひ見てみてください。

Androidアプリの登録について

Androidアプリの登録についてです。プロジェクトの作成を飛ばしているんですけど、Firebaseのプロジェクトを作成するとアプリの登録を行います。FirebaseではAndroidとiOS、Webも最近対応されて、このアプリの登録のところで各Firebaseで管理したいアプリを登録できます。

アプリを登録する際にパッケージの名前とアプリの名前と、今回大事になるsignatureの登録を行います。signatureに関しては、今回は検証用のAndroid SDKが標準で作ってくれる署名付きのデバッグキーを使って署名を行うので、それのSHA256を登録します。

keytoolというのを使えばSHA256を取得できるので、それでフィンガープリントを取得して登録してあげてください。1個だけでなく複数登録ができるので、複数個ある場合は複数登録してみてください。

この証明書のフィンガープリントが登録できると、このproject-idというのがFirebaseのプロジェクトIDで、そのproject-id.web.appというFirebaseが用意してくれているドメインの下に/.well-known/assetlinks.jsonというものが作られます。

これはFIDOのためにあるわけではなくて、ダイナミックリンク用に作られていて、Googleがドメインの所有権などを確認するために使うJSONファイルです。さっき登録されたpackage_nameやSHA256のフィンガープリントがここに出るようになります。

ダイナミックリンクのために自動生成されるんですけど、例えば勝手に生成されたら困るという場合に関しては、Firebase Hostingというサービスを使って、静的ファイルとして同じパスにJSONファイルを作ってあげれば、上書きすることができます。

ただ、Cloud Functionsとかでもやりたいと思ってもそれはできないらしくて、サポートに問い合わせたらいろいろと教えてくれました。とりあえずFirebase Hostingで今できるのは、自分で作った静的ファイルを置くか、自動生成されたものを使うかというかたちになります。assetlinks.jsonが作られたら、それをどこのURLにあるのかというのをAndroidマニフェストにつけてあげます。

これでとりあえず下準備はOKです。

レジストレーションの実装

レジストレーションの実装なんですけど、Cloud Functionsの関数を登録します。ここでレジストレーションの関数のregisterRequestとregisterResponse、registerResponseはchallengeのsignatureの検証用の関数です。これはNode.jsのコードなんですけど、functions.https.onCall()というものを使います。

これは何かというとCallable Functionというもので、Firebase SDK経由でCloud Functionsを呼び出すためのシンタックスシュガーです。Firebase Authで認証していたらクライアントから呼び出すときに自動的にトークンを付与してくれるし、サーバ側では検証までしてくれるとても便利なやつです。

このコードはAndroidから呼び出す例で、FirebaseFounctions.getHttpsCallable()で、さっき作ったregisterRequestという関数を入れてあげる。引数とかも入れてあげれば、それも送ることができます。

今回デバイスの識別用にInstance IDというものを使うんですけど、本来Instance IDは広告とかアナリティクスとかプッシュ用に使われているアプリを識別するためのIDで、とりあえず認証なしで付与されます。

デフォルトがONで意図的に生成しないように設定できたりもします。GDPR対応させるのであれば、このIDを使う前にユーザに同意を取ることが推奨されます。再インストールするとこのIDは変わります。Callable FunctionでInstance ID Tokenというかたちで付与されてトークン自体は「:」でくっついていて、「:」の前半部分がInstance IDということになります。

(スライドを指して)この下のfosrj-JUrl4というところまでがIDになります。

このInstance IDの検証もAPIを使ってできます。とりあえずプロジェクトのSettingsのクラウドメッセージングのサーバーキーというところのAPIキーを使えば、これを使ってInstance ID Tokenの検証ができます。

Android側のコードにいくと、Fido2ApiClientを取得するには、FIDOからFido.getFido2ApiClient()でコンテキストを渡してあげてAPIクライアントを取得できます。主に使うのが.getRegisterPendingIntent()と.getSignPendingIntent()です。

.getRegisterPendingIntent()にオプションを指定してあげて、Kotlinのコルーチンを使っているので.await()というのがあるんですけど、非同期でintentを取得してあげてstartIntentSenderForResult()を使って先ほどの画面を出してあげるというかたちになります。

オプションの説明

ここからオプションの説明をしていきます。とくに大事なことなんですけど、FIDOの世界では、その認証するサーバのことをRelying Partyといいます。略してrpと書かれたりするんですが、このオプションのsetRpのPublicKeyCredentialRpEntity()で、第1引数にさっき作ったプロジェクトのIDと.web.appで、assetlinks.jsonのあるドメインを指定してあげます。第2引数はアプリ側で表示するアプリ名とかですね。

顔認証とUSBでの認証で違いがあったと思うんですけど、その違いを作るところのオプションがsetAuthenticatorSelection()と、その中のsetAttachment()でPLATFORMとCROSS_PLATFORMの2種類があります。

PLATFORMを使えばPixel 4の場合は顔認証ができて、CROSS_PLATFORMを使えばUSBやBluetoothなどNFCを使ったものを呼び出すことができます。

先ほど第2引数に設定していたアプリの名前などもここに出てくるようになります。

今度は、順番が前後してしまったんですけど、Node.jsのFunctions側のコードに戻ってregisterRequestでchallengeを返してあげて、さっきのオプションのintentを呼び出す前にchallengeを設定してオプションに指定してあげます。

(スライドに)書き忘れてしまったんですけど、クライアント側がまずこのregisterRequestを呼び出してchallengeを取得したら、それをAndroid OSのAPI側に渡してあげて署名を行うというかたちになります。

オプションでいうとsetChallenge()で指定してあげることができます。

またAndroidのコードに戻るんですが、レジストレーションのオプションを付けて.getRegisterPendingIntent()で渡したものをintentを取得して呼び出してあげると、今回はPLATFORMで選択しているので、フェイスIDで認証ダイアログが出てくる感じです。

ダイアログの処理が完了するとonActivityResult()で結果が返ってきて、今回のコードではhandleRegisterResponse()で続きの処理を書いています。

Android OS側からいろいろな情報が返ってくるのでクライアントのデータをattestationObjectというかたちで取得します。

それを取得してAndroid OSから返されたデータを今度はFunctionsに返してあげるかたちでregisterResponseを呼び出して、FunctionsでregisterResponseのコードでsignatureの検証、attestationObjectの検証を行います。

ここはfido2-libのライブラリを使っていて、興味のある人はそこのコードを見てもらえばいいのかなと思います。

あとはレスポンスで、既にユーザの登録が済んでいればそのユーザにcredentialを紐づけるし、ユーザがなければfirebase.auth().createUser({})というかたちでユーザを作ってそのユーザに紐づけます。

これはあとでサインインのときに関係してくるんですが、transportsというものがあります。Yubikeyであればfido-u2fみたいなかたちで、先ほどのattestationObjectのデータのfmtというところにfido-u2fと出てくるので'usb'ととりあえず入れておきます。それ以外はいったんinternalというかたちで保存しておきます。

これがFirestoreに保存しておくコードなんですけど、クライアントから返してもらったcredentialの情報をほぼそのまま保存している状態ですね。transportsなどを入れてInstance IDに紐づけるかたちで保存しています。

保存が完了したら、createCustomToken()というコードを使ってカスタム認証用のトークンを作ってあげます。

このトークンをクライアント側に返してあげてsignInWithCustomToken()で作ったトークンを使って認証を行います。

webauthn: trueとやっておくと、あとでFirestoreからのセキュリティルールのときにwebauthnが完了しているユーザだけこのデータにアクセスできるというようなセキュリティルールを書くこともできます。

というわけで、レジストレーションの実装のまとめとしては、サーバで生成したchallengeをAndroidで署名してもらい、サーバで検証するというだけですね。今回はInstance IDで検証しましたが、実サービスであればユーザ名などで紐づけてあげるのがいいんじゃないかなという気はします。

あとは、ネイティブのFIDO Api Clientだと、FIDOの世界にはattestationというのがあって、AndroidではAndroid Keyとandroid-safetynetの2種類があるんですけど、ネイティブのFIDO ApiクライアントだとAndroid SafetyNet APIを使っての署名になるのかなという気はします。ここは僕の検証が足りていなくて、間違っていたらすみません。

サンプルでは AuthenticatorSelectionをクライアント側から指定していますが、仕様としてはサーバーサイドから許可するものだけを指定し、検証時にもサーバーが指定した内容と同一か検証する必要があります。

サインインの実装

一旦レジストレーションは以上で、次はサインインの実装ですね。

基本的にレジストレーションとあまり変わらないんですけど、challengeの生成だけでなく、先ほど保存したcredentialsの一覧も返してあげます。ここでそのレスポンスにchallengeとallowCredentialsというかたちで、登録したcredentialを返します。

今度はAndroidにいきます。パラメータは少し違うんですけど基本は同じで、setRpId()というのが認証に使いたいドメインを指定して、サーバ側から返してもらったallowCredentialsをAndroid側のintentにFido2ApiClientのオプションに詰め直してあげます。

ここでTransport.USBというのだけ渡してあげればさっきのYubikeyでの認証のダイアログが出てきて、internalとかだったらPixel 4の場合はFace IDの認証画面が出てくるようになります。

先ほどは.getRegisterPendingIntent()だったんですけど、ここでは.getSignPendingIntent()を使ってオプションを渡します。

あとはもう同じでstartIntentSenderForResult()で認証用のダイアログを出してあげます。

そのダイアログの結果をonActivityResult()で取って、今度はhandleSignInResponse()の関数で処理を行います。

こちらでも、やっていることはOSから返してもらうデータをそのままサーバに送っているだけなんですけど、signatureなどが付与されていて、それを.signInResponseのfunctionsに送ります。

Functionsに戻っていって、fido2-libの.assertionResult()という関数で検証を行って、何回使ったかみたいなカウンターのインクリメントをして保存します。あとはさっきのレジストレーションと一緒で検証が完了したらカスタムトークンを作ってAndroidに返してあげます。

そしたら、ここも先ほどと同じで.signInWithCustomToken()でそのトークンを使って認証を行うことができます。

これで一旦サインインの実装は終わりなんですが、ログインのときだけではなく、何か重要な処理を行うときもこういったFIDOのサインインを使うといいと思っていて、主に決済情報の登録や編集時、あるいは送金の確認やアプリケーションにおいて重要な操作をするときは、本人確認のためにもこういったサインインを使ってあげるのがよいはずです。

監査ログの取得方法

あまり知られていないんですけど、Firebase Authenticationは監査ログを取ることも可能です。このIdentity Toolkitというのを使って、APIは省略しますが、"{'monitoring': {'requestLogging':{'enabled':true}}}"と送ってあげます。

GCPのStackdriver Loggingにこういうかたちでログが取れるようになります。

今回は.signInWithCustomToken()しか使っていないので、カスタムトークンの認証情報のログしか流れてこないんですけど、Twitter認証やFacebook認証などいろいろやっていた場合は、そういった情報もここに流れてくるので、守りのセキュリティをやっている人はこのあたりを抑えておくといいんじゃないかなと思います。

まとめとしては、FIDOはパスワードのリスクを軽減するための公開鍵認証方式を提供してくれるプロトコルです。あまりNode.jsに良いライブラリがなくてCloud FunctionsでFIDOサーバを実装するのはけっこう苦行な気がしますが、Javaにも良いライブラリがあるので、実サービスで使ってみたいなと思う人はGoやJavaなどのライブラリを使うのをおすすめします。

Firebaseはめちゃくちゃ便利なんですが、何ができて何ができないかを理解せずに使うとけっこうつらいことになるので、用量用法はしっかり守って利用してください。

というわけで、僕の話は以上です。すべてを伝えきれていないと思うので、Twitterやメールなどで質問していただければ、可能な限り対応していこうかなと思います。

あとは単純にFirebaseの話やGCPの話をもっと聞きたいという人は、Firebase Japan User GroupやGCPのUser GroupであるGCPUGというものもあるので、そちらにぜひ参加してみてください。

あとは少し宣伝的になるんですけど、僕は今エンタープライズの認証基盤開発やエンタープライズ製品の管理、Plugin開発などもいろいろやっているので、そっちに興味がある人は声をかけてくれるとうれしいです。

僕の話は以上です。ありがとうございました。