2024年2月28日水曜日

PlayStationでパスキーを使う

こんにちは、富士榮です。

そういえばPlayStation Network(PSN)がパスキーに対応したというニュースが1月末ごろに流れたので確認してみていたのですが、その時点ではUSアカウントしかサポートしていない?みたいな状態だったのでしばらく放置していましたが、先ほど確認してみたら私のアカウントでもパスキーが使えるようになっていました。

ポイントはこちらです。

  • パスキーのサポート(とりあえずブラウザからのログインで確認)
  • パスキーを有効にするとパスワードは無効化される
  • パスキーを無効化する際にパスワードの生成を求められる
  • パスキーの識別はUser-Agentが使われる
  • パスキーを無くした際はメール+秘密の質問もしくは生年月日で回復できる
  • 回復の際はパスワードを生成が求められ、パスキーは無効化される

まぁ利便性などを考えると仕方ないのかもしれませんが、メール到達性+生年月日で回復できてしまうのは微妙だなぁ、、と思いつつ。


実機(といってもPS4しか持っていない)での確認は次回以降でやりたいと思いますが、とりあえずブラウザでの設定関係についてまとめておきます。


パスキーの登録

マイページにログインしてセキュリティーの設定の中に「パスキーでサインイン」というメニューがあります。


編集をクリックして登録を開始していきます。

とりあえずTouchIDで生成していきます。

生成が完了するとログアウトされます。


サインイン

サインイン画面に行くと自動的に候補となるパスキーが表示されます。

このまま選択してTouchIDをアクティベートするとログインが完了します。

なお、Autofillを無視してサインインIDにメールアドレスを入力して次へ、をクリックすると「パスキーでサインイン」しか出てこずパスワードでのログインはできないようになっています。


先ほどのマイページのセキュリティ設定を見るとパスキーが有効、パスワードが無効になっていることがわかります。



パスキーを無効化する

この状態でパスキーを無効化してみます。



パスワードの生成を求められますので、ここで生成するとパスキーが無効になり、パスワードでログインができるようになります。


パスキーの管理

パスキーの管理メニューを開くと登録済みのパスキーの一覧が出てきます。

パスキーの識別にはUser-Agentが使われるようです。自分で名前がつけられる方が親切な気はしますがまぁいいかと思います。


ローカル認証器が使えない状態でのサインイン(ハイブリッド)

メールアドレスを入力しパスキーでサインインから他端末で読ませるQRを表示する方法もありますが、リカバリの流れで他端末を使ったサインインもサポートしています。


リカバリ

他の端末を含め全部ロストしてしまったケース用の動線も用意されています。

具体的にはアカウント回復用のメール+秘密の質問もしくは生年月日を使います。


画面下部の「アカウントの回復用のEメールを送信」をクリックするとメールが送られてくるのでリンクをクリックすると回復用の画面が表示されます。


秘密の質問への回答を覚えている訳がないので生年月日を選びます。

検証に成功するとパスワードの生成を求められます。

この段階でパスキーが無効になりますので、パスワードでログインした後に再度パスキーを登録する必要があります。


なお、パスキーが無効化された状態でパスキーでログインしようとするとエラーが表示されます。



とりあえずはブラウザで試しましたが実機を触る時間ができたら実機でも試してみたいと思います。














2024年2月27日火曜日

パスキーでログインを実装する(クレデンシャルの取得編)

こんにちは、富士榮です。

前回までで登録がある程度実装できたので、細かいところを実装する前にログイン側の処理を実装してみましょう。

その前に、これまでのポストはこちらです。


登録に比べてログインは割とシンプルです。

  • 登録と同じ様にサーバ側で生成したchallengeを含むoptionを生成する
  • optionを指定してnavigator.credential.get()を実行する
  • 認証器をアクティブ化し、navigator.credential.get()の返却値から取得できるCredentialIDをキーに登録済みの公開鍵を取得する
  • 取得した公開鍵を使い同じくnavigator.credential.get()の返却地に含まれるデジタル署名等の検証を行い、成功したら認証OKとする
こんな流れです。


では実装していきましょう。

Challengeの取得

登録の時と同じですね。

// ログインする
async function loginWithPasskey(userId, userVerification) {
// challengeを取得する(後で使うのでサーバサイドで生成する)
const challenge = await _fetch(
'/passkey/getChallenge',
'GET',
{
'X-Requested-With': 'XMLHttpRequest'
}
);
// バイナリを扱うためにサーバ・クライアント間ではbase64urlエンコードした値でやり取りする
const encodedChallenge = await challenge.text();
const decodedChallenge = await b64decode(encodedChallenge);

サーバ側で生成してbase64urlエンコードされたchallengeをクライアント側でデコードしてArrayBufferの形にしてあげます。


optionパラメータを生成する

取得したchallengeを含むoptionパラメータを生成します。ユーザ認証を認証器側に求めるかどうかのスイッチもここで設定できます。

// パスキーログインのためのパラメータを生成する
const options = {
challenge: decodedChallenge,
allowCredentials: [],
userVerification: userVerification
}


navigator.credential.get()を実行する

生成したoptionを指定してAPIを実行します。

const cred = await navigator.credentials.get({
publicKey: options,
mediation: 'optional'
});

画面にはパスキーダイアログが表示されます。



結果、返却されるPublicKeyCredentialの中身を見ると今度は特徴的なプロパティとして

  • userHandle
  • signature
が入っていることがわかります。


このuserHandleはパスキー登録時に指定したユーザIDなので、credentialIdをキーにデータベースを検索して公開鍵を取得、このユーザIDに対して発行されたものなのかどうかの確認する、という流れになります。

JSONBinの中のcredentialIdが一致しているものを探してPublicKeyを取得します。



あとは検証の部分ですので、次回解説していきたいと思います。

2024年2月26日月曜日

登録済みのパスキーを削除する(MacOS)

こんにちは、富士榮です。

パスキーの実装をしていると大量のパスキーが実装中のRelying Partyに対して登録されてしまうことがあります。(使っているユーザ名を適当に使っていると尚更、、です)

こんなことが起きます。(ユーザ名が大量に表示されてしまいます)
これを消すにはどうしたら良いか、という話ですがMacの場合はシステム設定のパスワードメニューを開き、Relying PartyのID(ドメイン名)を入れると登録されているパスキーが大量に出てくるので個別に確認して消していきましょう。
ちなみに複数選択して一括で削除もできます。





実装とテストは計画的に、、、、ですね。

2024年2月25日日曜日

パスキーの登録を行う

こんにちは、富士榮です。

前回まででパスキーの登録に必要な最低限の準備ができたので公開鍵の情報をユーザに紐づけて保存していきましょう。

現時点で最低限登録すべき情報は、
  • ユーザ情報(ユーザ名、ユーザID)
  • 認証器クレデンシャルのID(credentialId)※ritou先生ご指摘ありがとうございます。
  • 公開鍵(PublicKey)
  • 署名回数(signCount)
くらいです。
今後、同一ユーザが複数の認証器を登録するケースへの対応はしていくとして、現時点では認証器は一人一つの登録に限定しておきたいと思います。(単に面倒なだけです)

では初めていきましょう。

ユーザの情報を取得する

前回までは認証器にフォーカスしてきたのでユーザ名、ユーザIDはフォームからの入力とAPI呼び出しの際に都度作成するだけでどこにも保存してきませんでした。
しかし、今回はパスキーの情報をユーザ情報と紐づけて保存しなければならないので、
  • 認証器の登録開始前にユーザ名入力〜ユーザIDの生成 → セッションに保存
  • 認証の生成API実行
  • API返却値の取得〜登録 → セッションからユーザ情報を取り出して一緒に登録
ということをする必要があります。

ということで、クライアント側のJSで認証器登録オプションを生成する際に、サーバ側のエンドポイントを呼び出して画面から入力したユーザ名の保存とArrayBuffer形式でのユーザIDの生成の処理をサーバ側に移します。
こんな感じの処理になります。
(クライアント側のJS)
// ユーザIDを取得する
// ユーザを登録する
const user = {
username: userId
};
const userIdresponse = await _fetch(
'/passkey/user',
'POST',
{
'Content-Type': 'application/json'
},
JSON.stringify(user)
);
const encodedUserId = await userIdresponse.text();
const arrayBufferUserId = await b64decode(encodedUserId);


(サーバ側のJS)
// ユーザを登録する
router.post('/user', async (req, res) => {
// 登録状態を調べる
// 既存なら登録済みパスキーを取得して返却する→Excludeに入れる
// 新規なら登録
// 構造
// - username
// - userId
// userIdを生成する
userId = base64url.encode(generateRandomBytes(16));
const user = {
username: req.body.username,
id: userId
};
// ユーザ情報をセッションに保存する
req.session.user = JSON.stringify(user);
// userId(ArrayBuffer)を返却する
res.send(userId);
})

見ての通り、クライアント側ではフォームに入力したユーザ名(userId)をサーバ側にPOST、サーバ側ではArrayBuffer形式でランダムなユーザID(同じ変数名にして失敗しましたがuserId)を生成、セッションに保存した上でクライアントにエンコード済みのユーザIDを返却しています。
クライアントはこの値をbase64urlデコードした上でoptionに指定してcredential.create()を実行することになります。

認証器の登録結果を取得する

これは単純にcredential.create()の結果を取得するだけです。
// ブラウザAPIの呼び出し
const cred = await navigator.credentials.create({
publicKey: options,
});

このcredという変数ですね。ここは前回まででも話しました。
この中身を保存していく必要があるのでサーバ側にこの値を渡していくことになるのですが、このままだとバイナリの値を含むのでサーバ側で取り回しにくいためPublicKeyCredentialのインターフェイスにはtoJSON()というメソッドがあるため、これを使ってJSON化したものをサーバ側へPOSTしていきます。
// 取得した認証器情報をサーバ側で保存
const savedCredential = await _fetch(
'/passkey/registerPasskey',
'POST',
{
'Content-Type': 'application/json'
},
JSON.stringify(cred.toJSON())
);

サーバ側では前回のポストで紹介した様にchallenge、origin、rpIdHashの検証を行います。
そしていよいよ保存すべき情報をPOSTされてきた情報から取得していきます。

先に述べた通り、保存すべき情報は以下の通りです。
  • ユーザ情報(ユーザ名、ユーザID)
  • 認証器のID(credentialId)
  • 公開鍵(PublicKey)
  • 署名回数(signCount)
ユーザ情報は先ほどセッションに入れておいたものを取り出せばOKです。
// sessionからユーザ情報を取り出す
const user = JSON.parse(req.session.user);
console.log("username: " + user.username);
console.log("userid: " + user.id);

認証器のIDはPOSTされてきたJSON(PublicKeyCredential)の直下にidという要素で入っていますので取り出します。
// credentialIdをPublicKeyCredentialから取得する
const credentialId = req.body.id;
console.log("credential id: " + credentialId);

公開鍵はJSONの下のresponseの中に入っているので取り出します。
// publicKey(base64urlエンコード済み)を取得する
const publicKey = req.body.response.publicKey
console.log("public key: " + publicKey);

署名回数はフラグの次の4バイトに入っているのでrpIdHashと同じくauthenticatorDataの中から取り出します。最初の32バイトがrpIdHash、33バイト目がフラグ、34バイト目〜4バイト分が署名回数ですので、0オリジンでsliceして値を取り出します。当然値はArrayBufferなので適切に処理します。今回はヘキサの文字列にしたのちにParseIntで数値化しています。
// signCount
const signCount = decodedAuthenticatorData.slice(33,37);
const signCountHex = Buffer.from(new Uint8Array(signCount)).toString('hex');
console.log("sign Count: " + signCountHex);
console.log("sign Count Number : " + parseInt(signCountHex, 16));

これで登録に必要な情報は揃いました。
// 登録する情報
const passkey = {
username: user.username,
userId: user.id,
credentialId: credentialId,
publicKey: publicKey,
signCount: parseInt(signCountHex, 16)
}

あとは以前OpenID Providerを作る時にも使ったJSONBinを使いパスキーの情報を登録していきます。
// JSONBinへ登録
const JSONBinResponse = await jsonbin.registerPasskey(JSON.stringify(passkey));

exports.registerPasskey = async function(passkey) {
const passkeyJSON = JSON.parse(passkey);
console.log("username: " + passkeyJSON.username);

const headers = new Headers({
"X-Master-Key": process.env.JSONBIN_MASTER_KEY,
"X-Collection-Id": process.env.JSONBIN_PASSKEYCOLLECTION_ID,
"X-Bin-Name": passkeyJSON.username,
"Content-Type": "application/json"
});
const binUrl = new URL(`${process.env.JSONBIN_BASEURL}/b`);
const binResponse = await fetch(binUrl, {
method: 'POST',
headers: headers,
body: passkey
});
return await binResponse.json();
}

これで登録はできましたね。

これでようやくサインイン処理の実装に進む準備ができましたので、次回以降でサインイン処理を実装していきたいと思います。


2024年2月24日土曜日

デジタル認証アプリがやってくる(その後)

こんにちは、富士榮です。

先月、デジタル認証アプリに関連する法令に関するパブコメ募集が出ている件について個人的に考える課題点について書きました。

デジタル認証アプリがやってくる

https://idmlab.eidentity.jp/2024/01/blog-post_28.html 


それなりのPVがあったこともあり、某雑誌社の方からインタビューがあったりもしました。

パブコメの募集が2月末までだったこともあり、コメントをしてみました。

(なお、重要なことですがこの分野は素人なので頓珍漢なコメントをしている可能性も高いです。後述しますが今回のパブコメの対象は認証アプリそのものというよりも法律だったこともあり、私の条文の解釈の仕方はおそらく間違っている可能性が高いです)

ということで個人的解釈に基づく今回のパブコメ募集の中身を深掘りしていきたいと思います。

パブコメの対象は何か? 

はい、ここがおそらく一番重要だと思います。
募集要項にバッチリ書かれています。よく読んでコメントしましょう。
対象は「法令施行規則」ですね。

読んでみます。縦書きPDF・・・


その中のコメントの対象は何か?

対象が法令施行規則だということが分かった上でコメントしてほしいポイントはどこなのか?という点を見ていきましょう。
「命令案」の中に「改正後」として記載されているところに改正したいポイントがわかる様に記載されています。

ここは優しく「概要」という資料に改正のポイントがまとめられていますのでこちらも併せて見ると理解が早まると思います。


要するに、「電子署名等確認業務受託者」に「内閣総理大臣」を加えたいということですね。
なんでこんなことをやっているのか?を紐解くためには「電子署名等確認業務受託者」とはなんなのか、どういう義務を負うのか、について改正対象となる「電子署名等に係る地方公共団体情報システム機構の認証業務に関する法律」を見てみましょう。
こちらで参照できます。

これをみていると結局のところ「電子署名等確認業務受託者」、つまり現状でいう「プラットフォーム事業者」といわれるJ-LISのサーバに対してアクセスをしてマイナンバーカードの有効性確認等を実施することができる事業者に「個人番号カード用利用者証明用電子証明書」に関して「利用者に関する情報を適切に扱うこと」などを義務付ける法律であることがわかります。


では、なぜ内閣総理大臣を受託事業者に追加するのか?

今回やりたいことは認証アプリの「システム連携イメージ」に記載がある様に、デジタル庁の管理する「デジタル認証アプリサーバ」がJ-LISの管理するJPKIサーバに対してアクセスする、ということです。これは正に現在のプラットフォーム事業者が行なっているマイナンバーカードの有効性確認などと同じ構図となります。

これまでは前述の法律に則って総務省認定を受けた受託事業者のみがJPKIサーバへのアクセスを許可されていたわけですが、上記の図の構成、つまりデジタル庁がJPKIサーバへアクセスしようと思うと根拠法が存在しないわけです。

となると、行政機関の長である内閣総理大臣を受託事業者に加えておかないといけなくなる、とうロジックなんだと思います。


本当にそれでいいのか?どうコメントすべきか?

構図がわかったところでツッコミどころコメントすべき事項を探すわけですが、結局は従来の民間のプラットフォーム事業者と行政機関の差を紐解いていくのがアプローチが良いのではないかと思います。

利用者に関する情報を適正にあつかうこと、という点については民間だろうが行政機関だろうがそうでしょうね、という感じで違和感はないのですが、この利用者に関する情報は一体何を含むんだろうか?という点を見ていくとどうやら法律では「利用者証明利用者符号」を中心に考えているんじゃないかな?と思えてきます。

結局、マイナンバー(カードじゃなく)や証明書シリアルなど、背番号制度に関するアレルギーが意味不明に強い日本では符号や識別子に関しては非常に慎重に扱われる傾向にありますが、行政機関においては識別子についてはそもそも扱う前提があるんじゃなかったっけ?民間事業者が扱うことになるから認定制度があるんじゃなかったっけ?というところに行き着きます。

むしろ前回のポストでも記載した通り行政機関がやるから気持ち悪いポイントは公共・準公共・民間という異なるコンテキストを横断的に政府のIdentity Providerがまとめてフェデレーションをする、つまりデジタル庁の認証アプリサーバから見て利用者がどのRelying PartyとID連携しているのかがわかってしまう、という点にあるはずです。これはIdentity Providerを実装したことがある人ならわかると思いますが、利用者がどのRelying Partyに対して属性提供について同意しているかの状態を保持したり、PPIDを生成するための連携状態を保持したり、とログを含むとそれなりにID連携状態の情報を持つ必要が出てきてしまいます。

こうなってくるとこの法令施行規則に関する改正のポイントが受託事業者の対象に内閣総理大臣を加えることでプラットフォーム事業者と同じことをデジタル庁ができる様になりますよ、だけでは少々不足していると思われるので、法令改正対象には少なくとも「符号などに関する情報の適正な管理」だけでなく「ログを含むID連携状態の適正な管理」を求めていかないといけないのではないか?というのが現時点での私の結論です。

とりあえずそんな主旨でコメントはしてみたので、今後どうなっていくかは注視していきたいと思います。








2024年2月23日金曜日

パスキーの登録レスポンスの検証を行う

こんにちは、富士榮です。

最近はパスキーの実装を眺めているわけですが、そろそろ認証器の登録を行う段階まで来ました。(まだまだ完成までは遠い道のりです。でも全然触ってこなかったものを勉強しながら作っていくのは楽しいですね)

これまでのポストはこちらです。

これまでの流れでパスキーを登録する流れを見てきました。
  • サーバのエンドポイントからchallengeを取得する
  • ブラウザAPIにchallenge、ユーザ情報、Relying Party情報、認証器への要求事項をセットして実行する
  • 認証器をアクティブ化する(Touch IDをタッチする、など)
  • ブラウザAPIからの返却値を検証し、認証器を登録する
という流れで認証器の登録を行います。
今回は最後のステップである「ブラウザAPIからの返却値を検証し、認証器を登録する」ところに入っていきます。

// ブラウザAPIの呼び出し
const cred = await navigator.credentials.create({
publicKey: options,
});

こんな感じでnavigator.credentials.create()を実行すると、PublicKeyCredentialが返されます。(上記例ではcred)
PublicKeyCredentialの仕様は以下のドキュメントに記載されています。

そして、これが今回の最大のテーマでもありますが、安全に認証器を登録するためには以下のポイントを押さえておく必要があります。
  • 登録を開始したセッションから最終的に認証器を登録するまでのセッションの一貫性
  • フィッシングやMITM等により攻撃者の認証器を横から登録されることの防止
この辺りはOpenID Connectとも共通しますが、セッションと紐づけた状態でchallenge(OpenID Connectの場合はstateやnonce)をサーバ側とクライアント側で引き回すことで一連の流れの中で認証器を登録していることを保証します。

そのためにPublicKeyCredentialの中に含まれる以下の値の検証を行うことが大切となります。
  • challenge
    • 認証器登録を開始する際にサーバ側で生成し、セッションに紐づけておきます。
    • credential.create()のオプションに取得したchallengeを入れることで登録しようとしている認証器とchallengeの紐付けを行います
    • 認証器がアクティブ化されcredential.create()からの返却値には認証器から受け取ったchallengeの値が入ります
    • credential.create()の返却値をサーバ側へ渡し、サーバ側に認証器を登録します。この際、最初に生成しセッションに紐づけたchallengeと同じ値が認証器の登録結果から取得したものと同一かどうかを確認します
  • origin
    • 認証器を提示した先のサイトと、認証器を登録する先のエンドポイントが同一のホストであることを確認することでフィッシングなどを防ぎます
    • credential.create()の返却値に含まれるclientJSONのメンバにoriginの値が入っているため、この値と認証器を登録するエンドポイントのホスト名が一致しているかどうかの検証が大切です。
  • rpIdHash
    • これoriginと同じ様にユーザがパスキーを登録しようとしているRelyingParty(credential.create()を実行する際のオプションに含まれます)と実際に認証器を登録するRelyingParty(credential.create()の結果のclientJSONに含まれます)が同一であることが必要です。

では実際のコードを見ていきましょう。

Challenge

まずはchallengeです。ポイントはセッションと紐づけてchallenge値を保持しておくことです。
router.get('/getChallenge', async (req, res) => {
const challenge = b64encode(generateRandomBytes(16));
console.log("save challenge into session : " + challenge);
req.session.challenge = challenge;
res.send(challenge);
});

今回の実装では/getChallengeがGETされるとランダム値を持つchallengeを生成しています。APIの仕様としてArrayBufferとする必要がありますが、ブラウザとバックエンドの間でやり取りするのにバイナリではやりにくいのでbase64urlエンコードをして扱います。なお、この値をブラウザに返すと同時にセッションにも保存をしておきます。この保存した値を使って後から登録処理の一貫性を担保します。

ブラウザ側はchallenge(base64urlエンコード済み)を受け取るとcredential.create()の引数となるoptionのメンバにArrayBufferに戻したchallengeをセットしてAPIを実行します。
// バイナリを扱うためにサーバ・クライアント間ではbase64urlエンコードした値でやり取りする
const encodedChallenge = await challenge.text();
const decodedChallenge = await b64decode(encodedChallenge);

こんな感じでoptionを生成していきます。
// パスキー登録のためのパラメータを生成する
const options = {
challenge: decodedChallenge,
rp: {
name: "test site",
id: window.location.hostname
},
user: {
id: arrayBufferUserId,

このオプションを指定してcredential.create()を実行して得られるレスポンス(以下の例ではcred)をサーバ側へPOSTします。
const savedCredential = await _fetch(
'/passkey/registerPasskey',
'POST',
{
'Content-Type': 'application/json'
},
JSON.stringify(cred)
);

このPublicKeyCredential形式のcredの中にはclientDataJSONが含まれ、その中にchallengeの値が入ってきます。
// navigator.credentials.create()から返却されるclientDataJSONの取得
const clientJSON = JSON.parse(base64url.decode(req.body.response.clientDataJSON))
サーバ側では受け取った値を解析し、clientDataJSONの中からchallengeの値を取得し、セッションに保持していたchallengeの値と比較します。
console.log("expected challenge from session : " + req.session.challenge);
console.log("challenge to be evaluated : " + clientJSON.challenge);
if(req.session.challenge !== clientJSON.challenge){
console.log("challenge mismatch");

origin

次はoriginです。
こちらも同じくclientDataJSONに含まれるのでサーバ側のドメインとoriginの値が一致しているか比較します。
// originがアクセスされているURLと同じかどうか
// ngrokでテストしているのでhttpではなくhttpsで固定する(localhost以外の場合)
const scheme = req.hostname !== 'localhost'? "https" : req.protocol;
const origin = url.format({
protocol: scheme,
host: req.get('host')
});
console.log("expected origin : " + origin);
console.log("origin to be evaluated : " + clientJSON.origin);
if(origin !== clientJSON.origin){
console.log("origin mismatch");

rpIDHash

最後はrpIdHashです。名前の通りrpIdのハッシュ値です。この値はclientDataJSONの中ではなく、同じくPublicKeyCredentialのresponseの中にあるauthenticatorDataに格納されています。
このauthenticatorDataは先日のflagの判別にも使いましたが、最初の32バイトがrpIdHash、33バイト目がflag、という形で構成されるバイナリ値です。そのため、authenticatorDataの最初の32バイトを切り出して比較可能な形式に変換する必要があります。

一方で比較対象となるrpIdはその名の通りあらかじめセットしてRelying PartyのID(通常はホスト名)のハッシュ値です。ハッシュの仕様の説明はこちらにある通りでSHA256です。ちゃんと検証しなさい、ということも書いてあります。
The SHA-256 hash of the Relying Party ID that the credential is scoped to. The server will ensure that this hash matches the SHA256 hash of its own relying party ID in order to prevent phishing or other man-in-the-middle attacks.
ということで仕組みが分かりましたので、まずは比較対象となるrpIdのハッシュを計算します。
const rpId = req.hostname;
const expectedRpHash = crypto.createHash('sha256').update(rpId).digest('hex');
console.log("expected rpId hash: " + expectedRpHash);

こんな感じの文字列が取得できます。
4dc265ac185ff67ead15d60a5cc745c2f10cd367cea3493a79a38a5f7a040a8b

次にauthenticatorDataの中から比較するものを取得します。
const decodedAuthenticatorData = base64url.toBuffer(JSON.stringify(req.body.response.authenticatorData));
const rpHashBuffer = decodedAuthenticatorData.slice(0, 32);
const rpHashString = Buffer.from(new Uint8Array(rpHashBuffer)).toString("hex");
console.log("rpId hash to be evaluated : " + rpHashString);

やっていることはsliceで最初の32バイトを切り取ります。
この状態だとArrayBufferなのでという状態のデータとなり、先に生成したrpIdHashとの比較を単純に行うことはできません。
<Buffer 4d c2 65 ac 18 5f f6 7e ad 15 d6 0a 5c c7 45 c2 f1 0c d3 67 ce a3 49 3a 79 a3 8a 5f 7a 04 0a 8b>

ただ、値を見ると上記で生成した4dc2....と同じ値になっていることはわかります。
これを文字列比較可能な状態にするためにArrayBufferを16進数の文字列へ変換(toString('hex'))します。
これで文字列として値の比較ができる様になりますので、challengeやoriginと同じ様に比較をしていきます。

もちろんプロダクション環境では実績のあるライブラリを使ってやる処理なのですが、今回は処理の内容を理解するためにこの辺りの細かいところも実装してみました。

比較がうまくいったら認証器の情報をサーバ側に保存して処理は終了です。
この辺りは次回以降で説明したいと思います。

2024年2月22日木曜日

パスキー登録APIのレスポンスを解析する

こんにちは、富士榮です。

先日、いろいろな認証器でパスキー登録をしてみましたが、レスポンスの中のフラグをどうやって取得するの?というあたりについて細かく解説していないの今回解説していきたいと思います。

navigator.credentials.create()メソッドの返却値の定義がこちらのドキュメントにあります。

https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create#return_value_2

定義によると、PublicKeyCredentialが返されるようですね。

A Promise that resolves with an PublicKeyCredential instance matching the provided parameters. If no credential object can be created, the promise resolves with null.

では、PuiblicKeyCredentialの定義を見ていきましょう。

https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential

以下のメンバが含まれます。

  • PublicKeyCredential.authenticatorAttachment
  • PublicKeyCredential.id
  • PublicKeyCredential.rawId
  • PublicKeyCredential.response
  • PublicKeyCredential.type
上記のうち、PublicKeyCredential.responseの中に各種フラグなどが格納されます。
このresponseはAuthenticatorAttestationResponse(AuthenticatorResponseから継承)にはgetAuthenticatorData()というメソッドがあり、認証器のデータを取得できます。
このメソッドは最低37バイトからなるArrayBufferであるAuthenticator dataという形式のデータを返却します。
回りくどかったですが、このAuthenticator Dataの中に欲しい情報が入っています。
先に書いた通り、この返却値はArrayBufferで、
  • rpIdHash (32 bytes)
  • flags (1 bytes)
  • signCount (4 bytes)
  • attestedCredentialData (variable length)
  • extensions (variable length)
という構造になっています。

先日のポストではこの33バイト目にあるflagsの値を見ていたわけです。
flagsの値を再掲しますが、この1バイトの中の各ビットが認証器が使用されたときの状態を表しています。
Bit意味説明
0User Presence (UP)If set (i.e., to 1), the authenticator validated that the user was present through some Test of User Presence (TUP), such as touching a button on the authenticator.
1--
2User Verification (UV)If set, the authenticator verified the actual user through a biometric, PIN, or other method.
3Backup Eligibility (BE)If set, the public key credential source used by the authenticator to generate an assertion is backup-eligible. This means that it can be backed up in some fashion (for example via cloud or local network sync) and as such may become present on an authenticator other than its generating authenticator. Backup-eligible credential sources are therefore also known as multi-device credentials.
4Backup State (BS)If set, the public key credential source is currently backed up (see Bit 3 for context).
5--
6Attested Credential Data (AT)If set, the attested credential data will immediately follow the first 37 bytes of this authenticatorData.
7Extension Data (ED)If set, extension data is present. Extension data will follow attested credential data if it is present, or will immediately follow the first 37 bytes of the authenticatorData if no attested credential data is present.


この辺りをコードで表すとこんな感じになります。

まずはcreate()を行います。
// ブラウザAPIの呼び出し
const cred = await navigator.credentials.create({
publicKey: options,
});

フラグの値を取得します。
const flags = new DataView(cred.response.getAuthenticatorData()).getUint8(32).toString();
$("#userPresence").text("User Presence(UP) : "+ ((Number(flags) & 1)? 'Yes': 'No'));
$("#userVerification").text("User Verification(UV) : "+ ((Number(flags) & 4)? 'Yes': 'No'));
$("#backupEligibility").text("Backup Eligibility(BE) : "+ ((Number(flags) & 8)? 'Yes': 'No'));
$("#backupState").text("Backup State(BS) : "+ ((Number(flags) & 16)? 'Yes': 'No'));
$("#attestedCredentialData").text("Attested Credential Data(AT) : "+ ((Number(flags) & 64)? 'Yes': 'No'));
$("#extensionData").text("Extension Data(ED) : "+ ((Number(flags) & 128)? 'Yes': 'No'));

getUint8の引数で32バイトオフセットすることでflagsのところまで辿りつけるので、その値をNumber化した上で各ビット単位で論理和をとって状態を取得しています。

ざっとこんな感じでフラグを取得していました、という種明かしでした。