こんにちは。株式会社メルペイのID Platformチームでエンジニアをしている @nerocrux です。この記事はMERPAY TECH OPENNESS MONTHの12日目の記事です。
はじめに
人類は20年以上前からパスワードと戦ってきました。子供の誕生日をパスワードとして使うのをやめて乱数生成器に生成させたり、ノートにパスワードを書き込むのをやめて、1passwordを使ったりして進化してきました。そして最近ようやくパスワード自体が問題の根源だと理解し、パスワードを使わない認証技術に手を付けた。パスワードの代わりに、指紋・虹彩などの生体特徴や、セキュリティチップが組み込まれたUSBキー、Android携帯などの端末を利用して認証できる仕組みと、それを利用するWebサービスが増えてきました。
このような仕組みを取りまとめ、標準化するのを目指して、2013年に標準化団体FIDO Alliance (Fast IDentity Online) が結成されました。
WebAuthn (Web Authentication API の略)はFIDO Allianceが発表した標準仕様FIDO2の一部として策定され、ブラウザ経由でパスワードレス認証を可能にする仕組です。この仕組は、2019年3月にW3Cの標準化勧告にされていて、注目を集めています。各メジャーなブラウザもすでにWebAuthnをサポートしており、開発者にとって実装環境が整っています。
メルカリでは常にこういった新しい技術に対するキャッチアップを行っており、技術検証(あとは単純に筆者の趣味)の文脈で、Go言語でWebAuthnの認証サーバを作ってみました。
本記事では、WebAuthnの仕組みと実装を紹介したいと思います。
WebAuthnについて
概要
WebAuthnは、パスワードレス認証を可能にするための仕組み(API)です。このAPIは、パスワードなどのクレデンシャルを取得/保存する機能をウェブサイトの開発者に提供するCredential Management APIの拡張として策定されています。
多くの認証システムは、サーバに送信されたID/パスワードの組み合わせの正しさをサーバが検証することで、認証を行ってますが、WebAuthnの認証システムでは、ID/パスワードの代わりに、公開鍵/秘密鍵
の組み合わせを利用して認証を行います。認証の大まかな流れは以下になります。
ID/パスワードを利用する認証システムと異なって、WebAuthnの認証システムを利用することで、サービス提供者にでもサービス利用者にでもいくつかメリットがあるかと思います。
- クレデンシャルとなる秘密鍵は認証器に保存されているため、ユーザはパスワードを暗記する必要がありません。そのため、パスワードを忘れたり他人に見られたりすることはありません。
-
サービス提供者はパスワードの代わりに公開鍵を保存することになります。そのため、オペミスや外部からの攻撃によって、パスワードを漏洩することがなくなります。もちろんその際公開鍵が流出されることになり、パスワード漏洩と同じ深刻だと感じる人がいるかもしれませんが、サーバ管理者にとって秘密情報が含まれているパスワード(ハッシュ化済みのものにしても)を管理するに比べると、セキュリティリスクが低くなると言えるでしょう。
-
ネットワーク経路上にパスワードが流れるとがなくなり、認証プロセスがより安全になります。
もちろん、WebAuthnの認証システムにも問題点があると考えています。
- 記憶できるパスワードに比べると、物理の認証器はなくしやすいと感じる方がいるかもしれません。認証器を紛失した場合、パスワードの紛失と同様にセキュリティリスクが生じます。
-
テキスト入力で完結できるパスワード認証に比べると、認証器を操作する必要があるため、認証のプロセスが煩雑と感じる人がいるかもしれませんし、認証器の携帯や保管が大変だと感じる人もいるかもしれません。
システム構成
WebAuthnを利用した認証システムは、以下の3つのパートで構成されています。
- サービス提供者が提供する認証サーバ(RP, Relying Partyとも呼ばれてます)
WebAuthnの標準仕様に従って実装されています。
-
ユーザがサービスを利用するためのブラウザ
ユーザとのインタラクションだけでなく、認証器とのやり取りも発生するため、ブラウザは Web Authentication API をサポートする必要があります。APIからリクエストを受けると、ブラウザは認証器との間に CTAP2 (Client To Authenticator Protocol 2.0) というプロトコルを利用して通信し、認証に必要な情報を提供してもらいます。
現在
Safari以外(※ 19/05/16: Webkit の WebAuthnがデフォルトONになっていて、TP82以降は動作しています)のメジャーブラウザ(chrome, firefox, edge, opera, chrome android, firefox android, etc..)はWebAuthnをサポートしています。 -
秘密鍵、デジタル証明書が保存されている認証器
ユーザはなにかしらの手段で自分の秘密鍵を保持する必要があります。異なるPC上で認証するニーズがあれば、秘密鍵を安全に保管できる、セキュリティチップなどが組み込まれたUSB型の認証キー(例えばyubikey)、あるいはスマートフォンを認証器として利用することが多いです。
登録と認証
パスワード認証と同様に、WebAuthnの認証システムも登録
と認証
の2つのステップで構成されます。
- 登録: ユーザ名、氏名、住所等一般的な個人情報だけでなく、認証のために認証器の公開鍵も登録します。
- 認証: 認証器を利用してユーザ認証を行います。
登録
登録ステップでは、認証サーバがRegister
, AttestationResponse
の2つのエンドポイントを提供する必要があります。処理の流れは以下になります。
⓪ ユーザが登録情報をRegisterエンドポイントに送信し、ユーザ登録を要求します。
これによってユーザ/認証器登録が始まります。
このエンドポイントは登録対象のユーザー情報を受け取ります。エンドポイントのリクエストの仕様やサーバの挙動はWebAuthnに定められていません。
アカウント登録時に必要な情報をサーバに送信します。以下の例はログイン時に必要なusernameのみをリクエストに含めたものです。サービスの仕様によって、氏名や住所など必要なユーザ情報を一緒に送信します。
POST /register HTTP/1.1 Host: auth.example.com username=nerocrux
送られた情報はあとで参照しますので、適切なバリデーションを行い、DBに一度保存します。
① サーバがフロントエンド(Javascript)に対して、レスポンスを返します。
ユーザ情報を保存後、認証サーバはChallengeを生成しユーザ情報と一緒に保存しておきます。Challenge, RelyingPartyの情報など、次のスッテップに必要な情報を含めたレスポンスを返します。
このレスポンスのフォーマットは特に決まっていませんが、ステップ②のJavascriptプログラムが取扱しやすくするため、なるべくWebAuthnの仕様に合わせると良いでしょう。今回では以下の形のレスポンスを返します。
HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 { "status":"ok", "challenge":"jF_lhtayhqlvyKnLfbkh877Y-ff4Ifyn84CAoI4tCmE", "rp":{ "name":"webauthn-sample" }, "user":{ "id":"839ca44e-02df-4468-822d-5e8dc06e3fc4", "name":"PeichaoYu", "displayName":"nerocrux" }, "attestation":"direct" }
各パラメータの説明を以下にまとめています。
パラメータ | 説明 |
---|---|
challenge | 署名の正当性を検証するためのランダムな文字列。攻撃者に入手されると公開鍵が攻撃者のものに置換られる脆弱性が生じるので、サーバ上に生成し、類推されにくい文字列である必要があります。仕様では16文字以上の長さを持つランダム文字列であることを推奨しています。 |
rp | 認証サーバの情報が含まれています。 |
user | ユーザの登録情報が含まれています。 |
attestation | Attestation Conveyance Preferenceというもので、認証器からクレデンシャルをどのように認可サーバが受け取るかを示します。 このパラメータの値は、”none”, “indirect”, “direct”に設定可能で、例えば “direct” に設定した場合、認証器のクレデンシャル情報を認可サーバが “attestation statement” というパラメータを通して直接に受け取るを意味します。 “attestation statement” について後ほど説明します。 |
② Javascript側はWebAuthnの navigator.credentials.create()
を呼び出し、認証器に認証データを用意させます。
Javascript側では、以上の情報の他にいくつかのパラメータをリクエストに加えて、navigator.credentials.create()
を呼び出します。リクエストの仕様はW3Cの5.4. Options for Credential Creationに記載されています。今回では以下のリクエストを作っています。
{ "challenge":"jF_lhtayhqlvyKnLfbkh877Y-ff4Ifyn84CAoI4tCmE", "rp":{ "name":"webauthn-sample" }, "user":{ "id":"839ca44e-02df-4468-822d-5e8dc06e3fc4", "name":"PeichaoYu", "displayName":"nerocrux" }, "attestation":"direct", "pubKeyCredParams":[ { "type":"public-key", "alg":-7 } ] }
認証サーバからもらったパラメータに加えて、pubKeyCredParams を追加しています。このパラメータは、認証器はどのような暗号アルゴリズムを使って鍵を作成するかを指定します。alg
の -7
は ECDSA-SHA256
(ES256) を意味します。このコードはCOSE Algorithmに準していて、こちらから参照できます。
そのほかにも以下のようなパラメータがあります。
パラメータ | 説明 |
---|---|
authenticatorSelection | どのような認証器を許容するか を限定できる。例えばクロスプラットフォームの認証器であるyubikeyを使わせるか、それともプラットフォーム限定のTouchIDのみに限定させるか、など。 |
timeout | ③で説明するユーザと認証器のインタラクション のタイムアウト値。 |
navigator.credentials.create()
を呼び出すことで、ブラウザがchallengeやoriginなどのパラメータが含まれる clientDataJSON を用意し、その他のパラメータに合わせて、ブラウザは認証器に対してauthenticatorMakeCredential()コマンドを呼び出します。
clientDataJSON: base64url でエンコードされているJSON Objectで、デコードすると以下のような情報が見れます。
{ "challenge":"upYb6sib9exL7fvSfQhIEazOkBh8_YJXVPzSx0T16B0", "origin":"https://auth.example.com", "type":"webauthn.create" }
ブラウザの挙動やブラウザと認証器の間のやり取りは我々認証サーバの開発者は特に意識する必要がありませんが、興味があればCTAP2の仕様を覗いてみると楽しいです。
③ 認証器がユーザ確認を行い、公開鍵とAttestationを作成します。
ブラウザが認証器を選択し、本人確認を行いなさい
を促すポップアップを表示します。
yubikeyを利用する場合、ユーザが本人の存在確認(光るボタンを指でタップする)を行います。認証器の仕様によって、指紋や虹彩等によって本人確認も行う場合もあります。
本人確認が成功したら、認証器がこの登録セッションのために、新しい公開鍵とAttestationを作成します。Attestationとは、認証器の有効性や正当性を証明するためのパラメータです。
④ 認証器が作成された公開鍵とAttestation等の認証情報をattestationObjectとしてブラウザに返します。
attestationObjectはCBORエンコードされた上でbase64urlエンコードされています。詳しくはAttestationについて
パートにて説明します。
⑤ Javascriptは、サーバのAttestationResponseエンドポイントに送信します。
ブラウザがattestationObjectを取得後に、navigator.credentials.create()
メソッドのレスポンスを用意してJavascriptに返します。attestationObjectのほかにいくつかのパラメータが追加されてます。具体的なレスポンスは以下の形になります。
{ "rawId":"OdZyvqSkSlsL5qYX2HECsR_rBLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "response":{ "attestationObject":"o2NmbXRoZmlkbxxxxx-ODGsrPjUhg9gDkfa9J50mpMg2R05sLaI5P_rB1QNGxcI5BlV5M-xxxxx-aaaaaa", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJ1cFliNnNpYjlleEw3ZnZTZlFoSUVhek9rQmg4X1lKWFZQelN4MFQxNkIwIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo5MDAxIiwidHlwZSI6IndlYmF1dGhuLmNyZWF0ZSJ9" }, "id":"OdZyvqSkSlsL5qYX2HECsR_rBLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "type":"public-key" }
各パラメータの意味は以下になります。
パラメータ | 説明 |
---|---|
rawId | 公開鍵を特定するためのユニークID |
id | rawIdをbase64urlエンコードしたもの |
type | “public-key”固定 |
⑥ サーバは認証情報を公開鍵を使って検証し、情報が正しければ登録を成功とします。
認証サーバは受信した認証情報を検証します。
- clientDataJSON.challengeとサーバが要求するchallengeは一致するか
- clientDataJSON.originとサーバのhostnameは一致するか
- attestationObjectは有効か
サーバは attestationObject を検証することで、登録しようとする公開鍵は、登録に使われている正当性のある認証器から生成されたものであることを確認できます。具体的な検証方法はAttestationについて
のパートで説明します。
検証成功後、適切な成功レスポンスを返し、認証時に利用するために、公開鍵をDBに保存します。
これでユーザ/認証器の登録が完了になります。
認証
認証ステップでは、認証サーバがLogin
, AssertionResponse
の2つのエンドポイントを提供する必要があります。処理の流れは以下になります。
⓪ ユーザが Username
のみをLoginエンドポイントに送信し、認証を要求します。
登録と同様に、loginエンドポイントのリクエスト仕様もWebAuthnの仕様の範囲外です。通常、ユーザIDなど、ユーザを特定できるユニークな識別子を利用することが多いでしょう。
POST /login HTTP/1.1 Host: auth.example.com username=nerocrux
① サーバが登録済みチェックを行い、フロントエンド(Javascript)に対して、レスポンスを返します。
登録に似たように、認証サーバがChallengeを生成し、DBに保存します。
さらに認証サーバはDBに保存されている認証器が作成した公開鍵の情報をallowCredentials
に格納して返します。こちらも登録と同様に、レスポンスの仕様が決まってないですが、ステップ②のJavascript側のプログラムを取扱しやすくするように、レスポンスはWebAuthnの仕様に合わせると良いでしょう。
HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 { "status":"ok", "challenge":"GkDyd2YlBDqtePUZosNJVSQrB_WjLNMwNbMLSAWBH0s", "allowCredentials":[ { "type":"public-key", "id":"HGDwB61CiOLcI8JnS0AifQdPW2MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "transports":[ "usb", "nfc", "ble" ] } ] }
allowCredentialsに含まれている情報は以下です。
パラメータ | 説明 |
---|---|
type | “public-key”固定文字列 |
id | 公開鍵を特定できるID |
transports | 認証器からAssertion情報を受取るための可能な通信手段 |
また、allowCredentials, challenge以外に、認証と同様に、認証器での本人確認のtimeoutを設定することも可能です。
② Javascript側はサーバから返ってきたレスポンスからWebAuthnの navigator.credentials.get()
を呼び出し、認証器に認証データを用意させます。
登録と似たように、Javascriptはサーバから受け取った情報を利用し、navigator.credentials.get()
を呼び出します。呼び出す際のリクエストは、①で示している認証サーバから受信したリクエストをそのまま利用してよいです。
ブラウザがリクエストを受信したら、challengeやoriginなどのパラメータが含まれる clientDataJSON を用意し、認証器に対してauthenticatorGetAssertion()コマンドを呼び出します。
③ 認証器がユーザ確認を行い、Assertionを作成します。
Assertion とは、認証器が署名した、ChallengeやRelyingPartyの情報が含まれたステートメントです。
ChallengeやRelyingPartyの情報はclientDataJSONをハッシュ化した形(clientDataHashと命名されている)で認証器に渡されます。それに加え、認証器は自身の情報が含まれているauthenticatorDataを含めたステートメントを秘密鍵で署名します。生成された署名はassertion signatureと呼ばれます。
④ 認証器が作成された認証情報をブラウザに返します。
認証器が③で生成されたassertion signature, clientDataHash, authenticatorDataをブラウザに返します。
⑤ Javascriptが認証器から受信した認証情報やclientDataを含めたリクエストを用意し、AssertionResponseエンドポイントに送信します。
登録時と同様に、Javascriptは認証器から受信したパラメータに加え、rawId、id、type, clienetDataJSON などのパラメータを含め、AssertionResponse エンドポイントに送信します。
リクエストは以下の形になります。
{ "rawId":"HGDwB61CiOLcI8JnS0AifQdPW2MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "response":{ "authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAEQ", "signature":"MEYCIQDiZwG_qNIBKUsYLft9xWR9Fsg0hqQK2Lj8tUZcI6q1GwIhALJ5lys-lpxg58_IbGVfnyJccpK3YMXaUeRN_aQN0owy", "clientDataJSON":"eyJjaGFsbGVuZ2UiOiJHa0R5ZDJZbEJEcXRlUFVab3NOSlZTUXJCX1dqTE5Nd05iTUxTQVdCSDBzIiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDo5MDAxIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9" }, "id":"HGDwB61CiOLcI8JnS0AifQdPW2MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "type":"public-key" }
⑥ サーバは認証情報を公開鍵を使って検証し、情報が正しければ登録を成功とします。
認証サーバはsignatureの有効性を検証します。具体的な検証方法はAssertion について
で説明します。
検証成功後、認証成功とします。セッションを発行しセッションIDをCookieにセットする等、一般的な認証サーバと同じように処理を継続します。
Attestation について
Attestationは、主に公開鍵をサーバが保存するときに、その公開鍵は攻撃者が置き換えたものではなく、ユーザが所持する認証器から生成されたものであることを保証するための仕組みです。
AttestationResponseへのリクエストの中に、認証器の情報や公開鍵などの情報が含まれたAttestationObjectの有効性を認証サーバが検証することで、登録しようとする公開鍵は登録対象となる認証器による生成されたものかを検証できます。
ただ認証器の種類によってAttestationのフォーマットが異なります。例えばyubikeyなどのU2FデバイスをサポートするためにFIDO U2FのAttestationをサポートしなければならない、Android PhoneをサポートするならAndroid KeyやAndroid SafetyNetのAttestationをサポートしなければなりません。
現在、W3Cが定義しているAttestation Formatは以下の6種類があります。
- FIDO U2F
- Packed
- TPM
- Android Key
- Android SafetyNet
- None
今回はFIDO U2F
フォーマットを例として、Attestation検証を紹介します。
FIDO U2FフォーマットはFIDO U2F認証器のFIDO U2F Raw Message Formatsとの互換性を担保したフォーマットで、よく見かけるyubikeyはこのフォーマットをサポートしています。
Attestation検証の概要
一般的なU2F認証器のセキュリティチップに、Attestation Private Keyと呼ばれる秘密鍵が埋め込まれています。その秘密鍵に対応する公開鍵とその公開鍵を含んだ証明書Attestation Certificateはベンダーのroot CAに署名されると思われます。例えばyubikeyの場合、Attestation Cerfiticateはyubikeyを生産しているyubico社のroot CAに署名されています。
登録が行うたびに、認証器が新しい鍵ペアを生成し、その公開鍵に対して、Attestation Private Keyが署名します。署名(Attestation Signature)は新たに生成された公開鍵、Attestation Certificateと一緒にAttestationObjectに含まれ、認証サーバに送信されます。
認証サーバはAttestation Certificateを使って署名を検証し、署名が有効であれば一緒に送られてきた公開鍵が有効である(登録しようとする、正当性のある認証器が作られた公開鍵である)ことを判定できます。
AttestationObject のフォーマット
前文で紹介したとおり、AttestationObjectはbase64urlでエンコードされているCBORです。AttestationObjectの構成は以下になります。
実際CBORパース後のAttestationObjectは以下の形になります。
{ "fmt":"fido-u2f", "attStmt":{ "sig":h'304402...', "x5c":[ h'3082017E30820124A00...' ] }, "authData":h'49960DE5880...' }
各パラメータの意味は以下になります。
パラメータ | 説明 |
---|---|
fmt | attestation statement format。 署名はどう表現されるか、認証器がattestation情報を表現するためのattStmtはどういったフォーマットになっているかを示すパラメータです。 |
attStmt | attestation statement。 fmtや証明書アルゴリズムによって異なりますが、Attestation Certificate(x5c)やAttestation Signature(sig)などが含まれています。 |
authData | authenticator data。 認証器に作られた、認証器の信頼性やセキュリティ等に関連する情報が含まれているバイナリデータです。authenticatorDataについて後ほど詳しく説明します。 |
AttestationObject の検証
AttestationObject の検証について説明します。
AttestationObject の検証のやることは、雑に言うと AttestationObject をパースして得られるパラメータ (verificationData、 certificate public key) を使って、Attestation Signature の有効性を検証します。
AttestationObject のパースや具体的な検証方法の具体的な手順は、W3CのドキュメントのVerification procedure
の部分に記載されてますが、全部紹介すると長くなりますので、ポイントと思うところをピックアップして紹介したいと思います。
1. AttestationObject のデコード
まずAttestationResponseで受信した文字列のattestationObjectをデコードします。結果はAttestationObject構造体になります。
AttestationObject(CBOR)のデコードは、ugorji/go を利用してます。
type AttestationObject struct { Fmt stringcodec:"fmt" cbor:"fmt"
AttStmt AttStmtcodec:"attStmt" cbor:"attStmt"
AuthData []bytecodec:"authData" cbor:"authData"
} func ParseAttestationObject(attestationObject string) (*AttestationObject, error) { attestationBin, err := base64.RawURLEncoding.DecodeString(attestationObject) if err != nil { return nil, err } var v AttestationObject var ch codec.CborHandle dec := codec.NewDecoderBytes(attestationBin, &ch) if err := dec.Decode(&v); err != nil { return nil, err } return &v, nil
2. authenticatorData について
次に、authenticatorData (AttestationObject.AuthData)をパースします。
authenticatorData は認証器に作られた、認証器の信頼性やセキュリティ等に関連する情報が含まれているバイナリデータです。このパラメータを決められたbyte数でパースすることで、いくつかのパラメータを取得できます。これらのパラメータの意味はこちらを参照するとわかりやすいです。
さらに、authenticatorDataの中に、attestedCredentialDataというパラメータがあり、認証器の情報や公開鍵の情報が含まれてます。このパラメータもバイナリで表現されており、利用時にauthenticatorDataと同様に、パースする必要があります。パース方法や各パラメータの意味はこちらから参照できます。
FIDO U2F フォーマットの authenticatorData は以下のようにパースします。
type AuthData struct { rpIDHash []byte flags byte signCount uint32 attestedCredentialData AttestedCredentialData } type AttestedCredentialData struct { aaguid []byte credIDLen uint16 credID []byte credentialPublicKey []byte } func parseAuthData(authData []byte) AuthData { ad := AuthData{} ad.rpIDHash = authData[:32] ad.flags = authData[32] signCount := authData[33:37] ad.signCount = binary.BigEndian.Uint32(signCount) acd := AttestedCredentialData{} acd.aaguid = authData[37:53] credIDLen := authData[53:55] acd.credIDLen = binary.BigEndian.Uint16(credIDLen) acd.credID = authData[55 : 55+ad.credIDLen] acd.credentialPublicKey = authData[55+ad.credIDLen:] ad.attestedCredentialData = acd return ad }
3. credentialPublicKey のパース
authenticatorData をパースして得られた credentialPublicKey を ANSI X9.62 証明書の公開鍵に書き出します。
credentialPublicKey は COSE 形式になっています。COSEとはCBORを使って署名と暗号化を行うプロトコルで、JWTを使ったJOSEに似たようなものだと理解するとよいかと思います。
credentialPublicKey を以下のようにパースします。
type CoseEcdsaPublicKeyStruct struct { PublicKeyData Crv int64codec:"-1,omitempty" cbor:"crv"
X []bytecodec:"-2,omitempty" cbor:"x"
Y []bytecodec:"-3,omitempty" cbor:"y"
} type PublicKeyData struct { _struct boolcodec:",int" json:"public_key"
Typ int64codec:"1" json:"kty"
Alg int64codec:"3" json:"alg"
} func ParseCOSE(credentialPublicKey []byte) *CoseEcdsaPublicKeyStruct { var v CoseEcdsaPublicKeyStruct var pk PublicKeyData var ch codec.Handle = new(codec.CborHandle) codec.NewDecoder(bytes.NewReader(credentialPublicKey), ch).Decode(&pk) codec.NewDecoder(bytes.NewReader(credentialPublicKey), ch).Decode(&v) v.PublicKeyData = pk return &v }
そして、得られた構造体を使って、以下のように公開鍵(publicKeyU2F)を書き出します。
func CoseToX962Raw(v CoseEcdsaPublicKeyStruct) []byte { pubKey := bytes.NewBuffer([]byte{0x04}) pubKey.Write(v.X) pubKey.Write(v.Y) return pubKey.Bytes() }
4. verificationData の準備
ここに来ると、署名対象となるverificationDataを組み立てる準備ができました。verificationData は、以上のステップで得られたパラメータ(バイナリ)を組み合わせることで得られます。具体的は以下のパラメータを利用します。
0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F
clientDataHash は clientDataJSON を base64url デコードし、SHA-256でハッシュ化することで得られます。
clientData, err := base64.RawURLEncoding.DecodeString(a.clientDataJSON) if err != nil { return nil, err } clientDataHash := sha256.Sum256(clientData)
各パラメータはこんな感じで連結します。
verificationData := bytes.NewBuffer([]byte{0x00}) verificationData.Write(ad.rpIDHash) verificationData.Write(clientDataHash[:]) verificationData.Write(ad.credID) verificationData.Write(credentialPublicKey)
5. 証明書フォーマットの変換
attStmt.x5c (X.509 Certification Chain)の中に、バイナリ形式(ASN.1データ構造)の証明書が含まれています。goでX.509証明書を取り扱うときに、ASN.1からx509.Certificate構造体に変換すると取扱しやすいです。
func ASN1toCert(asn1 []byte) (*x509.Certificate, error) { cert, err := x509.ParseCertificate(asn1) if err != nil { return nil, errors.New("failed to parse ECDSA public key") } if cert.PublicKeyAlgorithm != x509.ECDSA && cert.PublicKey.(*ecdsa.PublicKey).Curve != elliptic.P256() { return nil, errors.New("unsupported public key type") } return cert, nil }
ちなみに x5c は証明書チェーンで、中に複数の証明書を含めることができますが、FIDO U2FのAttestationで利用する証明書(attestnCert)は、必ず配列の1つ目の証明書を使うと仕様に決められています。
6. 署名検証
これで署名検証のためのすべてのパラメータが揃えました。
x509パッケージの func (*Certificate) CheckSignature を使ってSignature (attStmt.sig) を検証します。
func VerifyEcdsaSignatureByX509(signature []byte, verificationData []byte, cert *x509.Certificate) bool { if err := cert.CheckSignature(x509.ECDSAWithSHA256, verificationData, signature); err != nil { return false } return true }
この検証が成功すれば、AttestationObjectが有効であり、Attestation成功となります。
Assertion について
Assertion では、認証サーバが用意するchallengeを含んだ情報を認証器の秘密鍵に署名させ、認証サーバが登録されている公開鍵を使ってその署名(assertion signature)の有効性を検証することで、認証器の有効性を検証する仕組みです。
Attestationの図とは対称性がないですが、大体の流れはこちらの図で表現してみました。
Assertion の検証
W3Cにちゃんと定義されているAssertionの検証手順があります。こちらも検証手順がいくつかありますが、assertion signature検証のところをピックアップして紹介したいと思います。
Assertionの署名を検証するには、authenticatorDataとclientDataJSONを利用します。
1. authenticatorData について
Assertion の authenticatorData の定義は基本的に Attestation と同じです。異なる点は、公開鍵が含まれていないところです。
Attestation と同様に、authenticatorData をパースする必要があります。こちらもだいぶシンプルになってます。
type AuthData struct { rpIDHash []byte flags []byte counter []byte counterUint32 uint32 } func parseAuthData(authData []byte) AuthData { ad := AuthData{} ad.rpIDHash = authData[:32] ad.flags = authData[32:33] ad.counter = authData[33:37] ad.counterUint32 = binary.BigEndian.Uint32(ad.counter) return ad }
2. verificationData の準備
verificationData は、authenticatorData と clientDataHash を組み合わせることで得られます。
authenticatorData || clientDataHash
authenticatorData は文字列なので、base64url デコードする必要があります。
authenticatorData, err := base64.RawURLEncoding.DecodeString(response.AuthenticatorData)
clientDataHash は Attestation と同じように、AssertionResponse の clientDataJSON を base64url デコードし、SHA-256でハッシュ化することで得られます。
各パラメータはこんな感じで連結します。
verificationData := bytes.NewBuffer([]byte{})
verificationData.Write(authenticatorData)
verificationData.Write(clientDataHash[:])
3. 公開鍵の取得と署名検証
Assertionの署名検証はDBに保存されている公開鍵(COSE)を利用します。COSEをAttestation時と同様にパースし、以下の検証のロジックを通して、署名の有効性を検証します。
※ 署名アルゴリズムが ECDSA-sha256
の例のみ
func VerifyEcdsaSignatureByCOSE(cose *CoseEcdsaPublicKeyStruct, verificationData []byte, sig []byte) (bool, error) { type EcdsaSignature struct { R, S *big.Int } key := &ecdsa.PublicKey{ Curve: elliptic.P256(), X: big.NewInt(0).SetBytes(cose.X), Y: big.NewInt(0).SetBytes(cose.Y), } signature := &EcdsaSignature{} _, err := asn1.Unmarshal(sig, signature) if err != nil { return false, nil } h := sha256.New() h.Write(verificationData) result := ecdsa.Verify(key, h.Sum(nil), signature.R, signature.S) return result, nil }
これで、署名検証が完了になります。
実装ヒント
CBOR の取扱
attestation時のattestationObjectやassertion時のauthenticatorDataはCBORになっています。開発時にデバッグするためにCBORをパースしてみたいという気持ちもあるはずです(Valueはだいたいバイナリでパースしても意味がないが、Keyの名前を確認したり、そもそも正しいCBORになっているかを確認するためにパースしたくなるはず)ので、そのときは以下のツールが役に立つと思います。
- base64urlエンコードされているCBORをまず hex に変換する
- CBORの hex をパースする
- goでCBORをパースする際、以下のライブラリを利用しています。
WebAuthnのライブラリ
そもそもフルスクラッチでWebAuthnを実装するのが大変なので、なんかいい感じのライブラリがあるかな?と思う人がいると思います。僕は実装時に以下のライブラリを参考しました。
Go言語以外のライブラリもあるはずですが、あまりリサーチしてなかったので紹介できません。
まとめ
今回は、WebAuthnを実装レベルで理解するため、WebAuthnの認証サーバを実装しながら標準仕様を読みました。完全理解までまだほど遠いですが、WebAuthnの基本的な概念や、登録・認証の流れ、Attestation/Assertionのやり方が大体理解出来ました。この学習プロセスの中で得た知見を本文にまとめてみましたが、ご一読いただき、WebAuthnに対して興味を持っていただけると幸いです。
今後ですが、認可サーバの機能実装だけでなく、運用を含めてWebAuthnの認証サーバのあるべき姿を探究したいと考えています。例えば認証器を紛失したときに、認可サーバはどのようなリカバリ方法を提供するか。また、認証器に不具合や脆弱性が起き Attestation Private Key が漏洩された場合、どうやって問題のある認証器を特定しRevokeするか。
これからもテックブログを通してWebAuthnのみならず認証認可まわりの知見を積極的に発信していきたいと思いますので、ご期待ください!
MERPAY TECH OPENNESS MONTH 明日も続きます!次は laughingman7743 さんの記事です。お楽しみに!