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

[ミクシィグループ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/mixi) の20日目の投稿です。

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

## 何の話か

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

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

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

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

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

![WebAuthn_Registration_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10192/bfc5f03d-ad67-c83a-792d-712e1cd6c388.png)

![WebAuthn_Registration_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10192/ca6c32ed-64db-c0a2-8731-7403ffc162c6.png)

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

![WebAuthn_Authentication_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10192/110fc059-bbee-3c97-24c6-d522a7b9337a.png)

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

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

![WebAuthn_Registration_flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10192/f881b6de-56e5-e436-58bf-c4b73735a2d1.png)

![WebAuthn_Authenticator_flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10192/c80979fe-48ad-f81c-9120-cf0476faf92c.png)

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

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

[Yahoo! JAPANでの生体認証の取り組み（FIDO2サーバーの仕組みについて） - Yahoo! JAPAN Tech Blog](https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn/)

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

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

## "Wax"

まずは[Hexで "WebAuthn" を検索した結果](https://hex.pm/packages?search=WebAuthn&sort=recent_downloads)をみてみます。

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

![WebAuthnでhexを検索した結果.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/10192/2a1ec012-bda1-5743-4cd7-23bde5850c9b.png)

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

[wax_ | Hex](https://hex.pm/packages/wax_)

* [オンラインドキュメント](https://hexdocs.pm/wax_/readme.html)
* [GitHubリポジトリ](https://github.com/tanguilp/wax)

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

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

RPは最初に、

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

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

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

```elixir
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` に渡します。

```javascript
      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()` 関数に指定することで検証ができます。

```elixir
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` が生成されます。

```elixir
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` に渡します。

```javascript
    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()` 関数に指定することで検証ができます。


```elixir
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アップデート](https://www.sharplab.net/blog/2020/12/11/2020%E5%B9%B4%E3%81%AEwebauthn%E3%82%A2%E3%83%83%E3%83%97%E3%83%87%E3%83%BC%E3%83%88/)

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">認証がおかしくなったら生きた気がしなくなるので仕事にするのはおすすめしません。</p>&mdash; 👹秋田の猫🐱 (@ritou) <a href="https://twitter.com/ritou/status/1338457288302989314?ref_src=twsrc%5Etfw">December 14, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ではまた。