2024年2月29日木曜日

PlayStationでパスキーを使う(実機編)

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

前回はPCブラウザでPlayStationネットワークへのログインをする際にパスキーを使う話をしましたが、今回は実機が触れる環境があるので実機でも触ってみましょう。

結果、期待していた本体側でHybridのQRが出て、という形ではなくデバイスフロー+モバイル側でパスキーログインというのが答えでした。
ただし、本体側でパスキー設定を有効化することもできるようなメニューができている点が新しいファームウェアでの対応ポイントっぽいです。

サインイン

本体でPlayStationネットワークへサインインするメニューを立ち上げると、このような画面が出ます。これはパスキーをサポートする前と何も変わりません。よくあるデバイスフロー(もしくはその代替で独自で作った仕組み)でのログインです。

ちなみに「手動でサインインする」を開くと通常通りパスワードでログインする画面は出てきてしまいますがパスキーが有効な状態だとどう頑張ってもログインはできません。


ですので先ほどのQRコードを読み込んでスマホでパスキーを使ってログインする必要があります。


うまくログインできると本体とリンクされて本体側でのサインイン処理が進みます。


本体のセキュリティー設定を見るとちゃんとパスキーが有効になっていることがわかります。



本体でパスキーの登録を行う

では、パスキーが無効な状態で先ほどのセキュリティー設定の画面を開いて、パスキーの生成を本体で行うとどうなるのでしょうか?
ご想像の通りQRコードが出てきます。

こちらを読み取ってスマホ側でパスキー登録を行う、という流れです。






こんな感じです。
せっかくPlayStationにはUSBのコネクタがあるのでYubikeyを使えるとかだと面白かったのですが、、流石にそれはなかったです。

ニンテンドーアカウントでもパスキーが使えるようになっていますし、ゲームコンソールなどのパスワード入力が難しいデバイスのログインもどんどん進化していっていますね。
Switchが欲しくなってきました。




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化した上で各ビット単位で論理和をとって状態を取得しています。

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









2024年2月21日水曜日

OAuth2.0 Security Best Current Practiceを読んでみる(5)

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

すこし空きましたがこちらも続けていきます。

引き続き攻撃パターンと緩和策です。前回はアクセストークンインジェクションまで行きましたので続き7個目/18個のクロスサイトリクエストの偽造から行きたいと思います。


攻撃パターンと緩和策

  • CSRF(クロスサイトリクエストフォージェリ)
    • 攻撃者としては正規のクライアントが攻撃者の制御下にあるリソースにアクセスさせたいので、redirect_uriに不正にリクエストをインジェクションしようとします
    • 緩和策
      • 基本はstate/nonce/PKCEを正しくセッションに紐づけた形で利用することにつきます
        • しかしながら例えば、クライアントがPKCEを使う場合は当然のことながら認可サーバがPKCEをサポートしていることを確認しないといけません
        • 同じくstateを使う場合はstateの改ざんやスワッピングに対する耐性を持つような実装にしないと意味がありません
      • 認可サーバは自身がPKCEをサポートしていることをクライアントが検知するための仕組みを提供する必要があります(MUST)
      • 基本はメタデータを使うことになりますが、別のメカニズムで検知方法を提供しても問題はありません(MAY)
      • stateやnonce(response_typeがid_tokenの場合)は認可レスポンスやトークンレスポンスを攻撃者が読み取れる環境においてはリプレイアタックなどに使われる可能性がありますが、その点はPKCEを使うことで対応ができます
  • PKCEダウングレード攻撃
    • PKCEをサポートしているものの、全てのフローがPKCE対応しているわけではない認可サーバはPKCEダウングレード攻撃を受ける可能性があります
    • 例えばこんな実装です
      • 認可リクエストにcode_challengeがあったらPKCEを有効にする、という判定ロジックが認可サーバに組み込まれている(逆にいうとcode_challengeが指定されない場合はPKCE対応しない)
      • 上記の前提があるにも関わらずCSRF対策としてstateを使わない(PKCEを使うことを前提としてしまっている)
    • まぁ、当然ですがこうなるとCSRF対策をしていないのと同じです
    • 攻撃者はクライアントと認可サーバの間に入り、code_challengeを丸ごと削除してしまいセッションを乗っ取るわけです
    • 緩和策
      • この攻撃を受けている際の特徴は、認可リクエストにcode_challengeがない(削除されている)にも関わらずトークンエンドポイントへのアクセス時はcode_verifierが指定されることにあります
      • code_challengeが削除されていることを検知するために認可サーバは認可コードとcode_challengeを紐づけて管理しないといけません。このことによりトークンエンドポイントに認可コードがPOSTされたタイミングでcode_challengeとの紐付けが有効かどうかを検証することが可能となります
      • 加えて認可サーバは、認可エンドポイントへのリクエストにcode_challengeが指定されなかったにも関わらずトークンエンドポイントへcode_verifierが指定された場合はリクエストを拒否しないといけません(MUST)
  • リソースサーバでのアクセストークンの漏洩
    • リソースサーバが偽造されている場合(アクセストークンフィッシング)
      • 基本はredirect_uri(リソースサーバ)とクライアントの紐付きがゆるいケースにおいて発生します
    • リソースサーバが侵害されている場合
      • ログの盗難やシステムの完全な掌握までさまざまなパターンがありますが、リソースサーバが侵害されるとアクセストークンが盗難されてしまいます。当たり前ですが
    • 緩和策
      • 基本的にSender Contraintトークンを使うという対策に尽きます
      • 同じくAudience Restrictionも大切な対策です
      • またこれも原則ですがリソースサーバでアクセストークンは他の機密情報と同じようにPlain textで保存したり他のシステムへ転送するなどは避け厳重に扱う必要があります

今回も3つ紹介しました。
まだあと半分くらいありますね。。

2024年2月20日火曜日

さまざまな認証器でパスキー登録APIの返却値を確認する

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

OpenID Providerをパスキー対応にするためにパスキーの実装をしているわけですが、登録時のnavigator.credentials.createのAPIのレスポンスを各種認証器で確認してみました。


写真)どんどん増えていく認証器たち


色々と解せない点(黄色で色付けしたセル)も出てきているのですが、実際にパスキーの実装をする際は色々なデバイス・ブラウザ・認証器の組み合わせで試験をしないといけないのでこういうチェックはしないといけないと思います。

利用
デバイス
ブラウザ
認証器
ユーザ認証
レスポンス
TransportUPUVBEBSATED
Mac
Safari
内蔵Touch ID指紋Internal, hybrid111110
Yubikey USB-C/NFCPINnfc, usb110010
Yubikey USB-C/LightningPINusb110010
Yubikey USB-A/NFC
(PIN未設定)
-userVerificationをrequiredにしているのにPIN設定が求められず、登録できてしまう。ただしUVは0となる
eWBM GoldenGate USB-CPINusb110010
iPhoneFaceIDInternal, hybrid111110
Android指紋Internal, hybrid111110
Chrome
内蔵Touch ID指紋Internal, hybrid111110
Yubikey USB-C/NFCPINnfc, usb110010
Yubikey USB-C/LightningPINusb110010
Yubikey USB-A/NFC
(PIN未設定)
-userVerificationをdiscouragedにしてもPIN設定が求められる
eWBM GoldenGate USB-CPINoperation not allowed
iPhoneFaceIDInternal, hybrid111110
Android指紋Internal, hybrid111110
Firefox
内蔵Touch ID指紋internal111110
Yubikey USB-C/NFCPINnull110010
Yubikey USB-C/LightningPINnull110010
Yubikey USB-A/NFC
(PIN未設定)
-userVerificationをrequiredにしているのにPIN設定が求められず、登録できてしまう。ただしUVは0となる
eWBM GoldenGate USB-CPINnull110010
iPhoneFaceIDinternal111110
Android指紋internal111110
iPhone
Safari
FaceIDFaceIDInternal, hybrid111110
Yubikey USB-C/NFCPINnfc, usb110010
Yubikey USB-A/NFC
(PIN未設定)
-userVerificationをrequiredにしているのにPIN設定が求められず、登録できてしまう。ただしUVは0となる
Authntrend AT-Key/NFC指紋nfc110010
Android指紋Internal, hybrid111110
Chrome
FaceIDFaceIDInternal, hybrid111110
Yubikey USB-C/NFCPINnfc, usb110010
Yubikey USB-A/NFC
(PIN未設定)
-userVerificationをrequiredにしているのにPIN設定が求められず、登録できてしまう。ただしUVは0となる
Authntrend AT-Key/NFC指紋nfc110010
Android指紋Internal, hybrid111110
Android
Chrome
OSアンロック指紋Internal, hybrid111110
Yubikey USB-C/NFCPINble, hybrid, internal, nfc, usb110010
Yubikey USB-C/LightningPINble, hybrid, internal, nfc, usb110010
Yubikey USB-A/NFC
(PIN未設定)
-userVerificationをrequiredにするとPINが求められ、discouragedにするとPIN設定が求められず、UVが0で登録される
eWBM GoldenGate USB-CPINble, hybrid, internal, nfc, usb110010


ポイントとしては、

  • Firefoxを使うとTransportが上手く取れない
  • AndroidのChromeではcross-platform認証器のTransportがおかしい
  • userVerificationをtrueにセットしてPIN未設定のYubikeyを使うと本来はPIN設定が求められるべきだと思うがそのまま登録ができてしまうケースがある

などあるので、結局はちゃんとflagsの値を見て期待通りの認証器の状態となっているかを確認しないといけない、、ということです。