WebAuthnのOptionsを色々変更してブラウザの挙動を検証してみた(登録編)

パスワードレス認証を考えるにあたりWebAuthnの理解はかかせません。 ただ、WebAuthnの仕様を読んでもイマイチどういう風に使うのかわからない…となりがちなので、実際に色々なOptionsで登録APIを呼び出してみて挙動を確認してみました。
2023.06.29

こんにちは。prismatix事業部Devチームの中島です。

今回はWebAuthnの仕様を理解するために登録APIをOptionsを色々変更して呼び出した時にどのような挙動になるのかを検証してみたいと思います。

(後日認証のAPIについても検証してみたいと思います)

検証環境

  • デバイス:MacBook Pro(2022 Apple M2)
  • OS:MacOS Ventura 13.4.1
  • ブラウザ:Chrome(114.0.5735.133)

WebAuthnのおさらい

WebAuthnは公開鍵認証方式を用いてWebアプリ・サービスがパスワードレス認証を可能にするための仕様です。

W3Cによって標準化が進められており、LEVEL3まで存在しますがLEVEL3は2023年6月時点でDraftなので LEVEL2の仕様 をもとに話を進めます。

登録API

登録APIの流れは以下図の通りとなります。

WebAuthnCreate

(画像は W3CのWebAuthn仕様のページより引用)

①サーバーから登録APIを呼び出す際に使用するOptions(challenge, user info, relying party info)を取得

②①で取得したOptionsをもとに登録APIを通じて認証器を呼び出す

③ユーザーが認証器で認証すると公開鍵・秘密鍵が生成され、認証器に保存される

④attestationObject(公開鍵、クレデンシャルIDなどの情報)が認証器から返却される

⑤④で取得したattestationObjectとclientDataJSON(①で取得したchallengeやoriginの情報など)をサーバーに返却

今回はサーバーは用意せず、手元でOptionsを作成して②を呼び出し、ブラウザの挙動を検証してみたいと思います。

検証

何はともあれ呼び出してみる

今回はサーバーを用意しないため、どのページでも検証はできるので今回は example.com を使用します。
開発者コンソールを開いて、以下のスクリプトを打ち込んで実行してみます。

let options = {
  publicKey: {
    rp: {
      name: "example.com",
      id: "example.com"
    },
    user: {
      id: base64ToArrayBuffer("sb+NegbwCHShh4LfTayn1Q=="),
      name: "webauthnTest",
      displayName: "TestUser"
    },
    pubKeyCredParams: [
      {
        type: "public-key",
        alg: -7
      }, 
      {
        type: "public-key",
        alg: -37
      }
    ],
    attestation: "none",
    timeout: 20000,
    challenge: base64ToArrayBuffer("aml5fdRa1G2VAD3BznLwWQ=="),
    excludeCredentials: [],
    authenticatorSelection: {
      authenticatorAttachment: "platform",
      userVerification: "required",
      residentKey: "preffered",
      requireResidentKey: false
    }
  }
}

// 認証器の登録 API
navigator.credentials.create(options).then(() => {
    alert('登録に成功しました。');
})
.catch((err) => {
    console.log("ERROR", err);
    alert("登録に失敗しました。");
});
// Base64文字列をArrayBufferにデコード
function base64ToArrayBuffer(base64String) {
    return Uint8Array.from(atob(base64String), c => c.charCodeAt(0));
}

// ArrayBufferをBase64文字列にエンコード
function arrayBufferToBase64(arrayBuffer) {
    return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}

コンソールに貼り付けて実行すると・・・  

WebauthnCreate1

登録ダイアログが表示されましたね!

ダイアログに表示されている「webauthnTest」はどうやら optionsで渡したuser.nameの項目をもとに表示されているようです。

「続行」をクリックしてみましょう。

WebauthnCreate1-2

デバイスのパスワードを求められます。

パスワードを入力してOKをクリックすると、登録に成功した旨のメッセージが表示されます。

Chromeの設定 -> 自動入力とパスワード  -> パスワードマネージャー -> パスキーを管理 を確認すると、ブラウザにパスキーとして登録されていることが確認できます。

ここでもユーザー名はoptionsのuser.nameに指定した値で保存されているようです。

WebauthnCreate1-3

なお、検証のたびにここにパスキーが溜まっていくので、面倒ですが上記画面から毎回削除しておきます。

WebAuthnAPIの呼び出しのイメージがついたところでOptionsをいじくって再度登録APIの呼び出しを実行して挙動を確認してみましょう。

rpのパラメータを変更してみる

以下の通りrpのnameを別のものに書き換えてみます。

...
    rp: {
      name: "example.org", // <- 呼び出しを行うドメインとは違う値に変更してみる
      id: "example.com"
    },
...他は前回と同じ

前回と同じく、登録ダイアログが表示されました WebauthnCreate1

仕様の該当箇所を見てみると、どうやら人間がわかりやすいようにつけるラベル的な役割のようで、挙動に変化はなさそうです。

ではrpのidを変更したらどうなるでしょうか。

...
    rp: {
      name: "example.com",
      id: "example.org" // <- 今度はidを呼び出しを行うドメインとは違う値に変更してみる
    },
...他は前回と同じ

実行してみると、今度は「登録に失敗しました」のメッセージが表示されました。

WebauthnCreateFailed

コンソールを確認してみると、 The relying party ID is not a registrable domain suffix of, nor equal to the current domain. と表示されています。

rpのidはドメイン名と一致している必要があるということですね。

userのパラメータを変更してみる

登録ダイアログとパスワードマネージャーに保存されるパスキーの名前はoptionsのuser.nameの値をもとに表示されているらしいことは最初の検証でわかりましたが、本当にその仮説が正しいのか、user.nameの値を変更して検証してみましょう。

...
    user: {
      id: base64ToArrayBuffer("sb+NegbwCHShh4LfTayn1Q=="),
      name: "webauthnTest_changed", // <- webauthnTest から webauthnTest_changed に変更してみる
      displayName: "TestUser"
    },
...他は前回と同じ

実行してみましょう。

Webauthn3-1

ダイアログの表示名が「webauthnTest_changed」になっていますね!

「続行」から認証器の認証を行った上で、パスワードマネージャーも確認してみましょう。

Webauthn3-2

思った通りパスワードマネージャーのユーザー名も「webauthnTest_changed」となっています。

timeoutの値を変更してみる

timeoutに設定した時間内に認証器の操作が終わらなかったらどうなるのでしょうか?

timeoutの値をすごく小さい値にして検証してみます(単位はミリ秒)。

...
    timeout: 20,
...他は前回と同じ

実行してみるとすぐさまタイムアウトエラーになるかと思いきや、10秒程度で以下の表示になりました。  

WebauthnCreate4

timeoutの項目の仕様を確認すると、これはあくまでヒントとして扱われ、クライアント(ブラウザ)によって上書きされる可能性があるとのことなのでChromeの下限値で上書きされているのかもしれません(すみませんこれは調査した結果確定的なことはわかりませんでした・・・)

authenticatorSelectionの値を変更してみる

authenticatorAttachmentをcross-platformにしてみる

authenticatorAttachmentの取りうる値としては platformcross-platform の二種類があります。

cross-platform に変更して挙動を確認してみます。

...
    authenticatorSelection: {
      authenticatorAttachment: "cross-platform", // <- `platform` から `cross-platform` に変更
      userVerification: "required",
      residentKey: "preferred",
      requireResidentKey: false
    }
...他は前回と同じ

実行してみると、QRコードが表示されました。

WebauthnCreate5

platformが現在操作中の端末を使用するという意味なのに対し、cross-platformは外部認証器(スマートフォンやUSBセキュリティキー)を利用した認証のため、端末のパスワード入力による認証ではなく外部認証器を呼び出すためのQRコードが表示されたというわけですね。

authenticatorAttachmentの項目を未指定としてみる

この項目は任意なので指定しなかったらどうなるのかやってみます。

...
    authenticatorSelection: {
      // authenticatorAttachment: "platform", // <- authenticatorAttachment をコメントアウト
      userVerification: "required",
      residentKey: "preferred",
      requireResidentKey: false
    }
...他は前回と同じ

実行してみると、以下の通りのダイアログが表示されます。

WebCreate5-2

authenticatorAttachmentを指定しない場合、外部認証器と操作中の端末のどちらも選択できるようになるようですね。

userVerificationをdiscouragedにしてみる

userVerificationの取りうる値はdiscouraged, preferred, requiredの3種類です。

discouragedに変更してみましょう。

...
    authenticatorSelection: {
      authenticatorAttachment: "platform", 
      userVerification: "discouraged", // <- userVerification を `required` から `discouraged` に変更
      residentKey: "preferred",
      requireResidentKey: false
    }
...他は前回と同じ

実行してみると、以下の通りのダイアログが表示されます。

WebauthnCreate1

登録ダイアログは最初と変わりません。「続行」をクリックしてみましょう。

WebauthnCreate5-3

おや、パスワード入力画面に遷移せずに登録が完了してしまいました。

UserVerificationをdiscouraged(推奨しない)にすると、ユーザーの認証を行わず、UserPresence(ユーザーの存在)の確認のみを行うという挙動をするようです。

residentKeyのパラメータを変更してみる

このパラメータは認証APIとセットで挙動を確認しないとわかりにくいため、認証の記事で確認していこうと思います!

まとめ

WebAuthnの登録APIの主なパラメータの挙動を見てきました。

実際に手を動かして挙動を確認することで理解が深まるかと思います。

今回紹介できなかったパラメータもありますが、ブラウザの挙動だけならJavaScriptで任意のページで挙動を確認できるので、興味がある人は色々試してみてはいかがでしょうか。

それでは!