LoginSignup
1

More than 1 year has passed since last update.

posted at

Organization

ElixirのWebAuthn用ライブラリ "Wax" の使い方

ミクシィグループ Advent Calendar 2020 の20日目の投稿です。

どーも, ritouです。
皆さん知らないと思いますが、業務でもIDおじさんをしています

何の話か

WebブラウザのFIDO/WebAuthn対応が進み盛り上がり始めた(と自分では思っていた)2018年頃、 WebアプリでWebAuthnのフローを実装しようとしたことがありました。
当時は Elixir/ErlangのパッケージマネージャーであるHexにてライブラリを探しても WebAuthn,CBOR 周りであまりしっかり実装されたものが見つからず、自分でちょっとしたライブラリを作成して実装を行いました。

それから2年が経ち、現在のElixirなWebAuthn用ライブラリの状況を調べてみたところ、前に調べたときにも気になっていた "Wax" というライブラリが一番使われていそうだったので使い方を調べてみました。

そもそも誰が何をするためのライブラリ?

今回紹介するライブラリが WebAuthn のフローのどこで使われるものなのかと言うところから振り返りましょう。
Webアプリケーションにいわゆる"セキュリティキー"やTouchID, FaceIDのようなローカル認証を使ってログインするために、"Authenticatorの登録" と "Authenticatorを用いた認証" という2つのステップがあります。

まずはAuthenticatorを登録します。例えばChrome@MacOSだとこんな感じです。

WebAuthn_Registration_1.png

WebAuthn_Registration_2.png

で、登録が終わるとそのAuthenticatorを使って認証ができるようになります。

WebAuthn_Authentication_1.png

これらの処理を実現するために、複数の登場人物が頑張っています。

前にどこかで発表した際に使った、Chrome@Androidの例を示した資料から図を持ってきました。
(引用元: https://speakerdeck.com/ritou/webauthn-at-droidkaigi-2019)

WebAuthn_Registration_flow.png

WebAuthn_Authenticator_flow.png

RelyingPartyと呼ばれるWebアプリケーション、ClientであるWebブラウザ、セキュリティキーやOSが提供する機能であるAuthenticatorの3者でのやりとりの中で、RelyingPartyの部分を自前でライブラリなどを用いたり、いわゆるFIDO Serverと呼ばれる製品などを利用したりして実装する部分が今回のお話の対象となります。

RP実装の解説について、とてもわかりやすいドキュメントがあります。

Yahoo! JAPANでの生体認証の取り組み(FIDO2サーバーの仕組みについて) - Yahoo! JAPAN Tech Blog

この中でRPサーバーがFIDO2サーバーとやりとりしている、Authenticatorの登録、Authenticatorを用いた認証でそれぞれ必要となる処理をライブラリでどう実装されているかを調べていきます。

  • 登録
    • navigator.credentials.create実行時のパラメータ生成
    • navigator.credentials.create実行結果のハンドリング
  • 認証
    • navigator.credentials.get実行時のパラメータ生成
    • navigator.credentials.get実行結果のハンドリング

"Wax"

まずはHexで "WebAuthn" を検索した結果をみてみます。

スクリーンショットは12/19 AM3時ぐらいのものです。

WebAuthnでhexを検索した結果.png

ダウンロード数が少ないので、そんなにガッツリ使われてない気がしないでもないですが、一番上にあるのが "Wax" です。

wax_ | Hex

それでは見ていきましょう。

navigator.credentials.create実行時のパラメータ生成

RPは最初に、

  • 一連の処理を紐付けるための challenge の値
  • RP情報
  • RP上のユーザー情報
  • その他、Authenticatorへの各種要求

を用意する必要があります。

Waxでは Wax.new_registration_challenge という関数により Wax.challenge の値を生成し、セッションに保持しつつHTMLに出力するなどして navigator.credentials.create に渡します。

iex> challenge = Wax.new_registration_challenge([origin: "http://localhost:4000", rp_id: :auto])
%Wax.Challenge{
  acceptable_authenticator_statuses: [:fido_certified, :fido_certified_l1,
   :fido_certified_l1plus, :fido_certified_l2, :fido_certified_l2plus,
   :fido_certified_l3, :fido_certified_l3plus],
  allow_credentials: [],
  android_key_allow_software_enforcement: false,
  attestation: "none",
  bytes: <<195, 88, 151, 55, 83, 180, 149, 56, 179, 53, 177, 107, 155, 226, 80,
    113, 28, 215, 142, 134, 139, 69, 137, 237, 251, 170, 64, 48, 207, 133, 13,
    189>>,
  issued_at: -576422502,
  origin: "http://localhost:4000",
  rp_id: "localhost",
  silent_authentication_enabled: false,
  timeout: 1200,
  token_binding_status: nil,
  trusted_attestation_types: [:none, :basic, :uncertain, :attca, :self],
  type: :attestation,
  user_verification: "preferred",
  verify_trust_root: true
}

bytes が challenge の値です。
rp_id, user_verification, timeout などが navigator.credentials.create に渡されることになりますが、その他レスポンスの検証に利用する内部の設定値も含まれています。

ここから Base64 でごにょごにょしたりしつつ、最終的に navigator.credentials.create に渡します。

      var rp = {
          name: 'localhost',
          id: 'localhost'
      };

      var user = {
          id: decode_b64_to_array_buffer("..."),
          name: 'UserName',
          displayName: 'UserDisplayName'
      };

      var createCredentialDefaultArgs = {
          publicKey: {
              rp: rp,
              user: user,

              pubKeyCredParams: [
                  {
                      type: 'public-key',
                      alg: -7
                  }
              ],

              attestation: 'direct',

              challenge: decode_b64_to_array_buffer("w1iXN1O0lTizNbFrm-JQcRzXjoaLRYnt-6pAMM-FDb0"),

              authenticatorSelection: {
                  user_verification: "preferred"
              }
          }
      };

      navigator.credentials.create(createCredentialDefaultArgs)

navigator.credentials.create実行結果のハンドリング

Authenticatorとのやりとりが終わったら、navigator.credentials.create のレスポンスから

  • attestationObject : バイナリデータ
  • clientDataJSON : JSON文字列

の値を取得し、上記 Wax.Challenge の値と共に Wax.register() 関数に指定することで検証ができます。

iex> {:ok, {key, _}} = Wax.register(attestation_object, client_data_json, challenge)
{:ok,
 {%Wax.AuthenticatorData{
    attested_credential_data: %Wax.AttestedCredentialData{
      aaguid: <<173, 206, 0, 2, 53, 188, 198, 10, 100, 139, 11, 37, 241, 240,
        85, 3>>,
      credential_id: <<1, 59, 183, 111, 80, 84, 156, 165, 157, 188, 61, 43, 104,
        75, 18, 114, 82, 236, 71, 170, 68, 55, 252, 223, 172, 162, 71, 101, 154,
        13, 67, 113, 50, 52, 243, 212, 3, 131, 231, 238, 190, 113, 217, 127,
        ...>>,
      credential_public_key: %{
        -3 => <<1, 200, 28, 190, 98, 180, 165, 132, 159, 153, 225, 135, 176,
          167, 146, 201, 24, 7, 39, 174, 64, 23, 58, 77, 46, 55, 240, 194, 245,
          175, 245, 118>>,
        -2 => <<62, 199, 250, 107, 136, 13, 180, 236, 132, 81, 235, 129, 19,
          241, 86, 213, 207, 171, 103, 52, 207, 165, 216, 8, 53, 229, 98, 151,
          242, 208, 0, 20>>,
        -1 => 1,
        1 => 2,
        3 => -7
      }
    },
    extensions: nil,
    flag_attested_credential_data: true,
    flag_extension_data_included: false,
    flag_user_present: true,
    flag_user_verified: true,
    raw_bytes: <<73, 150, 13, 229, 136, 14, 140, 104, 116, 52, 23, 15, 100, 118,
      96, 91, 143, 228, 174, 185, 162, 134, 50, 199, 153, 92, 243, 186, 131, 29,
      151, 99, 69, 95, 222, 22, 110, 173, 206, 0, ...>>,
    rp_id_hash: <<73, 150, 13, 229, 136, 14, 140, 104, 116, 52, 23, 15, 100,
      118, 96, 91, 143, 228, 174, 185, 162, 134, 50, 199, 153, 92, 243, 186,
      131, 29, 151, 99>>,
    sign_count: 1608390254
  }, {:self, nil, nil}}}

Wax.AuthenticatorData.attested_credential_data が登録の対象となる公開鍵の情報です。
この credential_id, credential_public_key をユーザーに紐付けてデータストアに保存しておくことでその後の認証に利用できます。

一連の処理が終わったらセッションから Challenge の値を削除するのも忘れてはいけません。

navigator.credentials.get実行時のパラメータ生成

次に、メアドなどでユーザー識別をした後にAuthenticatorによる認証を要求するフローで必要な処理を見ていきます。

登録時と同様に、Wax.new_authentication_challenge() という関数を利用してAuthenticatorを用いた認証に必要なchallengeなどのパラメータを生成し、セッションに保存しつつ navigator.credentials.get の引数として渡します。

上記登録時に保存しておいた公開鍵情報({"id", COSE形式の公開鍵データ})のリストを渡すと、認証用の Wax.Challenge が生成されます。

iex> keys = [{key.attested_credential_data.credential_id |> Base.url_encode64(), key.attested_credential_data.credential_public_key}]
[
  {"ATu3b1BUnKWdvD0raEsSclLsR6pEN_zfrKJHZZoNQ3EyNPPUA4Pn7r5x2X92r588khOeb8hryisivy38Kkk0MtIO1LxchfBNj6zqP_XBedVLwDK8J6malhwv",
   %{
     -3 => <<1, 200, 28, 190, 98, 180, 165, 132, 159, 153, 225, 135, 176, 167,
       146, 201, 24, 7, 39, 174, 64, 23, 58, 77, 46, 55, 240, 194, 245, 175,
       245, 118>>,
     -2 => <<62, 199, 250, 107, 136, 13, 180, 236, 132, 81, 235, 129, 19, 241,
       86, 213, 207, 171, 103, 52, 207, 165, 216, 8, 53, 229, 98, 151, 242, 208,
       0, 20>>,
     -1 => 1,
     1 => 2,
     3 => -7
   }}
]

iex> challenge = Wax.new_authentication_challenge(keys, [origin: "http://localhost:4000", rp_id: "localhost"])
%Wax.Challenge{
  acceptable_authenticator_statuses: [:fido_certified, :fido_certified_l1,
   :fido_certified_l1plus, :fido_certified_l2, :fido_certified_l2plus,
   :fido_certified_l3, :fido_certified_l3plus],
  allow_credentials: [
    {"ATu3b1BUnKWdvD0raEsSclLsR6pEN_zfrKJHZZoNQ3EyNPPUA4Pn7r5x2X92r588khOeb8hryisivy38Kkk0MtIO1LxchfBNj6zqP_XBedVLwDK8J6malhwv",
     %{
       -3 => <<1, 200, 28, 190, 98, 180, 165, 132, 159, 153, 225, 135, 176, 167,
         146, 201, 24, 7, 39, 174, 64, 23, 58, 77, 46, 55, 240, 194, 245, 175,
         245, 118>>,
       -2 => <<62, 199, 250, 107, 136, 13, 180, 236, 132, 81, 235, 129, 19, 241,
         86, 213, 207, 171, 103, 52, 207, 165, 216, 8, 53, 229, 98, 151, 242,
         208, 0, 20>>,
       -1 => 1,
       1 => 2,
       3 => -7
     }}
  ],
  android_key_allow_software_enforcement: false,
  attestation: "none",
  bytes: <<234, 198, 18, 106, 107, 150, 220, 97, 3, 248, 228, 44, 221, 56, 161,
    67, 196, 137, 126, 100, 161, 168, 199, 71, 15, 168, 139, 240, 3, 38, 141,
    83>>,
  issued_at: -576420545,
  origin: "http://localhost:4000",
  rp_id: "localhost",
  silent_authentication_enabled: false,
  timeout: 1200,
  token_binding_status: nil,
  trusted_attestation_types: [:none, :basic, :uncertain, :attca, :self],
  type: :authentication,
  user_verification: "preferred",
  verify_trust_root: true
}

ここから Base64 でごにょごにょしたりしつつ、最終的に navigator.credentials.get に渡します。

    var CredentialRequestOptions = {
        publicKey: {
            challenge: decode_b64_to_array_buffer("6sYSamuW3GED-OQs3TihQ8SJfmShqMdHD6iL8AMmjVM"),
            rpId: "localhost",
            allowCredentials: [
                {
                    id: decode_b64_to_array_buffer("ATu3b1BUnKWdvD0raEsSclLsR6pEN_zfrKJHZZoNQ3EyNPPUA4Pn7r5x2X92r588khOeb8hryisivy38Kkk0MtIO1LxchfBNj6zqP_XBedVLwDK8J6malhwv"),
                    type: "public-key"
                }
            ]
        },
    };

    navigator.credentials.get(CredentialRequestOptions)

navigator.credentials.get実行結果のハンドリング

Authenticatorとのやりとりが終わったら、navigator.credentials.get のレスポンスから

  • credential_id : Base64エンコード済みの文字列
  • authenticatorData : バイナリデータ
  • signature : バイナリデータ
  • clientDataJSON : JSON文字列

の値を取得し、上記 Wax.Challenge の値と共に Wax.register() 関数に指定することで検証ができます。

iex> Wax.authenticate(cred_id, authenticator_data, sig, client_data_json, challenge)                                               
{:ok, 
 %Wax.AuthenticatorData{
   attested_credential_data: nil,
   extensions: nil,
   flag_attested_credential_data: false,
   flag_extension_data_included: false,
   flag_user_present: true,
   flag_user_verified: true,
   raw_bytes: <<73, 150, 13, 229, 136, 14, 140, 104, 116, 52, 23, 15, 100, 118,
     96, 91, 143, 228, 174, 185, 162, 134, 50, 199, 153, 92, 243, 186, 131, 29,
     151, 99, 5, 95, 222, 30, 216>>,
   rp_id_hash: <<73, 150, 13, 229, 136, 14, 140, 104, 116, 52, 23, 15, 100, 118,
     96, 91, 143, 228, 174, 185, 162, 134, 50, 199, 153, 92, 243, 186, 131, 29,
     151, 99>>,
   sign_count: 1608392408
 }}

Wax.AuthenticatorData がRPの要件を満たすものであれば、対象ユーザーでログイン状態にできます。
一連の処理が終わったらセッションから Challenge の値を削除するのも忘れてはいけません。

感想など

Wax を使ってAuthenticatorの登録、Authenticatorの認証を行う方法を紹介しました。
実用にあたっては細かいオプションの値などは調べる必要がありますが、ちょっと試すにはシンプルで使いやすそうな気がしました。オンラインドキュメントを見るとかなり丁寧に説明されていますのでこの記事を見て興味をもたれた方は試してみてはいかがでしょうか?

最初にも少し書きましたが、このライブラリは2年前から存在は認識していましたが、当時はまだHex上で公開はされていませんでした。(確認した所、hexに上がったのは今年の夏過ぎてからのようです。)
そこで、調査がてら手元のアプリケーションからGithubのリポジトリを指定して使ってみた所、ローカルではそれなりに動いたもの、Elixir/Phoenix向けPaaSであるgigalixirで使おうとしたら何かのエラーが出まくって、結局諦めた経緯があります。

ID関連のアドカレで他の方が近況を解説していただいているのですが、今年はiOS SafariでのTouchID/FaceIDがPlatform AuthenticatorになったことでWebAuthnが使える環境が広がりました。
これによって今後は様々な言語でのWebAuthnのRP実装が増えるかもしれません。

2020年のWebAuthnアップデート

安全で便利な認証方式の普及により世界を少しでも平和にできるように、今後もこの分野に注目していきたいと思います。

ではまた。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1