FFRIエンジニアブログ

株式会社FFRIセキュリティのエンジニアが執筆する技術者向けブログです

FFRI AMC へのパスキー実装

はじめに

こんにちは、製品開発部の柳です。

私は、当社の主要セキュリティプロダクトである FFRI yarai を集中管理する FFRI AMC(以下、AMC)という Web アプリケーションの開発・保守業務を行っています。

現在 AMC には、ワンタイムパスワードを用いた多要素認証機能が搭載されています。

ワンタイムパスワードを用いた多要素認証は、従来のパスワード認証の弱点であったリスト型攻撃や中間者攻撃などへの耐性がある一方、方式によってはフィッシング攻撃などへの耐性が弱いという問題が指摘されています。

そのような中、昨今よりセキュリティ強度の高い「パスキー」による認証が注目されています。 パスキーは、FIDO アライアンスW3C が共同で策定した公開鍵暗号をベースとした認証手段です。 現在では、さまざまな OS や Web ブラウザーがサポートしており、主要なサービスでは利用が推奨されています。 AMC に対しても近々搭載要望が来ると想定されるため、それに備えて実際に AMC へ簡易的に実装してみましたので、その内容をご紹介します。

なお、本記事ではパスキー認証の仕組みについては解説しません。 以下のページなど、分かりやすく解説されている記事がインターネット上にありますので、それらをご確認ください。

Yahoo! JAPAN Tech Blog FIDO認証&パスキー総復習(認証の仕組みやパスキー登場までの経緯)

AMC への実装

留意事項

  • サーバー側のソースコードはセキュリティ上の都合により公開していません。リクエストパラメーターとレスポンスのみ記載しています。
  • 画面はあくまで今回の簡易実装のために作成したもので、今後製品に実装する画面がこの通りになるとは限りません。

実装の大筋

まず、実装全体の大筋について説明しておきます。 パスキーの登録および認証処理は、Web Authentication API を中心に行われます。 リンク先のページに、Web Authentication API の説明と登録、認証処理の流れが示されていますので、まずはそちらの内容をご確認ください。

上記のページに記載されていますが、Web アプリケーション側で実装が必要なのは、登録と認証処理それぞれの①と⑤に関する部分です。 具体的には次の内容を実装していきます。

登録パラメーター取得 API の作成

この API は navigator.credentials.create() の呼び出しパラメーターになる情報を取得するためにクライアントから呼び出されます。 レスポンスとして次のような JSON を返します。

{
    "rp": {
        "name": "passkey.example.com",
        "id": "passkey.example.com"
    },
    "user": {
        "name": "admin",
        "id": "NDg1ZGVmMzQtYjZkMC00ZDQ3LWJlNzQtOGY1NTQ1ZjI5YmQ2",
        "displayName": "admin"
    },
    "challenge": "zQdt2R39RXI4W0AkBdlSxA",
    "pubKeyCredParams": [
        {
            "type": "public-key",
            "alg": -7
        },
        {
            "type": "public-key",
            "alg": -257
        }
    ],
    "authenticatorSelection": {
        "requireResidentKey": true,
        "residentKey": "required"
    },
    "attestation": "none"
}
キー名 設定値
rp サーバーの情報
user パスキーを登録するユーザーの情報
challenge サーバーが生成したチャレンジ
pubKeyCredParams サーバーが対応可能な署名アルゴリズムを優先度順に設定
alg には IANAのCOSE Algorithms レジストリ に登録されている値を指定
例えば -7 は  ES256(ECDSA w/ SHA-256) を表す
residentKey 認証器に秘密鍵を含む認証情報を保存するかどうかの設定
attestation 認証器の真正性確認(認証器が正当なものであるか確認すること)についての設定
none を指定するとサーバーは真正性を確認せず、認証器もそれに関する情報を送信しない

表中の challenge(チャレンジ) は、サーバーが発行するランダムな文字列ですが、どのような用途で使用されるか簡単に説明しておきます。

  • 同一性の検証

    • この API で生成された値が、登録処理 API で送信される値と同一かを検証し、異なっていれば不正なリクエストとして処理を中止します。
  • 認証器の真正性確認を目的に生成される署名の一部

    • 真正性確認のために、認証器がチャレンジやその他情報を結合したものに対して署名を生成する際に使用されます(今回は実装していないため詳細は割愛します)。

登録処理 API の作成

この API は Web ブラウザーから送信された情報を検証し、問題なければ公開鍵をデータベースに登録します。 リクエストパラメーターとして、次のような JSON を送信します。

{
    "id": "JkxADqBLdF1M5Rt3y0EGfMXGXx0",
    "type": "public-key",
    "rawId": "JkxADqBLdF1M5Rt3y0EGfMXGXx0",
    "response": {
        "clientDataJSON": "d2Vi44OW44Op44Km4 ..省略.. 4K244O855Sf5oiQ44",
        "attestationObject": "KOODieODoeOCpO ..省略.. ODs+OBlOOBqOOBrin"
    }
}
キー名 設定値
id この認証情報の識別子
type public-key 固定
rawId idと同じ
clientDataJSON web ブラウザーが生成する情報で、この中にサーバーが生成したチャレンジやオリジン(ドメイン)が含まれる
attestationObject 認証器が生成した公開鍵、公開鍵やチャレンジに対する署名、アテステーション証明書などの情報
登録パラメーター取得 API が "attestation": "none" で応答している場合、認証器の真正性確認で使用される署名や証明書情報は設定されない

登録処理の WebUI の実装

次に、WebUI 側の実装です。

パスキー登録画面

これが AMC の WebUI で、今回はユーザー個人の設定を行う機能にパスキー登録のための画面を作成しました。

この画面の設定ボタンをクリックすると、以下のスクリプトが実行され、パスキー登録が始まります。

register_request : async () => {
    // (1) 登録パラメーター取得 API の呼び出し
    response = await $.ajax
    ({
        type:'post',
        url:'/func/personal/mfa/passkey/api/creation_options/',
        dataType: 'json',
        cache: false,
        data:JSON.stringify({})
    });

    // (2) 登録パラメーター取得APIのレスポンスを編集
    // 認証器にはバイナリデータを渡す必要があるため、base64url2ab()にてBase64urlエンコードされたものを
    // Uint8Arrayへ変換している
    response.user.id = base64url2ab(response.user.id);
    response.challenge = base64url2ab(response.challenge);
    let options = {
        publicKey: response
    }

    // (3) create() の呼び出し
    pwdCred = await navigator.credentials.create(options);

    // (4) create()の戻り値を編集
    // ab2base64url()では、バイナリデータをBase64urlエンコードしている
    let = credentials = {
        id : pwdCred.id,
        type : pwdCred.type,
        rawId : ab2base64url(pwdCred.rawId),
        response : {
            clientDataJSON : ab2base64url(pwdCred.response.clientDataJSON),
            attestationObject : ab2base64url(pwdCred.response.attestationObject)
        }
    };

    // (5) 登録処理 API の呼び出し
    let param = {
        'credentials' : JSON.stringify(credentials)
    };
    result = await $.ajax
    ({
        type:'post',
        url:'/func/personal/mfa/passkey/api/attestation_response/',
        dataType: 'json',
        cache: false,
        data:JSON.stringify(param)
    });
    alert("公開鍵情報がサーバーに登録されました");
}

処理内容はシンプルで、まず登録パラメーター取得 API を呼び出して navigator.credentials.create() のパラメーターを生成しています(1)。
その際、base64url2ab() で、Base64url エンコーディングされた情報をバイナリデータ(ArrayBuffer 型) に変換しています(2)。

次に navigator.credentials.create() を呼び出して(3)、戻り値を先程と反対に ab2base64url() でバイナリから Base64url エンコーディングしています(4)。

その後、登録処理 API を呼び出して公開鍵などの情報を登録します(5)。

上記スクリプトの実行結果

では、上記のスクリプトを実行した結果を見てみましょう。 なお、今回は認証器に YubiKey 5 NFC を使用しています。

パスキー登録1

上のダイアログは、設定ボタンをクリックしたとき最初に表示される画面です。 YubiKey 5 NFC のようなセキュリティキーの場合は、「Windows Hello または外部セキュリティキー」をクリックします。

パスキー登録2

パスキー登録3

パスキー登録4

YubiKey5 NFC では、本人認証は、PIN コードの入力と YubiKey の中央の金属部分(y の字の部分)へタッチすることで行われます。 このとき入力する PIN コードは認証器内部の照合で使用され、ネットワーク上は流れません。 また、PIN コードの入力に加えてタッチを要求されるのは、人が認証器のすぐ近くにいて操作している(=リモートで不正な操作が行われているわけではない)ことを確認するために行われます。*1

パスキー登録5

これで AMC にパスキーが登録できました。 次に認証処理の実装をみていきます。

認証パラメーター取得 API の作成

この API は navigator.credentials.get() の呼び出しパラメーターになる情報を取得するためにクライアントから呼び出されます。 次のような JSON をレスポンスとして返します。

{
    "challenge": "T26O5DpSozRzxLqQ0Zrp387Ij3H6jxNYZxUf9vTwITk"
}
名前 概要
challenge サーバーが生成したチャレンジ

認証処理での challenge(チャレンジ) の用途は以下の通りです。

  • 同一性の検証

    • これは、登録処理と同様です。
  • 本人認証で使用される署名の一部

    • 登録処理では、認証器の真正性の確認に使用されていましたが、認証処理ではその他の情報とともに署名され、サーバーでの本人認証に使用されます。

認証処理 API の作成

署名などを検証し、正当性が確認できればユーザーにログインを許可します。 リクエストパラメーターとして、次のような JSON を送信します。

{
    "id": "JkxADqBLdF1M5Rt3y0EGfMXGXx0",
    "type": "public-key",
    "rawId": "JkxADqBLdF1M5Rt3y0EGfMXGXx0",
    "response": {
        "clientDataJSON": "d2Vi44OW44Op44Km4 ..省略.. 4K244O855Sf5oiQ44",
        "authenticatorData": "0ungbt45g67h6c ..省略.. dgeDsdrg3d44amjye",
        "signature": "MEUCIQCI7bi_NqqVS0CdLg ..省略.. sadkwxciGv1HRIQbxAeY",
        "userHandle": "NDg1ZGVmMzQtYjZkMC00ZDQ3LWJlNzQtOGY1NTQ1ZjI5YmQ2"
    }
}
名前 概要
id 登録処理で生成された公開鍵情報の識別子
type public-key 固定
rawId idと同じ
clientDataJSON web ブラウザーが生成する情報で、この中にサーバーが生成したチャレンジやオリジン(ドメイン)が含まれる
authenticatorData 認証器に関する情報
signature clientDataJSON と authenticatorData をもとに認証器が生成した署名
これを AMC 側で検証し、ログイン可否を判定する
userHandle ログインするユーザーを識別するための情報

認証処理の WebUI の実装

次に、WebUI 側の実装です。

メールアドレス入力

認証方法選択

現在の AMC のログイン画面は、一般によくあるユーザーID(メールアドレス)とパスワードの 2 項目とログインボタンが用意された画面です。 簡易的な実装なので、メールアドレスの入力とパスワードの入力を分離し、パスワード入力の画面でパスワードかパスキーのいずれかを選択できる程度の変更に留めています。 この画面で、「パスキーを使用してログインする」をクリックすると以下のスクリプトが実行され、本人認証が始まります。

authenticate_by_passkey : async () => {
    // 1. 認証パラメーター取得 API の呼び出し
    let request_param = {
        'ticket': $( 'input[name="ticket"]' ).val(),
        'email': $( 'input[name="e_mail_address"]' ).val()
    };
    response = await $.ajax
    ({
        type:'post',
        url:'/func/initial/api/request/',
        dataType: 'json',
        cache: false,
        data: JSON.stringify(request_param)
    });
    // 2. 認証パラメーター取得 API のレスポンスを編集(Base64url ⇒ バイナリ)
    response.challenge = base64url2ab(response.challenge);
    let options = {
        publicKey: response
    }
    // 3. get() の呼び出し
    assertion = await navigator.credentials.get(options);
    console.log(assertion);
    // 4. get() の戻り値の編集(バイナリ ⇒ Base64url)
    let credentials = {
        id : assertion.id,
        type : assertion.type,
        rawId : ab2base64url(assertion.rawId),
        response : {
            clientDataJSON : ab2base64url(assertion.response.clientDataJSON),
            authenticatorData : ab2base64url(assertion.response.authenticatorData),
            signature : ab2base64url(assertion.response.signature),
            userHandle : ab2base64url(assertion.response.userHandle)
        }
    };

    // 5. 認証処理 API の呼び出し
    let credential_param = {
        'email': $( 'input[name="e_mail_address"]' ).val(),
        'credentials' : JSON.stringify(credentials)
    };
    await $.ajax
    ({
        type:'post',
        url:'/func/initial/api/verify/',
        dataType: 'json',
        cache: false,
        data: JSON.stringify(credential_param)
    });
    // 6. トップ画面へアクセス
    $('#form_passkey').trigger('submit');
}

処理内容は、呼び出している API が異なるだけで登録処理と概ね同じなので、説明は割愛します。

上記スクリプトの実行結果

パスキー選択

PIN入力

YubiKeyタッチ

登録処理と同じような画面ですね。 YubiKey にタッチして認証器での本人認証が完了すると、チャレンジや署名などが AMC に送信され、それらを検証し正当性が確認できればログインが可能となります。

おわりに

サーバー側の処理は掲載しませんでしたが、さまざまな OSS が提供されているので、それらをうまく利用すれば実装の難易度を低くできると思います。 実際に AMC へ実装するときは、認証器が故障や紛失したときの救済策などさまざまなケースを想定して運用に耐えられる仕組みを構築する必要があると考えています。 もし次にブログを書く機会があれば、AMC に実際に実装した内容とその際に工夫したことや苦労したことなど交えながらご紹介できればと思います。

*1:これは指紋認証ではなく、人間の皮膚の微弱電流を感知することで人間の存在を確認することが目的です。