ミクシィグループ 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だとこんな感じです。
で、登録が終わるとそのAuthenticatorを使って認証ができるようになります。
これらの処理を実現するために、複数の登場人物が頑張っています。
前にどこかで発表した際に使った、Chrome@Androidの例を示した資料から図を持ってきました。
(引用元: https://speakerdeck.com/ritou/webauthn-at-droidkaigi-2019)
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時ぐらいのものです。
ダウンロード数が少ないので、そんなにガッツリ使われてない気がしないでもないですが、一番上にあるのが "Wax" です。
それでは見ていきましょう。
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実装が増えるかもしれません。
安全で便利な認証方式の普及により世界を少しでも平和にできるように、今後もこの分野に注目していきたいと思います。
認証がおかしくなったら生きた気がしなくなるので仕事にするのはおすすめしません。
— 👹秋田の猫🐱 (@ritou) December 14, 2020
ではまた。