Edited at

FIDO2によるChromeとMacBook Proのtouch idを利用したパスワードレスログインの実装


はじめに

ChromeとMacBook Proを利用したFIDO2によるパスワードレスなログインの実装について説明します。

ここではFIDO2が何かについては言及しませんのであらかじめご了承ください。

とりあえずFIDO2(Web Authentication)で遊びたい方、FIDO2について知りたい方、FIDO2のフローやパラメーターについて知りたい方、FIDO2の実装について知りたい方向けのリンクをそれぞれいくつか貼っておくのでよければご覧ください。

とりあえずFIDO2(Web Authentication)で遊びたい方

https://webauthn.me/

https://webauthn.org/

https://demo.yubico.com/webauthn-technical/registration

FIDO2(Web Authentication)が何か知りたい方

https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API

https://gihyo.jp/dev/column/newyear/2019/webauthn

https://speakerdeck.com/ynojima/webauthn-in-a-nutshell-ntt-tech-conf-number-3-ja

FIDO2(Web Authentication)の詳しいフローやパラメーターについて知りたい方

https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn/

https://medium.com/@herrjemand/introduction-to-webauthn-api-5fd1fb46c285

実装について知りたい方

https://slides.com/fidoalliance/jan-2018-fido-seminar-webauthn-tutorial#/

https://www.slideshare.net/techblogyahoo/fido-124019677


実装


検証環境

Chrome 72.0.3626.119

MacBook Pro (13-inch, 2016, Four Thunderbolt 3 Ports)


Attestationの検証

ChromeとMacBook Proのtouch idを利用したWeb Authenticationの場合、最低でも以下のどちらかのattestation formatに対応している必要があります。


  • "none" attestation

  • SELF "packed" attestation

これはChromeが返すattestation formatがSELF "packed" attestationであるためです。

"packed"attestationについては以下の記事に詳細に書かれておりますので、こちらをご覧いただくと良いでしょう。

https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn-attestation-packed/

https://medium.com/@herrjemand/verifying-fido2-packed-attestation-a067a9b2facd

一応上記のattestationについて説明をしていきます。


"none" attestation

こちらはRPがattestaion情報を受け取ることを希望しないときのフォーマットになります。

具体的にはnavigator.credentials.create()を呼び出す際、以下のようにattestationというオプションに対してnoneを指定することでattestation format"none"のレスポンスが返ってきます。


{
"status": "ok",
"errorMessage": "",
"rp": {
"name": "Example Corporation"
},
"user": {
"id": "S3932ee31vKEC0JtJMIQ",
"name": "johndoe@example.com",
"displayName": "John Doe"
},

"challenge": "uhUjPNlZfvn7onwuhNdsLPkkE5Fv-lUN",
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -7
}
],
"timeout": 10000,
"excludeCredentials": [
{
"type": "public-key",
"id": "opQf1WmYAa5aupUKJIQp"
}
],
"authenticatorSelection": {
"residentKey": false,
"authenticatorAttachment": "cross-platform",
"userVerification": "preferred"
},
"attestation": "none"
}

ちなみに、attestationに対しては、none、indirect、directを指定することができます。詳しくは以下のリンクをご覧ください。

https://w3c.github.io/webauthn/#enumdef-attestationconveyancepreference

上記のようにattestationにnoneを指定した場合、attestationObjectのfmtがnoneで返ってくるのでそれをチェックして、Attestationの検証をスキップします。


{ fmt: 'none',
attStmt: {},
authData:
<Buffer 49 96 0d e5 88 0e 8c 68 74 34 17 0f 64 76 60 5b 8f e4 ae b9 a2 86 32 c7 99 5c f3 ba 83 1d 97 63 45 5c 7d 1c de ad ce 00 02 35 bc c6 0a 64 8b 0b 25 f1 ... 150 more bytes>
}


SELF "packed" attestation

こちらは、packed attestationの中でも、attestation statementから公開鍵を取得できないようなフォーマットになります。

具体的なFULL "packed" attestation と SELF "packed" attestationのattestationObjectは以下のようになります。

FULL "packed" attestation


{ fmt: 'packed',
attStmt:
{ alg: -7,
sig:
<Buffer 30 46 02 21 00 be 80 1a 66 cb 2b 11 d3 ba c6 94 10 17 05 56 ee 72 e1 75 ff 0d ec c1 46 17 96 7d 11 10 5f 1d 5c 02 21 00 fe e4 f7 09 64 97 4d f5 bd 3d ... 22 more bytes>,
x5c:
[ <Buffer 30 82 02 be 30 82 01 a6 a0 03 02 01 02 02 04 74 86 fd c2 30 0d 06 09 2a 86 48 86 f7 0d 01 01 0b 05 00 30 2e 31 2c 30 2a 06 03 55 04 03 13 23 59 75 62 ... 656 more bytes> ] },
authData:
<Buffer 49 96 0d e5 88 0e 8c 68 74 34 17 0f 64 76 60 5b 8f e4 ae b9 a2 86 32 c7 99 5c f3 ba 83 1d 97 63 41 00 00 01 58 f8 a0 11 f3 8c 0a 4d 15 80 06 17 11 1f ... 146 more bytes>
}

SELF "packed" attestation

{ fmt: 'packed',

attStmt:
{ alg: -7,
sig:
<Buffer 30 46 02 21 00 f3 c8 07 b7 2d ce 45 7d 45 94 30 44 89 2c 85 36 c8 e1 1e 20 64 a2 6b 4d cc 74 ab 86 16 cd 53 c3 02 21 00 c1 77 e6 45 96 c9 ff 55 12 1c ... 22 more bytes> },
authData:
<Buffer 49 96 0d e5 88 0e 8c 68 74 34 17 0f 64 76 60 5b 8f e4 ae b9 a2 86 32 c7 99 5c f3 ba 83 1d 97 63 45 5c 7d 17 d1 ad ce 00 02 35 bc c6 0a 64 8b 0b 25 f1 ... 150 more bytes>
}

それぞれのattestationObjectの中身を見ると、x5cの有無がよくわかるかと思います。

x5c(X.509 certificate chain)は証明書です。つまり、FULL "packed" attestationではx5c(証明書)から公開鍵を取り出し、署名を検証することができます。

一方、SELF "packed" attestationには、x5c(証明書)がないため、別の方法で検証をする必要があります。


SELF Attestationの検証


AuthenticatorDataから公開鍵を取得

以下、AuthenticatorDataの中身になります。AuthenticatorDataのCOSEPublicKeyから公開鍵を取得します。


{
 rpIdHash:
<Buffer 49 96 0d e5 88 0e 8c 68 74 34 17 0f 64 76 60 5b 8f e4 ae b9 a2 86 32 c7 99 5c f3 ba 83 1d 97 63>,
flagsBuf: <Buffer 45>,
flags: { up: true, uv: true, at: true, ed: false, flagsInt: 69 },
counter: 1551704367,
counterBuf: <Buffer 5c 7d 21 2f>,
aaguid: <Buffer ad ce 00 02 35 bc c6 0a 64 8b 0b 25 f1 f0 55 03>,
credID:
<Buffer 00 17 89 f2 d9 ab 21 64 9a a3 1c 2a 70 7e 2e 3f 7f d8 1d 22 78 47 75 2c 57 d7 36 fe 81 40 e5 14 ee 2b 6a 6b 6c fb a5 37 c9 83 67 79 1f f0 cf fe 65 5f ... 18 more bytes>,
COSEPublicKey:
<Buffer a5 01 02 03 26 20 01 21 58 20 da 96 c4 1b 2c 77 32 f3 0c 2d 10 7a d7 d6 55 f6 e4 58 eb d4 db f4 1a 2b 36 77 4f d3 67 e7 c8 1f 22 58 20 59 ac c1 55 c0 ... 27 more bytes>
}


署名用データの作成

署名用のデータはauthDataとclientDataJSONをハッシュ値を連結したものになります。以下、作成イメージです。


Buffer.concat([attestationStruct.authData, clientDataHashBuf]);


取得した公開鍵、alg、sig、署名用データで検証

1で取得した公開鍵、attStmtのalg、sig、2で作成した署名用のデータを用いて検証します。

(以下のattStmtのalg、sigを利用します)

{ fmt: 'packed',

attStmt:
{ alg: -7,
sig:
<Buffer 30 46 02 21 00 f3 c8 07 b7 2d ce 45 7d 45 94 30 44 89 2c 85 36 c8 e1 1e 20 64 a2 6b 4d cc 74 ab 86 16 cd 53 c3 02 21 00 c1 77 e6 45 96 c9 ff 55 12 1c ... 22 more bytes> },
authData:
<Buffer 49 96 0d e5 88 0e 8c 68 74 34 17 0f 64 76 60 5b 8f e4 ae b9 a2 86 32 c7 99 5c f3 ba 83 1d 97 63 45 5c 7d 17 d1 ad ce 00 02 35 bc c6 0a 64 8b 0b 25 f1 ... 150 more bytes>
}


Assertionの検証

Assertionの検証時は他の認証器を利用する場合と特別違うことをする必要はありません。

署名の検証の処理については以下のようになります。


let verifyAuthenticatorAssertionResponse = (webAuthnResponse, authenticators, userVerification) => {
let authr = findAuthr(webAuthnResponse.id, authenticators);

...

let authenticatorData = base64url.toBuffer(webAuthnResponse.response.authenticatorData)
let response = { 'verified': false }
let authrDataStruct = parseGetAssertAuthData(authenticatorData)
let clientDataHash = hash('sha256', base64url.toBuffer(webAuthnResponse.response.clientDataJSON))
let signatureBase = Buffer.concat([authenticatorData, clientDataHash])
let publicKey = ASN1toPEM(base64url.toBuffer(authr.publicKey))
let signature = base64url.toBuffer(webAuthnResponse.response.signature)
response.verified = verifySignature(signature, signatureBase, publicKey)

...

return response

}

大まかな処理としては、以下の4つになります。


登録時に保存しておいた公開鍵を取得

navigator.credentials.get()のレスポンスとして以下のような値が送られてきます。

以下のオブジェクト内のidに紐づく公開鍵を取り出します。


{
"id":"LFdoCFJTyB82ZzSJUHc-c72yraRc_1mPvGX8ToE8su39xX26Jcqd31LUkKOS36FIAWgWl6itMKqmDvruha6ywA",
"rawId":"LFdoCFJTyB82ZzSJUHc-c72yraRc_1mPvGX8ToE8su39xX26Jcqd31LUkKOS36FIAWgWl6itMKqmDvruha6ywA",
"response":{
"authenticatorData":"SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAAAA",
"signature":"MEYCIQCv7EqsBRtf2E4o_BjzZfBwNpP8fLjd5y6TUOLWt5l9DQIhANiYig9newAJZYTzG1i5lwP-YQk9uXFnnDaHnr2yCKXL",
"userHandle":"",
"clientDataJSON":"eyJjaGFsbGVuZ2UiOiJ4ZGowQ0JmWDY5MnFzQVRweTBrTmM4NTMzSmR2ZExVcHFZUDh3RFRYX1pFIiwiY2xpZW50RXh0ZW5zaW9ucyI6e30sImhhc2hBbGdvcml0aG0iOiJTSEEtMjU2Iiwib3JpZ2luIjoiaHR0cDovL2xvY2FsaG9zdDozMDAwIiwidHlwZSI6IndlYmF1dGhuLmdldCJ9"
},
"type":"public-key"
}


signatureの取得

navigator.credentials.get()のレスポンスに含まれるresponseというオブジェクトの中にsignatureが入っているので取り出します。


{
authenticatorData:
'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMd ...',
signature:
'MEYCIQDHQ8Fk3yFRuNQmMYRNArThKIXG-To514Of ...',
userHandle: 'dsMGBXJO8wtWGMPf0t0XIa38NJWkT ...',
clientDataJSON:
'eyJjaGFsbGVuZ2UiOiJLeUVWa3N1Rm1IUUlnUEpx ... '
}


authenticatorDataとclientDataJSONのハッシュ値を結合して署名用データの作成

以下、データ作成例です。


Buffer.concat([authenticatorData, clientDataHash])


署名の検証

上記で用意したデータを用いて署名の検証を行います。


おわりに

うまく実装できると以下のように動作します。

ezgif-2-15c7cf25b423.gif

自分は長い間Assertionのシグネチャの検証が失敗しており、またその理由がわからずに悩んでいました。

しかし先日ついに、署名用のデータの生成方法を変えることでうまくいくようになりました。

具体的には、


Buffer.concat([authrDataStruct.rpIdHash, authrDataStruct.flagsBuf, authrDataStruct.counterBuf, clientDataHash]);

から以下のように変えることでうまくいきました。


Buffer.concat([attestationStruct.authData, clientDataHashBuf]);

どうやらデータが不足していたようです。


参考

https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API

https://gihyo.jp/dev/column/newyear/2019/webauthn

https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn/

https://medium.com/@herrjemand/introduction-to-webauthn-api-5fd1fb46c285

https://w3c.github.io/webauthn/#enumdef-attestationconveyancepreference

https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn-attestation-packed/

https://medium.com/@herrjemand/verifying-fido2-packed-attestation-a067a9b2facd

https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-server-v2.0-rd-20180702.html