update 2018-06-25 userHandle に関して追記
2018-10-20 この記事より詳細な記事書きました webauthn における ResidentKey について

とりあえず動画をみれば大体わかる

どうも、先日DMM英会話の退会を忘れていて、4万円ほど失った @watahani です。かなしい。

さて、先日 Insider Preview Build 17682 で WebAuthN のサポートがされたと聞いて、とりあえずは webauthn.org で使えることまでは試しました。

WebAuthN自体動くことは分かったけど、

Resident Key のサポートとかいろいろ気になるよね…?

ということで調べてみました。

Resident Key とは?

FIDO2.0 から新しく追加された機能で、要はキーの中にユーザ情報を保存できる。

細かく言うと、 PublicKeyCredentialUserEntity Credential ID を保存しておいて、次回以降のログインに利用することができる。

こんな感じ。

キーの中に Challenge へのサインに必要な情報をキャッシュしておくことで、次回以降のログインに User 情報を入力する必要なくログインができる。

今回は、 Security Key by Yubico を利用しました。

U2F と FIDO2 の違い

ゼロからコードを書くスキルは無いので、https://github.com/fido-alliance/webauthn-demo をベースに改造を加えてみる。

認証部分

2018-06-19 Attestation を認証部分と書いてしまっていたので修正

認証部分に関して、Yubico の Security Key は fido-u2f ではなく packed で動くので認証部分を追加する。

UV も検証したほうがいいかもだけど、ほかのブラウザがどういう方針で行くのか不明なのでとりあえずそのまま

    }else if(authr.fmt === 'packed') { //別にこれ fido-u2f と分ける必要ない、というか attestation ない場合もあるので...
        let authrDataStruct  = parseGetAssertAuthData(authenticatorData);

        if(!(authrDataStruct.flags & U2F_USER_PRESENTED)) //FIDO2 の場合は UP に加え、UV をチェックすべき(0x05)
            throw new Error('User was NOT presented durring authentication!');

        let clientDataHash   = hash(base64url.toBuffer(webAuthnResponse.response.clientDataJSON))
        let signatureBase    = Buffer.concat(
            [
                authrDataStruct.rpIdHash, 
                authrDataStruct.flagsBuf, 
                authrDataStruct.counterBuf,
                clientDataHash
            ]
        );

        let publicKey = ASN1toPEM(base64url.toBuffer(authr.publicKey));
        let signature = base64url.toBuffer(webAuthnResponse.response.signature);

        response.verified = verifySignature(signature, signatureBase, publicKey)

        if(response.verified) {
            if(response.counter <= authr.counter)
                throw new Error('Authr counter did not increase!');

            authr.counter = authrDataStruct.counter
        &#125;
    &#125;

Attestation Data の検証に関しては、Extension がない場合、

  • RP ID Hash
  • Flags
  • Counter
  • aaguid,
  • Credential ID Length
  • Credential ID
  • Public Key(CBOR)
  • Client Data Hash

に対して行う。あとは U2F と一緒っぽい。

+ (ctapMakeCredResp.fmt === 'packed')&#123;
+        let authrDataStruct = parseMakeCredAuthData(ctapMakeCredResp.authData);
+
+        if(!(authrDataStruct.flags & U2F_USER_PRESENTED))
+            throw new Error('User was NOT presented durring authentication!');
+
+        let clientDataHash  = hash(base64url.toBuffer(webAuthnResponse.response.clientDataJSON))
+        let reservedByte    = Buffer.from([0x00]);
+        let publicKey       = COSEECDHAtoPKCS(authrDataStruct.COSEPublicKey)
+        let signatureBase   = Buffer.concat(
+            [
+                authrDataStruct.rpIdHash, 
+                authrDataStruct.flagsBuf, 
+                authrDataStruct.counterBuf, 
+                authrDataStruct.aaguid,
+                authrDataStruct.credIDLenBuf, 
+                authrDataStruct.credID, 
+                authrDataStruct.COSEPublicKey,
+                clientDataHash
+            ]);
+
+        let PEMCertificate = ASN1toPEM(ctapMakeCredResp.attStmt.x5c[0]);
+        let signature      = ctapMakeCredResp.attStmt.sig;
+
+        response.verified = verifySignature(signature, signatureBase, PEMCertificate)
+
+        if(response.verified) &#123;
+            response.authrInfo = &#123;
+                fmt: 'packed',
+                publicKey: base64url.encode(publicKey),
+                counter: authrDataStruct.counter,
+                credID: base64url.encode(authrDataStruct.credID)
+            &#125;
+        &#125;
     &#125;

WebAuthN 部分

WebAuthN 部分については、まず登録時に Resident Key が登録できるように authenticatorSelectionrequiredResidentKey true をセットするだけ。まあそんな難しくない。

    getMakeCredentialsChallenge(&#123;username, name&#125;)
        .then((response) => &#123;
            let publicKey = &#123;
                'challenge' : challenge,
                'user': &#123;
                    'id': 'xxxxxxxxxxxxxxxxxx',
                    'displayName': 'Hoge Fuga',
                    'user': 'hoge'
                &#125;
            &#125;;
            if(residentKey)&#123;
                publicKey.authenticatorSelection = &#123;
                    'requireResidentKey': true
                &#125; 
            &#125;
            return navigator.credentials.create(&#123; publicKey &#125;)
        &#125;)
    ...

ただ、このままだと無限にキーを登録し続けられてしまう。実際には aaguid などから、キーがすでに登録済みか調べて除外するなどする必要があると思う。

と思ったけど、 aaguid を excludeCredentials には追加できない ので、ユーザが重複登録してしまわないようにするにはサーバーに送ってから登録情報を見て登録済みならはじくとかかなあ…。
それだとキー自体に登録してしまってから送っちゃうしだめか…

次に認証時だけど、これも簡単で credentials.get オプションに渡す allowCredentials を空にすればいい。

let publicKey = &#123;
    'challenge': challenge
&#125;
return navigator.credentials.get(&#123;publicKey&#125;);

当然 CredentialID 等はいらないのでこれでOK。

U2F では Challenge と ユーザを紐づけて管理していたが、FIDO2 の One Factor Authentication の場合、セッションなどと紐づけて保存しておかなくてはならなくなった。

また、サーバー側には CredentialID のみが送られるため、どのユーザがログインしようとしているかは CredentialID から逆引きする機構が必要になる。

2018-06-25
Edgeの実装を確認していると getAssertion のレスポンス内に含まれる user handle 内に、 user id が含まれていた。
スペックでは nullable なので、使えたら使う?ってスタンスでよいのかなあ…。要調査。

+let getUsernameFromCredentialID = function(credentialId)&#123;
+    let matchedUsername;
+    Object.keys(database).forEach((username) => &#123;
+        var authenticators = database[username].authenticators
+        authenticators.forEach((authenticator) => &#123;
+            if(authenticator.credID === credentialId)&#123;
+                matchedUsername = username;
+            &#125;
+        &#125;)
+    &#125;)
+    return matchedUsername;
+&#125;

追記し忘れていたけれども CTAP2 のスペック
FIDO devices - device resident credentials: For device resident keys on FIDO devices, at least user "id" is mandatory.
とあるので、Resident Key においては必ず UserHandle が帰ってくると考えてよいと思う
ということで上記のコードは不要なハズ

実際の動作

実際に Edge で動作確認をしてみる。

Resister

まずは、登録作業。

どうも Edge では User Verify (PINの入力) は必須らしく、PIN 設定していないキーでも関らず PIN の設定をしろと言われてしまう。

追記: User Verification はデフォルトで “preffered” です。
UV が不要であれば “discourage” オプションの指定が必要です。
詳しくは は webauthn における ResidentKey について

One Factor Authentication するには PIN なり、指紋なりで保護しないとだめなのはわかる。けど、認証レベルによってタップでログインして、重要な情報を変更するときのみ PIN の入力する…みたいな使い方は出来なさそう。

User Verification の後は User Presence のためにタッチする。

多分、生体認証などのキーでは User Verification は User Presence な動作を必要とするまず。つまりもう一度タッチしなくていいのかと思うけど、実機が無いのでわからない。誰か試してみてほしい。

ともあれ、タッチまでして登録完了。

Assertion

次にログインの場合。先ほども言った通り、Credential ID 等はキーに保存できるため allowCredentials を空にして、getAssertion を呼べばよい。

PIN を入力すると、キーが与えられた challenge に対し、保存している Credentials すべてに対応する attestationResponse を Edge に返す。

Edge は Credential が一つしかない場合、自動でログインするらしい。
正直どのユーザでログインしようとしているかは表示してほしい。

Credential が複数ある場合はユーザリストが表示される。

ちなみに 25 ユーザほど登録した時点で、キーの登録容量が足りなくなった。

雑感

ソースはここに置いておいた。

https://github.com/HWataru/webauthn-demo/tree/fido2-support

キーの登録自体は動画の通りうまく動いたけども、実際の運用には重複登録への対策や、他のブラウザごとの実装などがどうなるかなど、検討しないといけないことは色々あると感じた。

正直キーだけで Web認証のすべてを行うのは難しいと思う。実際には PC や スマホの platform Authenticator を利用したり、何かしらの IdP にのみキーを登録して、フェデレーションなどを利用していく必要があるだろう。

何より DMM 英会話で失った 4万円が重くボディーブローのように響いてきてつらたん。

記事がためになったらだれかご飯おごってください。