このエントリは認証認可技術 Advent Calendar 2019の17日目の記事となります.が,多分投稿する頃には18日になってしまうのだろうなぁ...
計画性がない上,出来も悪いしネタみたいなエントリですが,良ければみんなもこれを読んで,好きな言語でFIDOサーバをフルスクラッチ開発しよう!!!!!(?)
動機〜開始
認証認可のアドベントカレンダーに登録したけど何も書くことねぇなぁと思っていた一昨日,もう大きな物を準備して書くことが叶わんとわかったので,一日でFIDOのサーバでも実装してやるか!という謎の思いつきが発生.
というわけで今日一日で実装しようとなりました.
元気に今朝起きてみたら昼過ぎだったので,結論から言えば全く間に合いませんでした!すいません!
FIDO2(WebAuthn + CTAP)とは
既に日本語の記事も数多くあるので割愛します.
個人的にはYahooさんが昨年投稿されたここがとてもわかり易いです.
とてもざっくりといえば,パスワードをサーバ間で送信するの代わりに公開鍵をするので,最悪鍵が流出しても安心だね!というものです.(間違っていたら申し訳ない)
さて,詳しいことは先程のリンク先,目次1. 2. 3.で詳細に説明してくれていますので.次行きましょう.
ルール
- 最近学び始めたGoでなんとなく作ってみる
- GoのWebAuthnの為のライブラリは複数あるが,せっかくなら標準ライブラリだけで作りたい
- W3Cが公開している仕様すべてを網羅するのは難しいため,最低限の登録,認証シーケンスを行うことが出来たらOK
-
僕に技術力がないので遊び(+学習)用なので,本来すべき良い設計,きれいなコードは目指さない
これは認証サーバもどき
このエントリ参考にして,あるいはコードを流用して本当の認証サーバを立てるべきではないです.なにしろ全くW3Cの言っている仕様を網羅しているわけではないし,
本来チェックするべき多くの検証が抜けています.本当の本当に最低限動いて楽しい!ってなるだけのものです.
なんとなく僕が興味をもった思いつきで,遊びのために作ったものですので,チョットWebAuthnのサーバってどんな感じの処理してるのかなーぐらいの好奇心を満たすもの程度(多くの方はこの程度では満たされないかもしれない)だと思って見てください.
ぼくは初学者
こうやって保険をかけておきます.書いてあることが正確である保証はありません.なので(たぶん)とか(のはず)を多用します.
W3C読もうね!!!
あとGoの書き方を知りません.どうやったらimmutableな書き方ができるんや...
それ抜きにしても本当にコードが汚いです,許してください.
いざ実装!
結論から申し上げますと,登録までしか実装できませんでした...肝心の認証はまた次の機会ということでご容赦ください.
さて,実装するためにはWebAuthnにおける登録シーケンスを知る必要があります.
今回作るものは最低限の仕様しか網羅しないため,かなり省略したもの(かつ正確性に欠けるもの)となっていますが,大まかには以下のような手順になります.
加えて,本来であれば,クライアントとサーバとの間にRelyingParty(以下,RP)が入り,認証に関する様々なポリシーを設定,ユーザの設定,他色々をする事が前提とされた仕様ではありますが,簡略化のためにサーバ側に統合しました.
多分本運用にするにしても,完全にRPと認証サーバが一対一でしか存在しない想定なら,このような実装でも問題ないと思われます.そんなことがあるのかわかりませんが.
まずシーケンス図を眺めてみましょう(最初のAuthenticatorからBrowserの矢印は無視で)
1. 認証リクエスト (ブラウザ側)
これは単純にブラウザで動いているボタンを押したら,FIDOサーバに向けて認証を要求するエンドポイントを叩くだけです.
よくあるログイン画面のログインボタンを想定しています.今回はHTTP POST
を採用しました
ただ入力欄にパスワードは無く,UserName
とDisplayName
のみです.この2つの使い分けはよく分からんのですが,よく見るのはUserName
がメールアドレスに当たり,DisplayName
は表示名としているところが多いです.UserName
はメールアドレスを指定することも可能ですし,そうでなくても大丈夫です.
サーバ側でのちにUserID
にあたる値をランダムで生成する,あるいはメールアドレスから取る,のような方法により,作る必要があるため,この2つは他のユーザと重複していても大丈夫であると思われます(たぶん)
コードは大体以下のような感じ,ブラウザ側なので,単なるjsです.
async function _sendChallenge() {
const endpoint = "http://localhost:8080/register/challenge"
const userName = document.getElementById("register_username").value;
const displayName = document.getElementById("register_displayname").value;
// build request
const req = {
"username": userName,
"displayName": displayName,
}
// send request
const response = await fetch(endpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(req),
});
return response.json();
}
2. 3. Generate CredentialCreationOptions (サーバ側)
サーバ側ではどのような認証器を使用するか,どんな認証アルゴリズムを用いるか,本人確認はするか,RPの設定,Attestation(後述)をどのような方法でするか....
などなど数多くの認証方法に関する設定を行うことが出来ます.今回は固定します.
またChallengeと呼ばれるリクエスト毎に異なるランダムな値を生成します.これはリプレイ攻撃を回避するためです.
W3Cによると少なくとも16byteの長さで,安全な方法で乱数を生成すべきであるとされています.
この2つの処理によって生成されるパラメータをCredentialCreationOptions
と呼ぶようです.あるいはPublicKeyCredentialCreationOptions
とか言うこともあるらしく,ちょっとわからん.
このパラメータはjsonによって表現可能です.
例のごとく以下にコード
func HandleRegisterChallenge(rw http.ResponseWriter, rq *http.Request) {
// ブラウザからのjsonをGoの構造体に変換
var challengeReq protocol.ChallengeRequest
rawReq := make([]byte, rq.ContentLength)
rq.Body.Read(rawReq)
json.Unmarshal(rawReq, &challengeReq)
// userIDは32byteランダム, challengeは64byteのランダム(クソ適当)
challenge := make([]byte, 64)
userID := make([]byte, 32)
rand.Read(challenge)
rand.Read(userID)
// リクエストから読んだUserNameとdisplayNameを使用
userName := challengeReq.Username
userDisplayName := challengeReq.DisplayName
// build options
credOptions := protocol.ChallengeResponse{
PublicKey: protocol.PublicKey{
Challenge: challenge, // ランダムな64byte
Attestation: "none", // Attestationは今回行わないので"none"
TimeOut: 60000,
Rp: protocol.Rp{ // RPの設定,本来はRPサーバがやるもの
ID: "localhost", // RPのドメイン
Name: "Hiragi Corp",// RPの名前
},
User: protocol.User{ // 登録するユーザの情報,本来はRPサーバがやるもの(多分)
ID: userID, // 一意なID
Name: userName, // 登録画面で入力されたUserName
DisplayName: userDisplayName, // 表示名
},
PubKeyCredParams: []protocol.PubKeyCredParam{ // どんな公開鍵のアルゴリズムを使えるかの配列
protocol.PubKeyCredParam{ // 今回は一個のみ, 公開鍵認証で COSE -7 (ref RFC8152)は ECDSA SHA-256らしい
Type: "public-key",
Alg: -7,
},
},
},
}
...
さて,色々とよく分からんprotocol.Hoge
が出てきましたね...
実はprotocol
パッケージ下では,jsonと対応する構造体の型がひたすら定義されています.一つづつ見ていく...のはつらいのでコードから雰囲気を掴んでください.
だいたいこんな感じの構造体を作って値を設定してやったらOKです. ここらへんは本当にJavascriptのほうが向いています.jsonなので当たり前だけど
4. Store Challenge, UserID (サーバ側)
生成したChallengeとUserIDを紐付けてを一時保存します.この情報は改ざんされない領域に保存されるべきです.ブラウザのセッションや,サーバ側のDBでも,改ざんされなければ場所は問わない(はず)です.
UserID
は先程認証リクエストで入力してもらった値ではなく,サーバ側で生成した一意な値です.
ここで保存したものは次に公開鍵とともに来るリクエストが正当であるかどうかのものなので,一定時間で削除すべきです.
今回DBを用意するのが面倒だったので,Goの機能の一つであるmapでsessionっぽいなにかを作ってそこに放り込んでいくことにします.
...
// set session
Sessions[fmt.Sprintf("%x", challenge)] = SessionData{
UserID: userID,
Expire: time.Now().Add(time.Duration(time.Second * 60)).Unix(),
}
...
SessionsData
型はただ単にUserIDとExpireを持つ構造体です. 変数Sessionsはvar Sessions map[string]SessionData
みたいな宣言がグローバルでなされています.
5. Send CredentialCreationOptions (サーバ側)
-
- で作った物をブラウザに返します.POSTに対するレスポンスとして返却します.
jsonに変換してからレスポンスに書き込んで終わり!
...
// jsonに変換
response, err := json.Marshal(credOptions)
if err != nil {
log.Print(err)
}
// ヘッダをセットしてレスポンスに書き込み
rw.Header().Add("Content-Type", "application/json")
rw.Write(response)
}
6. navigator.credential.create() + 7. 8. (ブラウザ Javascript)
さて,ブラウザ側にお帰りなさい.
ここは実際に認証器を用いてキーベアを生成するところです.
ブラウザ上のJavaScriptで先程返却されたCredentialCreationOptions
を引数としてnavigator.credential.create()
を呼びます.
この関数は内部でCTAPを用いた認証器との通信を行っています.ブラウザ実装ですが,ちゃんと仕様が策定されているはずなので違いは無いはず.
CTAP/CTAP2は低レイヤー過ぎて僕はまだ触れていません.これについては今回のアドベントカレンダー22日目のgeboさまが書かれるみたいなので楽しみにしております...
そういえばWebAuthnはIE, Opera(Android含む), Safari(iOS含む)以外のブラウザで対応しています.Safariで使えないのは痛いなぁ(普及的な意味で)と思っていましたが
実はiOS13.3からSafariも対応するとかしないとか. FaceIDとかTouchIDにも使えたら更に普及が目指せるかもしれませんね,少し期待です.
さて,こいつを呼びたいですが,呼ぶ前に,jsonのままだとbase64されていて,動いてくれません. navigator.credential.create()
はArrayBuffer
ライクな物を要求してきます.
ということで,bufferDecode()
というものを作りまして,base64をdecodeしてから引数に渡してやります.
function bufferDecode(value) {
return Uint8Array.from(atob(value), c => c.charCodeAt(0));
}
// send challenge request
const credOptions; // これがサーバから送られてきたCredentialCreationOptionsだと思いましょう
// base64 decoding
credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge)
credOptions.publicKey.user.id = bufferDecode(credOptions.publicKey.user.id)
// create credentials
const clientData = await navigator.credentials.create(credOptions);
話がそれましたが,こいつを呼んだ返り値が実際の公開鍵やら,様々な情報が詰まったjsonです.見た目はだいたいこんな感じです.
こいつの名前はPublicKeyCredential
とか呼ばれるみたいです.ソースコード中ではclientData
としています.
{
"id": "EbNlO4Ai2F-2ZDe2Kd6FG_VEtFkymOI8ML6ljEnZXYGoCII1FY4dm8UHRQtBNlJ37WBfy9VEjFunDhlCPW8Vbg",
"rawId": "EbNlO4Ai2F-2ZDe2Kd6FG_VEtFkymOI8ML6ljEnZXYGoCII1FY4dm8UHRQtBNlJ37WBfy9VEjFunDhlCPW8Vbg",
"type": "public-key",
"response": {
"attestationObject": "o2NmbXRmcGFja2VkZ2F0dFN0bXSjY2FsZyZjc2l 〜中略〜 ftYF_L1USMW6cOGUI9bxVupQECAyYgASFYIGNS96nGQ5mPVrSeWQOMTuPpA-fjiQyfuZVf-_7ol884IlggTQGANRYL_ajap1v8cO_vokedD3FPk2taaUE82WxEUfY",
"clientDataJSON": "eyJjaGFsbGVuZ2UiOiJObVptTURJM1lXW 〜中略〜 HM6Ly9sb2dpbi55YWhvby5jby5qcCIsInR5cGUiOiJ3ZWJhdXRobi5jcmVhdGUifQ"
}
}
見てわかる通り,jsonのガワをかぶったjsonではないなにかです.まぁとりあえずこのままサーバにぶん投げ返してやりましょう,(ぶん投げ返していいのかは知りません.今回はぶん投げ返しました)
こいつの中身が何を意味しているのかは後ほど説明します.とりあえず「なんか公開鍵とか認証器の情報がエンコードされて打ち込まれている」ぐらいの認識で行きましょう
9. Request PublicKeyCredential
さて,例のごとくjson文字列に変換してからサーバ側に投げ返します.ArrayBuffer
ライクな値に関してはbase64してから投げますので,bufferEncode
を実装(されたものを拝借)しました.
async function _sendAttestation(clientData) {
const endpoint = "http://localhost:8080/register/attestation"
const type = clientData.type;
const id = clientData.id;
const rawId = bufferEncode(clientData.rawId);
const attestationObject = bufferEncode(clientData.response.attestationObject);
const clientDataJSON = bufferEncode(clientData.response.clientDataJSON);
console.dir(clientData);
console.log(clientDataJSON);
const response = await fetch(endpoint, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
rawId,
type,
response: {
attestationObject,
clientDataJSON,
},
}),
});
return response.json()
}
10. ~ 14. Parsing
怒涛のパースです. PublicKeyCredentialはそれはもうエンコードにエンコードを重ね,とんでもない多重構造となっております.
10. Decode base64
パース前のデータ構造,及び形式は以下の通りになっております.
idとrawIdの違いですが,よくわかりません.navigator.credential.create()
の返り値の時点では,rawIdがArrayBufferで返って来ますが,送信時にbase64されるため,サーバ側ではidと等価です.
{
"id": "rawIdをbase64した文字列",
"rawId": "Idと同じ",
"type": "「public-key」で固定",
"response": {
"attestationObject": "CBORのバイト列をbase64した文字列",
"clientDataJSON": "JSON文字列をbase64した文字列"
}
}
さて,9.
では,ArrayBuffer
ライクな値であるattestationObject
とclientDataJSON
をBase64していたため,サーバ側でとりあえず戻してあげましょう.
func HandleRegisterAttestation(rw http.ResponseWriter, rq *http.Request) {
// JSON文字列をGoの構造体に変換
var tagAuthAttResp protocol.JsonTagAuthenticatorAttestationResponse
rawReq := make([]byte, rq.ContentLength)
rq.Body.Read(rawReq)
json.Unmarshal(rawReq, &tagAuthAttResp)
// cliendDataJSONのbase64文字列をバイト列にデコード
decodedClientDataJSON, _ := base64.StdEncoding.DecodeString(tagAuthAttResp.Response.ClientDataJSON)
// attestationObjectのbase64文字列をバイト列にデコード
decodedAttObj, _ := base64.StdEncoding.DecodeString(tagAuthAttResp.Response.AttestationObject)
これによってPublicKeyCredential
はどのように変化したのでしょうか?
{
"id": "rawIdをbase64した文字列",
"rawId": "Idと同じ",
"type": "「public-key」で固定",
"response": {
"attestationObject": "CBORのバイト列", <- 変化!
"clientDataJSON": "JSON文字列を示すasciiバイト列" <- 変化!
}
}
attestationObject
がCBORのバイト列に,clientDataJSON
はJSON文字列のバイト列になりました
11. Parse ClientDataJSON
clientDataJSON
はJSONの文字列そのものなので,これもJSON化しましょう.Goなら2行で出来ます.
...
// JSON -> go struct
var tagClientDataJSON protocol.JsonTagClientDataJSON
json.Unmarshal(decodedClientDataJSON, &tagClientDataJSON)
...
そして,clientDataJSON
の構造は以下の通りです.
challengeはちゃんと先程認証リクエストが飛んできたクライアントからの通信であるかの検証に使われます.
originはちゃんと同一オリジンのページで認証器が使用されたかの検証に使われます.
typeはこのリクエストがwebauthnの登録リクエストであることを表します.
要はclientDataJSONとはクライアント側が投げたリクエストがちゃんと正当なものであるかを証明するためのデータです(多分)
{
"challenge": "3.で生成したchallengeをbase64した文字列",
"origin": "navigator.credential.create()を実行したサイトドメインの文字列",
"type": "「webauthn.create」固定",
}
その結果,PublicKeyCredential
は以下のような構造になります.
{
"id": "rawIdをbase64した文字列",
"rawId": "Idと同じ",
"type": "「public-key」で固定",
"response": {
"attestationObject": "CBORのバイト列",
"clientDataJSON": { <- 変化!
"challenge": "3.で生成したchallengeをbase64した文字列",
"origin": "navigator.credential.create()を実行したサイトドメインの文字列",
"type": "「webauthn.create」固定",
}
}
}
12. Parse AttestationObject
さてやってまいりました,(初学者の僕には)最難関です.
このattestationObject
はCBOR(Concise Binary Object Representation)
と呼ばれるJSON
を更にコンパクトにしたフォーマットで格納されています.
認証器は,非力で小さな保存領域や転送能力しか持っていない事が多いため,より効率的にデータを転送されるために採用されたようです.
attestationObject
には何が格納されているか,というと,この登録リクエストがどんな種類のアルゴリズムを用いて登録してほしいか,あとは公開鍵そのもの,認証器の情報などが格納されています.
加えて,attestationというのは,使われた認証器が正当なものであるか証明するための仕組みです.今回は検証しませんので,attestationはなし,ということになります.
本来であれば,セキュリティキーを販売,開発しているベンダーが公開する署名を用いて,今回登録リクエストに使用された
セキュリティキーが本当に正規なものなのか,などを検証することが出来ます...が,この当たりの話は自分もよく理解出来ていませんし,説明するにもクソ長くなってしまいそうなので割愛します.
12.1 Parse CBOR
さて,CBORのデコーダを実装するのは流石にちょっとつらいということもあり,こいつに限って外部ライブラリに頼ることにしました.外部ライブラリを利用したデコードのコードが以下になります.
// CBORをGoの構造体に変換
var tagAttObj protocol.CborTagAttestationObject
cbor.NewDecoder(bytes.NewReader(decodedAttObj)).Decode(&tagAttObj)
ライブラリには"bitbucket.org/bodhisnarkva/cbor/go"
を利用させていただきました.
まず構造体を用意し,次の行,bytes.NewReader([]byte)
でio.Reader
を作り,そいつを外部ライブラリに投げ,デコードしております.
さて,このCBORをデコードした後の構造をjsonで表すと以下のようになります.
{
"attStmt": "Attestationを行う時は,Attestationに必要な情報のobject,今回は空"
"authData": "認証器自体のデータを表すバイナリ"
"fmt": "「none,packed,fido-u2f」などの文字列,今回はnone"
}
12.2 Parse authData
次に,authData
をパースします.これはバイナリレベルでフォーマットが決まっていて,指定バイトごとにデータを切り刻んでいけばOKです.
フォーマットは以下の通りです.ATTESTATION OBJECT
は無視して頂き,AUTHENTICATOR DATA
を御覧ください.
CREDENTIAL IDは可変長のため,先にLから長さを取得しておく必要があります.
また,今回EXTENSIONSは無いため,CREDENTIAL IDより後はすべて取得しています.
...
// parse hex auth data (Raw HEX -> go hex struct)
rawHex := tagAttObj.AuthData
credIDLen := binary.BigEndian.Uint16(rawHex[53:55])
hexAuthData := protocol.HexAuthData{
RpIDHash: rawHex[0:32],
Flags: rawHex[32],
Counter: rawHex[33:37],
AAGUID: rawHex[37:53],
CredID: rawHex[55 : 55+credIDLen],
COSEPublicKey: rawHex[55+credIDLen:],
}
...
jsonっぽく表すと以下のようになります.
{
"RpIDHash": "rpIdをSHA-256したバイト列",
"Flags": "UV, UPなどの状態を表すフラグ,ビットごとに表現されている",
"Counter": "この認証器における認証回数バイト列, unsigned int16 bigendian",
"AAGUID": "認証器固有のIDバイト列,Attestationを行わないときは常に0",
"CredID": "公開鍵固有のIDバイト列",
"COSEPublicKey": "公開鍵データをCOSEエンコードしたバイト列",
}
12.2.1 Parse COSEPublicKey
さらにさらに,authData
の中のCOSEPublicKey
は鍵そのものを表していますが,こいつもまたCOSE(CBOR Object Signing and Encryption)
というフォーマットで格納されています.COSEはデータ構造にはCBORを採用し,中身のkey:valueに対して様々な意味をもたせるような仕組みになっております.詳しくはRFC8152で確認しましょう,僕は英語が読めないので無理です.
今回は暗号化アルゴリズムをCOSE -7(ECDSA 256)に固定しているため以下のフォーマットとなります.
{
3: "アルゴリズムの種類, COSE参照 -7固定"
1: "鍵の種類, COSE参照 2固定"
-2: "公開鍵x 32byte"
-3: "公開鍵y 32byte"
}
こいつをパースするとき,内部のkey:valueは keyもvalueも可変です.Goの構造体ではつらいので,map[int]interface{}
を使うことにしました.色々試してたらこれが一番良さそうです.
しかし今回はただひとつのアルゴリズム,認証キーしか用いないため,構造が固定できます.
各キーが数値で人間には読みにくいため,Goの構造体を定義し変換するコードを書きました.
...
// COSEからGoの構造体に変換
mapPublicKey := make(map[int]interface{})
cbor.NewDecoder(bytes.NewReader(hexAuthData.COSEPublicKey)).Decode(&mapPublicKey)
publicKeyData := protocol.PublicKeyData{
Alg: int(mapPublicKey[3].(int64)),
Kty: int(mapPublicKey[1].(uint64)),
X: mapPublicKey[-2].([]byte),
Y: mapPublicKey[-3].([]byte),
}
...
{
"Alg": "アルゴリズムの種類 -7固定",
"Kty": "鍵の種類 2固定",
"X": "公開鍵x 32byteバイト列",
"Y": "公開鍵y 32byteバイト列",
}
12.3 authDataのパース結果
さて,authData
と,その中のCOSEPublicKey
のパースが終了しました.
ただ,authData
のFlags
や,Counter
はバイト列であり,本来あるべき姿ではないので,ココらへんも弄ってあげます.
さて,これらもすべて変換した結果,authDataは以下のような見た目になります.
{
"RpIDHash": "rpIDをSHA-256したバイト列",
"AAGUID": "モデル固有のIDバイト列",
"Flags": { "RpIDHash": "rpIDをSHA-256したバイト列",
"AAGUID": "モデル固有のIDバイト列",
"Flags": {
"UV": "UserVerificationされたかのbool",
"UP": "UserPresentationされたかのbool",
"AT": "AttestedDataがあるかのbool",
"ET": "ExtendedDataがあるかのbool",
}
"Counter": "何回認証器が使われたかの整数値",
"CredID": "公開鍵固有のIDバイト列",
"PublicKey": {
"Alg": "暗号化アルゴリズム",
"Kty": "鍵の種類",
"X": "公開鍵x",
"Y": "公開鍵y",
}
"UV": "UserVerificationされたかのbool",
"UP": "UserPresentationされたかのbool",
"AT": "AttestedDataがあるかのbool",
"ET": "ExtendedDataがあるかのbool",
}
"Counter": "何回認証器が使われたかの整数値",
"CredID": "公開鍵固有のIDバイト列",
"PublicKey": {
"Alg": "暗号化アルゴリズム",
"Kty": "鍵の種類",
"X": "公開鍵x",
"Y": "公開鍵y",
}
}
AttestationObjectのパース結果
怒涛のパースが漸く終わりました.このパースによって,最初の状態からどのように変わったのか再確認してみましょう.
最初の状態は以下の様なものです.
{
"id": "rawIdをbase64した文字列",
"rawId": "Idと同じ",
"type": "「public-key」で固定",
"response": {
"attestationObject": "CBORのバイト列",
"clientDataJSON": "JSON文字列を示すasciiバイト列"
}
}
最終形はこのような状態です.
{
"id": "rawIdをbase64した文字列",
"rawId": "Idと同じ",
"type": "「public-key」で固定",
"response": {
"attestationObject": {
"attStmt": "Attestationを行う時は,Attestationに必要な情報のobject,今回は空",
"fmt": "「none,packed,fido-u2f」などの文字列,今回はnone",
"authData": {
"RpIDHash": "rpIDをSHA-256したバイト列",
"AAGUID": "モデル固有のIDバイト列",
"Flags": {
"UV": "UserVerificationされたかのbool",
"UP": "UserPresentationされたかのbool",
"AT": "AttestedDataがあるかのbool",
"ET": "ExtendedDataがあるかのbool",
},
"Counter": "何回認証器が使われたかの整数値",
"CredID": "公開鍵固有のIDバイト列",
"PublicKey": {
"Alg": "暗号化アルゴリズム",
"Kty": "鍵の種類",
"X": "公開鍵x",
"Y": "公開鍵y",
},
},
},
"clientDataJSON": {
"challenge": "3.で生成したchallengeをbase64した文字列",
"origin": "navigator.credential.create()を実行したサイトドメインの文字列",
"type": "「webauthn.create」固定",
},
},
}
さて,クソデカくなりました.今回はAttestationが無いため,まだ小さいほうで,これにExtended Dataが追加されたり,AttStmt(Attestation Statement)が追加されたりするとまたまた巨大になっていきます.
今後のシーケンスはこのデータを用い,登録リクエストの検証をし,登録処理(DBに鍵とCredIDを格納する)を行います
さて,ついでにですが,CBORがどれほどデータを削減しているのかも確認したいので,
実際のデータが格納された状態も見てみましょう
{
"id": "AaeqKxjvAbHVAEe6PQV-rBcfIirMCQl_Ov-nlcVRNcp1iok-D9VTK1Qrr_1q9UZ7Nylgz3M3XZonEzfJYMdGjIncbkGcb5B
D3MzpLx8A",
"rawId": "01a7aa2b18ef01b1d50047ba3d056b05",
"type": "public-key",
"response": {
"attestationObject": {
"attStmt": {},
"fmt": "none",
"authData": {
"RpIDHash": "rpIDをSHA-256したバイト列",
"AAGUID": "モデル固有のIDバイト列",
"Flags": {
"UV": "UserVerificationされたかのbool",
"UP": "UserPresentationされたかのbool",
"AT": "AttestedDataがあるかのbool",
"ET": "ExtendedDataがあるかのbool",
},
"Counter": "何回認証器が使われたかの整数値",
"CredID": "公開鍵固有のIDバイト列",
"PublicKey": {
"Alg": "暗号化アルゴリズム",
"Kty": "鍵の種類",
"X": "公開鍵x",
"Y": "公開鍵y",
},
},
},
"clientDataJSON": {
"challenge": "3.で生成したchallengeをbase64した文字列",
"origin": "navigator.credential.create()を実行したサイトドメインの文字列",
"type": "「webauthn.create」固定",
},
},
}
13. Verify Parameters
各種パラメータが正しいかどうかを検証します.
W3Cが言っているものを参照しながら確認しましょう.
W3Cでは1.〜19.までありますが,面倒簡略化のため,いくつかのものは端折っています.
clientDataJSON
や,attestationObject
が正しくパース出来ていますので,W3Cの1. 2.はクリアしています.
もしパース中にエラーが発生したなら,サーバは400や500系統のレスポンスを返すべきでしょう.
13.1 Verify clientDataJSON.type
このリクエストが登録リクエストであると確認します.
// verify type
if reqType := authrAttResp.Response.ClientDataJSON.Type; reqType != "webauthn.create" {
log.Printf("Register Attestation: invalid type(%v)\n", reqType)
fmt.Fprintf(rw, "{\"error\": \"invalid type\", \"o\": \"%v\"}", reqType)
return
}
13.2 Verify clientDataJSON.origin
登録が行われたオリジンがRPと同一オリジンかを検証します.
// verify origin
if origin := authrAttResp.Response.ClientDataJSON.Origin; origin != "http://localhost:8080" {
log.Printf("Register Attestation: invalid origin(%v)\n", origin)
fmt.Fprintf(rw, "{\"error\": \"invalid origin\", \"o\": \"%v\"}", origin)
return
}
本来はちゃんと同一オリジンの定義に従って検証すべきです.
今回は簡略化の為,ハードコーディングしております.
同一オリジンについてはここを確認して,実装しましょう.
13.2 Verify attestationObject.flags
(Skip)
UPとUVのフラグが立っているかを確認します. が,CredentialCreationOptions
で指定していない為,(たぶん)チェックしなくても問題ありません.
今回は
CredentialCreationOptions.authenticatorSelection
を指定していません.このオプションを指定すると,登録に用いる認証器の種類を制限したり,登録時のオプションを追加で指定することが出来ます.詳しいことはココらへんを確認すると幸せになれます.
13.3 Verify attestationObject.rpIdHash
CredentialCreationOptions.rp.id
のSHA-256と一致しているか確認をします.
// verify type
if rpIDHash := authrAttResp.Response.attestationObject; rpIDHash != base64.StdEncoding.EncodeToString([]byte("Hiragi")) {
log.Printf("Register Attestation: invalid type(%v)\n", reqType)
fmt.Fprintf(rw, "{\"error\": \"invalid type\", \"o\": \"%v\"}", reqType)
return
}
13. Verify Attestation
今回は簡略化のため,Attestationは行いませんが,一応軽く説明しておきます.
attestationObject
のAttStmt
は本来Attestationを行うためにあります.
かなり上の方で一度解説しましたが,Attestationとは,「登録に用いられた認証器が信頼できるものか」を確認するための仕組みです.
具体的に言うと,WebAuthnに用いられる鍵ペアとは別のもう一つの鍵ペアを認証器は持っています.
Attestationを"none"ではなく,"direct"や"indirect"にすると,登録時に認証器はAttestationを返し(実態は鍵ペア),attestationObject
のattStmt
に格納します.
サーバ側ではこのattStmt
に格納された鍵を検証することによって,登録に用いられた認証器を信頼することが出来ます.
ココらへんにはpacked
とかfido-u2f
だとか,様々なフォーマットが存在し,検証方法もSelfAttestation
とか,AttestationCA
だとかEllipticCurveBasedDirectAnonymousAttestation
,結構ボリュームのある内容になってしまうので,今回は省略しました.
14.
<!-- 後日書きますその3 次は12/29 -->