WebAuthnを使ったFIDOサーバを立ててみた

Googleから最近になって、FIDO認証デバイスの「Titanセキュリティーキー」が発売されたので、購入しました。

[Google Titanセキュリティーキー］
　https://store.google.com/jp/product/titan_security_key_kit

今のところ、はやっていそうですし、せっかくなのでこれを使って遊んでいきたいと思います。

Titanセキュリティーキーがあれば、Webサーバから見てユーザID/パスワードで認証するのではなく、Titanセキュリティーキーのデバイスを認証してくれます。公開鍵をベースとしているそうです。
また、Titanセキュリティーキーには、USBに加えて、NFCとBLEの接続方法があるので、直接PCに挿さなくとも、デバイス認証してくれます。

ということで、今回は、王道のFIDOサーバを立ち上げます。

(参考) こちらもどうぞ。
　[FIDOデバイスエミュレータようやく完成](https://qiita.com/poruruba/items/5b859e635ffc3d8e0242)

# FIDOの全体像を知る

以下のサイトの記事が非常に参考になりました。
　https://techblog.yahoo.co.jp/advent-calendar-2018/webauthn/

それから、以下のチュートリアルも非常に参考になりました。
　https://github.com/fido-alliance/webauthn-demo

これがWebAuthn
　https://w3c.github.io/webauthn/

頭が混乱してきたら、以下のページで再整理しましょう。
　[Web Authentication API の裏側と、なぜそうなっているのかを図解した](https://qiita.com/kyrieleison/items/3dbb8ece94572dc3e962)

# サーバ側に必要なエンドポイントを知る

サーバ側に必要なエンドポイントは以下の4つです。

* POST /attestation/options
* POST /attestation/result
* POST /assertion/options
* POST /assertion/result

(参考) ただし、一部未対応。
　https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-server-v2.0-rd-20180702.html

詳細はよくわからないのですが、以下のnpmモジュールを使わせてもらうことで、実装量をかなり省けました。(ありがとうございます！)

fido2-lib
　https://github.com/apowers313/fido2-lib

エンドポイントに対応する関数が定義されていますので、それを呼び出せるように、エンドポイントへの入力パラメータを整理して呼び出します。
対応関係は以下の通りです。

* /attestation/options → f2l.attestationOptions()
* /attestation/result → f2l.attestationResult()
* /assertion/options → f2l.assertionOptions()
* /assertion/result → f2l.assertionResult()

ここで一つややこしいことがありまして、クライアントサーバ間のバイナリデータの受け渡しは、BASE64URLでエンコードするのが通例のようでして、エンドポイントからの入力と関数呼び出しの間、関数戻りからエンドポイントからの出力の間のそれぞれで、エンコード/デコードする必要があります。

さらにややこしいのが、一部fido2-libがエンコード/デコードを処理してなさそうに見えて、一部処理していたりで、ちょっと混乱しました。
後で示しますが、(頑張って理解しようとしたのですが)不具合と思われる個所を修正して、やっと動かすことができました。

BASE64URLとは、Base64エンコードに対して、Webで扱いやすいように、「+」を「-」に、「/」を「_」に置き換えたもののようです。
以下のnpmモジュールを使わせていただきました。

base64url
　https://github.com/brianloveswords/base64url

# ユーザIDとデバイスの紐づけ

サーバ側から見てデバイスを認証できますが、そのデバイスの持ち主がどのユーザIDの人なのかはわからないので、紐づけが必要となります。
ですので、通常は、ユーザID/パスワードで認証したのち、デバイスで生成した公開鍵をユーザIDに紐づけます。こうすることで、デバイスを認証したこと＝ユーザIDのユーザがログインした、ということになります。
一般に、/attestation/options のところで本人認証をしてから、デバイスの登録処理を進めるのですが、今回のデモサーバでは、ユーザID/パスワードの認証は省略しています。

# クライアント側の呼び出し関数

Client側は、ブラウザを使いました。
かなり実装が進んでいるようで、やはりChromeは対応していました。

以下の2つの関数を呼び出します。
・navigator.credentials.create()
・navigator.credentials.get()

まとめると、以下の順番になります。

[デバイスの登録]
・POST /attestation/options
・navigator.credentials.create()
・POST /attestation/result

[デバイスでログイン]
・POST /assertion/options
・navigator.credentials.get()
・POST /assertion/result

ちょっと補足します。

・POST /attestation/options
デバイスに紐づけたいユーザ名を知らせます。本来であれば、パスワードや認証後のセッションを渡して、ユーザ本人であることを示すのが正しいですが、省略しています。
レスポンスとして、チャレンジが返ってきます。

・navigator.credentials.create()
デバイス内部で、公開鍵ペアを生成し、チャレンジに対して署名を付与します。
レスポンスとして、公開鍵値、署名値、公開鍵を識別するIDが返ってきます。

・POST /attestation/result
デバイスから取得したレスポンスをサーバに登録します。
サーバでは、署名検証し、公開鍵・公開鍵を識別するID(credId)などを、ユーザ名に紐づけて保持することで登録完了です。
レスポンスに含まれるX.509の検証は手抜きでしていませんっ！

・POST /assertion/options
ユーザ名を入力とし、サーバに保持しているユーザ名を検索します。
そして、ユーザ名に紐づけたcredIdを取り出し、またチャレンジを生成して、クライアントに返します。複数のデバイスが紐づけられている可能性もあるので、credIdは複数の場合があります。

・navigator.credentials.get()
デバイスに署名生成を要求します。生成に使う公開鍵は、サーバから取得したcredIdで指定します。
レスポンスとして署名に使った公開鍵のIDと署名値が返ってきます。

・POST /assertion/result
署名に使った公開鍵のIDでcredIdを特定し、署名を検証します。

上記以外にも、細かいチェック処理が入っています。

# ソースコード

ソースコード量が多いので、GitHubに上げておきました。
　https://github.com/poruruba/fido2_server

まずはサーバ部分です。

```js:index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const Response = require(HELPER_BASE + 'response');
const Redirect = require(HELPER_BASE + 'redirect');

const base64url = require('base64url');
const crypto    = require('crypto');
const { Fido2Lib } = require("fido2-lib");

const FIDO_RP_NAME = process.env.FIDO_RP_NAME || "Sample FIDO Host";
const FIDO_ORIGIN = process.env.FIDO_ORIGIN || "https://localhost";

var f2l = new Fido2Lib({
  rpName: FIDO_RP_NAME
});

let database = {};

exports.handler = async (event, context, callback) => {
  if( event.path == '/assertion/options'){
    var body = JSON.parse(event.body);
    console.log(body);

    let username = body.username;

    if(database[username] && !database[username].registered) {
      return new Response({
        'status': 'failed',
        'message': `Username ${username} does not exist`
      });
    }

    var authnOptions = await f2l.assertionOptions();
    authnOptions.challenge = base64url.encode(authnOptions.challenge);

    let allowCredentials = [];
    for(let authr of database[username].attestation) {
        allowCredentials.push({
          type: 'public-key',
          id: authr.credId,
          transports: ['usb', 'nfc', 'ble']
        })
    }
    authnOptions.allowCredentials = allowCredentials;
    console.log(authnOptions);

    context.req.session.challenge = authnOptions.challenge;
    context.req.session.username  = username;

    authnOptions.status = 'ok';

    return new Response(authnOptions);
  }else
  if( event.path == '/assertion/result'){
    var body = JSON.parse(event.body);
    console.log(body);

    var attestation = null;
    for( var i = 0 ; i < database[context.req.session.username].attestation.length ; i++ ){
      if( database[context.req.session.username].attestation[i].credId == body.id ){
        attestation = database[context.req.session.username].attestation[i];
        break;
      }
    }
    if( !attestation ){
      return new Response({
        'status': 'failed',
        'message': 'key is not found.'
      });
    }

    var assertionExpectations = {
      challenge: context.req.session.challenge,
      origin: FIDO_ORIGIN,
      factor: "either",
      publicKey: attestation.publickey,
      prevCounter: attestation.counter,
      userHandle: null
    };

    body.rawId = new Uint8Array(base64url.toBuffer(body.id)).buffer;
    if( !body.response.userHandle )
      body.response.userHandle = undefined;
    var authnResult = await f2l.assertionResult(body, assertionExpectations);
    console.log(authnResult);

    if(authnResult.audit.complete) {
      attestation.counter = authnResult.authnrData.get('counter');

      return new Response({
        'status': 'ok',
        credId: body.id,
        counter: attestation.counter
      });
    } else {
      return new Response({
        'status': 'failed',
        'message': 'Can not authenticate signature!'
      });
    }
  }else
  if( event.path == '/attestation/options'){
    var body = JSON.parse(event.body);
    console.log(body);

    let username = body.username;

    if(database[username] && database[username].registered) {
      return new Response({
          'status': 'failed',
          'message': `Username ${username} already exists`
      });
    }

    var id = randomBase64URLBuffer();

    var registrationOptions = await f2l.attestationOptions();
    registrationOptions.challenge = base64url.encode(registrationOptions.challenge);
    registrationOptions.user.id = id;
    registrationOptions.user.name = username;
    registrationOptions.user.displayName = username;
    console.log(registrationOptions);

    database[username] = {
      'name': username,
      'registered': false,
      'id': id,
      'attestation': []
    };

    context.req.session.challenge = registrationOptions.challenge;
    context.req.session.username = username;

    registrationOptions.status = 'ok';

    return new Response(registrationOptions);
  }else
  if( event.path == '/attestation/result'){
    var body = JSON.parse(event.body);
    console.log(body);

    var attestationExpectations = {
        challenge: context.req.session.challenge,
        origin: FIDO_ORIGIN,
        factor: "either"
    };
    body.rawId = new Uint8Array(base64url.toBuffer(body.id)).buffer;
    var regResult = await f2l.attestationResult(body, attestationExpectations);
    console.log(regResult);

    var credId = base64url.encode(regResult.authnrData.get('credId'));
    var counter = regResult.authnrData.get('counter');
    database[context.req.session.username].attestation.push({ 
        publickey : regResult.authnrData.get('credentialPublicKeyPem'),
        counter : counter,
        fmt: regResult.authnrData.get('fmt'),
        credId : credId
    });

    if(regResult.audit.complete) {
      database[context.req.session.username].registered = true

      return new Response({
        'status': 'ok',
        credId: credId,
        counter: counter
      });
    } else {
      return new Response({
        'status': 'failed',
        'message': 'Can not authenticate signature!'
      });
    }
  }
};

function randomBase64URLBuffer(len){
  len = len || 32;

  let buff = crypto.randomBytes(len);

  return base64url(buff);
}
```

以下の部分は、環境に合わせて、変更してください。
・process.env.FIDO_RP_NAME
・process.env.FIDO_ORIGIN 

次は、クライアント側ソースです。
Base64URLエンコードには以下のモジュールを使わせていただきました。

Base64URL-ArrayBuffer
　https://github.com/herrjemand/Base64URL-ArrayBuffer

HTMLは以下の通りです。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

  <title>FIDO Demo Server</title>

  <script src="js/methods_utils.js"></script>
  <script src="js/vue_utils.js"></script>

  <script src="dist/js/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <script src="https://unpkg.com/vue"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>FIDO Demo Server</h1>

        <div class="alert alert-info" role="alert">{{message}}</div>

        <div class="form-inline">
            <label>username</label> <input type="text" class="form-control" v-model="username">
        </div>
        <button class="btn btn-default" v-on:click="start_register()">登録開始</button>

        <div v-if="attestation != null">
            <label>rp.name</label> {{attestation.rp.name}}<br>
            <label>user.displayName</label> {{attestation.user.displayName}}<br>
            <label>user.id</label> {{attestation_encode.user.id}}<br>
            <label>challenge</label> {{attestation_encode.challenge}}<br>
            <label>attestation</label> {{attestation.attestation}}<br>

            <button class="btn btn-default" v-on:click="do_register()">登録実行</button>
        </div>
        <div>
            <div v-if="registered">
                <label>credId</label> {{register_credId}}<br>
                <label>counter</label> {{register_counter}}<br>
            </div>

            <button class="btn btn-default" v-on:click="start_login()">ログイン開始</button>
        </div>
        <div v-if="assertion != null">
            <div v-for="(cred, index) of assertion_encode.allowCredentials">
                <label>cred.id[{{index}}]</label> {{cred.id}}<br>
            </div>
            <label>challenge</label> {{assertion_encode.challenge}}<br>

            <button class="btn btn-default" v-on:click="do_login()">ログイン実行</button>
        </div>
        <div v-if="logined">
            <label>credId</label> {{login_credId}}<br>
            <label>counter</label> {{login_counter}}<br>
        </div>

        <div class="modal fade" id="progress">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">{{progress_title}}</h4>
                    </div>
                    <div class="modal-body">
                        <center><progress max="100" /></center>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="dist/js/base64url-arraybuffer.js"></script>
    <script src="js/start.js"></script>
</body>
```

Javascript部分です。

```js:start.js
'use strict';

//var vConsole = new VConsole();

const base_url = "【サーバのURL】";

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        message: "",
        username: 'test',
        attestation : null,
        attestation_encode: {
            user: {}
        },
        register_credId: null,
        register_counter: -1,
        registered : false,
        assertion : null,
        assertion_encode: {
            allowCredentials: []
        },
        login_credId: null,
        login_counter: -1,
        logined : false
    },
    computed: {
    },
    methods: {
        start_register: function(){
            this.registered = false;
            var param = {
                username: this.username,
                userVerification: true
            };
            this.progress_open();
            do_post( base_url + '/attestation/options', param )
            .then(json =>{
                this.progress_close();
                console.log(json);
                if( json.status != 'ok'){
                    alert(json.message);
                    return;
                }

                this.attestation_encode.challenge = json.challenge;
                this.attestation_encode.user.id = json.user.id;

                json.challenge = base64url.decode(json.challenge);
                json.user.id = base64url.decode(json.user.id);

                this.attestation = json;
                this.message = '登録の準備ができました。';
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        },
        do_register: function(){
            return navigator.credentials.create({ publicKey: this.attestation })
            .then(response =>{
                var result = publicKeyCredentialToJSON(response);
                
                this.progress_open();
                return do_post(base_url + '/attestation/result', result )
            })
            .then((response) => {
                this.progress_close();
                if(response.status !== 'ok'){
                    alert(json.message);
                    return;
                }

                if( response.status == 'ok'){
                    this.register_credId = response.credId;
                    this.register_counter = response.counter;
                    this.registered = true;
                    this.message = '登録が完了しました。';
                }else{
                    throw 'registration error';
                }
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        },
        start_login: function(){
            this.logined = false;
            var param = {
                username: this.username,
            };

            this.progress_open();
            do_post( base_url + '/assertion/options', param )
            .then(json =>{
                this.progress_close();
                console.log(json);
                if( json.status != 'ok'){
                    alert(json.message);
                    return;
                }

                this.assertion_encode.challenge = json.challenge;
                json.challenge = base64url.decode(json.challenge);

                for(var i = 0 ; i < json.allowCredentials.length ; i++ ) {
                    this.assertion_encode.allowCredentials[i] = { id: json.allowCredentials[i].id };
                    json.allowCredentials[i].id = base64url.decode(json.allowCredentials[i].id);
                }

                this.assertion = json;
                this.message = 'ログインの準備ができました。';
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        },
        do_login: function(){
            return navigator.credentials.get({ publicKey: this.assertion })
            .then(response =>{
                var result = publicKeyCredentialToJSON(response);
                
                this.progress_open();
                return do_post(base_url + '/assertion/result', result )
            })
            .then((response) => {
                this.progress_close();
                if(response.status !== 'ok')
                    throw new Error(`Server responed with error. The message is: ${response.message}`);

                console.log('sendWebAuthnResponse received: ', response);

                if( response.status == 'ok'){
                    this.login_credId = response.credId;
                    this.login_counter = response.counter;
                    this.logined = true;
                    this.message = 'ログインが成功しました。';
                }else{
                    throw 'login error';
                }
            })
            .catch(error =>{
                this.progress_close();
                alert(error);
            });
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );

function do_post(url, body){
//    const headers = new Headers( { "Content-Type" : "application/json; charset=utf-8" } );
    const headers = new Headers( { "Content-Type" : "application/json" } );
    
    return fetch(url, {
        method : 'POST',
        credentials: 'include',
        body : JSON.stringify(body),
        headers: headers
    })
    .then((response) => {
        if( !response.ok )
            throw 'status is not 200.';
        return response.json();
    });
}

function publicKeyCredentialToJSON(pubKeyCred){
    if(pubKeyCred instanceof Array) {
        let arr = [];
        for(let i of pubKeyCred)
            arr.push(publicKeyCredentialToJSON(i));

        return arr
    }

    if(pubKeyCred instanceof ArrayBuffer) {
        return base64url.encode(pubKeyCred)
    }

    if(pubKeyCred instanceof Object) {
        let obj = {};

        for (let key in pubKeyCred) {
            obj[key] = publicKeyCredentialToJSON(pubKeyCred[key]);
        }

        return obj
    }

    return pubKeyCred
}
```

以下の部分は、環境に合わせて書き換えてください。
・【サーバのURL】

# 動作確認

Windows10のChromeブラウザで実施してみました。
以下は、TitanセキュリティキーをUSB端子に挿した場合です。

index.htmlを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/be2a2ba8-df44-fe0f-797a-72bcdba75bc7.png)
 
usernameに適当なユーザ名を入力して、「登録開始」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9f99e341-08c3-0538-a4ef-e6558ef61910.png)

「登録実行」ボタンを押下すると、以下が表示されます。
Titanセキュリティキーの表面の金属のところを指で触ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b8902626-456c-7e7e-be42-132d911be26b.png)

以下が表示されますので、「許可」ボタンを押下して進めます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/fb74e8a8-5531-ff75-bfbb-5cc1de8df54f.png)

これで登録完了です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/806d6048-9446-06d6-066c-496c31e9aea9.png)


さあログインしましょう。
「ログイン開始」ボタンを押下すると、以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3abf726e-bdf2-af2e-14e1-08abaf93cc61.png)

そして、「ログイン実行」ボタンを押下します。
登録時と同様に、Titanセキュリティキーの表面の金属のところを指で触ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1da45375-fb26-b437-a587-d7f391da664f.png)

以下のように表示されて、ログイン完了です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7921296a-0242-67a0-9e7a-228540dc8aba.png)

Androidでもやってみます。
こちらはBLEでやってみました。NFCでも似たような感じでした。

事前に、BLEデバイスとしてペアリングしておく必要があるようです。
ペアリングは、デバイスのボタンを5秒間長押しするとペアリングが開始されます。

それでは、始めましょう。AndroidのChromeからindnex.htmlを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/a4cb2d5d-6aa8-0c94-1c22-60338eacf117.png)

「登録開始」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/37df3d3f-645f-54a9-acae-d0b17d4d037d.png)

「登録実行」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b67451eb-84ef-94d4-dbdc-ce8023032bc9.png)


デバイスにあるボタンを押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d3287626-9c2f-774f-ad09-e608a719059c.png)

登録完了です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/5635cfcb-2a2a-a008-c826-c1bdd429656f.png)

次はログインです。
「ログイン開始」ボタンを押下すると以下の画面になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e88383a8-ae0e-7ecc-dc99-d7f2d9eb59e3.png)

「ログイン実行」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/793a5d61-d7c3-30e0-f38e-dfdf5f84e602.png)

デバイスにあるボタンを押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2591191c-1361-7cdf-7b48-cc84381b0bf2.png)

ログインできました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b52a739e-9a5a-1fe5-f632-af60f34a8b62.png)

# 制限事項

・~~WindowsでBLEを使って認証しようとしましたがうまくいきませんでした。原因不明です。~~
・https://github.com/apowers313/fido2-lib をみたら、Androidの指紋認証Attestationが実装されているではないかっ！
・こちらの記事は逆にFIDOデバイスをエミュレーションした記事です⇒「 [FIDOデバイスエミュレータようやく完成](https://qiita.com/poruruba/items/5b859e635ffc3d8e0242)」

以上