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と同じ様に比較をしていきます。

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

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

0 件のコメント: