「パスキーのすべて」を読んでサイトにパスキーを実装した際に困ったこと

WebAuthn APIの利用自体は、署名の検証などをライブラリに任せることができたため、それほど難しくはありませんでした。また、「パスキーのすべて」という書籍のおかげで、WebAuthn APIの各オブジェクトに指定すべき値を理解するうえで大いに助けられました。

以下では、実装の際に検討する必要があった点について記します。

WebAuthnに使用するサーバー側エンドポイントの設計

エンドポイントの設定についてはとくに規定がないので任意に作成できます。

FIDOが定義するTransport Binding Profileには、以下のエンドポイントが記載されていますが、必ずしもこれに従う必要はありません。

  • POST /attestation/options
  • POST /attestation/result
  • POST /assertion/options
  • POST /assertion/result

今回は、WebAuthn APIの知識がある人がエンドポイントの役割を一目で理解できるようにするため、これに準拠する形で設定しました。

他社の実装例として、例えば、はてなのアカウントログインなどはフォームオートフィルで実装しているため、 /assertion/options は無視してドキュメントにチャレンジなどのオプション情報を埋め込んで表示することで、無駄なリクエストを省略しています。このように、各社それぞれで有意と思う形で実装されています。

AAGUIDの取得に関するブラウザごとの違い

AAGUIDは、キープロバイダーの特定に使用されますが、プライバシー情報に該当するため、ブラウザごとに扱いが異なります。

  • Google Chromeでは、ダイアログなしでAAGUIDが送信されます
  • Firefoxでは、追加の許可ダイアログが表示されます

Firefox Developer Editionでの表示

キープロバイダーの登録名とrp.nameの関係

パスキー作成リクエスト(PublicKeyCredentialCreationOptions)では、Relying Party(パスキーでの認証を受け入れるサイト)の情報を設定します。その際、nameに指定した文字列がキープロバイダーの登録名として使用されるかと思いましたが、実際にはそうではありませんでした。通常のサイトでパスワードを保存する場合と同様に、ドメイン名を基に適当な名前が生成されるだけでした。

rp: {
  "name": "Example Corp.",
  "id": "example.com"
}

1PasswordのChromeブラウザ拡張の挙動

2つの問題に直面しました。

フォームオートフィルログインの自動実行

フォームオートフィルログインでは、ユーザー選択のinputタグに選択肢を表示する必要があるため、ページロード時に/assertion/optionsが実行されます。

キープロバイダーがiCloud Keychainなどの場合は、ここで中断し、ユーザーがinputタグで選択してから認証が継続されます。しかし、1Passwordの場合、ロード時にキープロバイダーのユーザー選択が即座に表示され、そのまま認証に進んでしまいます。

実害はありませんが、挙動としてやや気になる部分でした。

JSON変換の問題によるパスキー登録・利用の失敗

こちらの問題は深刻で、Bitwardenでも同様の現象が発生するそうです。

AuthenticatorAttestationResponseAuthenticatorAssertionResponseはサーバーに送信する必要がありますが、これらにはバイナリデータが含まれるため、適切にJSON形式に変換する必要があります。

通常、PublicKeyCredential.toJSON() APIが提供されており、JSON.stringify()を実行すると内部的にこれが呼ばれます。しかし、1PasswordのChromeブラウザ拡張を認証器として使用すると、レスポンスオブジェクトのtoJSON()が封じられており、TypeError: Illegal invocationエラーが発生し、パスキーの登録・利用が必ず失敗します。

Google, GitHub, MoneyForwardなどでは1Passwordでも登録が可能ですが、Oracle CloudはこのAPIに依存しているそうで、不具合が報告されています。

1PasswordはPassageというパスキー登録サポートサービスを開始しており、この利用を促進するために意図的に制限している可能性もあるかなと推測しました。

この問題を回避するため、自前でJSON変換処理を実装しました。

function arrayBufferToBase64(buffer) {
  if (!(buffer instanceof ArrayBuffer)) {
    throw new TypeError("Expected an instance of ArrayBuffer");
  }
  const bytes = new Uint8Array(buffer);
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

function manualSerializeAttestationResponse(attestationResponse) {
  if (!(attestationResponse instanceof PublicKeyCredential)) {
    throw new TypeError("Expected an instance of AttestationResponse");
  }

  const credObject = {
    id: attestationResponse.id,
    type: attestationResponse.type,
    authenticatorAttachment: attestationResponse.authenticatorAttachment,
    clientExtensionResults: attestationResponse.getClientExtensionResults(),
    rawId: arrayBufferToBase64(attestationResponse.rawId),
    response: {
      attestationObject: arrayBufferToBase64(
        attestationResponse.response.attestationObject,
      ), // ArrayBuffer
      authenticatorData: arrayBufferToBase64(
        attestationResponse.response.getAuthenticatorData(),
      ), // ArrayBuffer
      clientDataJSON: arrayBufferToBase64(
        attestationResponse.response.clientDataJSON,
      ), // ArrayBuffer
      publicKey: arrayBufferToBase64(
        attestationResponse.response.getPublicKey(),
      ), // ArrayBuffer
      publicKeyAlgorithm: attestationResponse.response.getPublicKeyAlgorithm(),
      transports: attestationResponse.response.getTransports(),
    },
  };
  return JSON.stringify(credObject);
}

function manualSerializeAssertionResponse(assertionResponse) {
  if (!(assertionResponse instanceof PublicKeyCredential)) {
    throw new TypeError("Expected an instance of AssertionResponse");
  }

  const credObject = {
    id: assertionResponse.id,
    type: assertionResponse.type,
    authenticatorAttachment: assertionResponse.authenticatorAttachment,
    clientExtensionResults: assertionResponse.getClientExtensionResults(),
    rawId: arrayBufferToBase64(assertionResponse.rawId),
    response: {
      authenticatorData: arrayBufferToBase64(
        assertionResponse.response.authenticatorData,
      ), // ArrayBuffer
      clientDataJSON: arrayBufferToBase64(
        assertionResponse.response.clientDataJSON,
      ), // ArrayBuffer
      signature: arrayBufferToBase64(assertionResponse.response.signature), // ArrayBuffer
      userHandle: arrayBufferToBase64(assertionResponse.response.userHandle), // ArrayBuffer
    },
  };
  return JSON.stringify(credObject);
}

export function convertJsonSafely(authenticatorResponse, attestation = true) {
  try {
    return JSON.stringify(authenticatorResponse);
  } catch (e) {
    console.error("Error during JSON.stringify:", e);
    if (e instanceof TypeError) {
      return attestation
        ? manualSerializeAttestationResponse(authenticatorResponse)
        : manualSerializeAssertionResponse(authenticatorResponse);
    }
  }
}