2024年3月1日金曜日

パスキーでログインを実装する(検証編)

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

前回の続きです。前回はログイン処理の前段部分まで実装できたので今回はcredential.get()の結果の検証を行います。

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


検証の大きな流れは、以下の通りです。
  • challengeの検証
  • originの検証
  • credentialIdをキーに保存済みの公開鍵をDBから取得
  • verificationDataの生成
  • 公開鍵とsignatureを使ってverificationDataの署名を検証
と、例によってCBORのデコードやバイナリの切り出し、公開鍵の変換(DER→COSE-Key)など本質的ではないところの面倒臭い処理があるので生で実装するのはやめました。

結論はsimplewebauthnのライブラリを使うことになるのですが、その前にざっくりやるべき処理だけ理解しておきたいと思います。(署名検証部分)

  • 鍵の変換
    • パスキー登録の時にレスポンスから取得できるPublicKeyはDER形式なのでcoseに変換する必要があります
    • 変換した鍵の中身を使って以下のBufferを連結して検証用の鍵を作ります
      • 0x04
      • 鍵のx
      • 鍵のy
  • VerificationData(検証対象とするデータ)の生成
    • 以下のBufferを連結して検証対象のデータを生成します
      • 0x00
      • rpIdHash(登録時にも使ったrpIdの検証用のHashと同じ値)
      • clientDataHash(clientDataJSONのSHA256ハッシュ値)
      • credentialId
      • publicKey
結構面倒くさいです。

ということでsimplaewebauthnのライブラリを使いました。
// 検証
const result = await simpleWebAuthn.verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: req.hostname,
authenticator: authenticator
});

超簡単です。
それぞれパラメータを解説していきます。
  • response
    • nagivator.credential.get()の結果取得できるpublicKeyCredentialをtoJSON()したオブジェクトをそのまま渡します
  • expectedChallenge
    • sessionに保存してあるchallengeを取り出してセットします
  • expectedOrigin
    • アクセスしているサイトのoriginをセットします。以前解説した通りngrokを使っている関係でschemeは工夫しています
// originの生成
const scheme = req.hostname !== 'localhost'? "https" : req.protocol;
const origin = url.format({
protocol: scheme,
host: req.get('host')
});
  • expectedRPID
    • ホスト名をつかっています
  • authenticator
    • credentialIdをキーにデータベースを検索し取得した公開鍵と署名回数のデータを含むオブジェクトを生成します(JSONBinに保存しているのでレスポンスの中に公開鍵、署名回数が含まれます)
// authenticator
const authenticator = {
credentialPublicKey: base64url.toBuffer(JSONBinResponse.publicKey),
credentialID: base64url.toBuffer(credentailId),
counter: JSONBinResponse.signCount
};

これでおしまいです。
検証結果(verified)がtrue/falseで返ってくるので認証OK/NGで処理を振り分けましょう。また、署名回数が前回よりも増えていることの確認をしておくことで認証器の偽造にも対応できます。(MacOSのTouchIDの署名回数は0のままなので署名回数の検証はできません)
if(result.verified) {
// 検証成功
// 検証回数が増えているかどうか(0の場合以外)
if(result.authenticationInfo.newCounter !== 0) {
if(result.authenticationInfo.newCounter <= JSONBinResponse.signCount){
console.log("error signCount is not increased");
}
}
// UVが行われているかどうか
if(!result.authenticationInfo.userVerified) {
console.log("user was not verified");
}

こんな感じで認証結果が表示できるように作りました。


と、ここで疑問が湧いてきます。
対象ユーザが正しいかどうかの検証ってしなくていいの?ログイン時にパスワードはもちろんユーザ名も入れてないけど、って話です。
今回の実装ではResidentKeyで認証器側でユーザ情報を持っている前提で組んでいるので、そうではない環境を想定するとユーザ名を入力させて認証はパスキー、という動線も作らないといけません。また、Credential IDだけを使って公開鍵をDBから取得していますが、ちゃんとユーザハンドルとDB内にCredential IDと紐付けて保存されているユーザIDが一致していることの確認をしないといけません。
ということで公開鍵をユーザDBから取得するところのロジックはこんな感じで実装していきます。
}
// credentialIdをキーにPublicKeyとCounterを取得する
// userHandleが一致していることを検証する
exports.getPublicKey = async function(credentialId, userHandle) {
// JSONBin用のヘッダ
const headers = new Headers({
"X-Master-Key": process.env.JSONBIN_MASTER_KEY,
"Content-Type": "application/json"
});
// JSONBinのユーザCollectionからユーザbinのidを取得する
const collectionUrl = new URL(`${process.env.JSONBIN_BASEURL}/c/${process.env.JSONBIN_PASSKEYCOLLECTION_ID}/bins`);
const collectionResponse = await fetch(collectionUrl, {
headers: headers
});
const passKeyCollection = await collectionResponse.json();
const passKeyBin = passKeyCollection.find(i => i.snippetMeta.name === credentialId);
if(typeof passKeyBin === "undefined"){
console.log("passKey not found");
return {
result: false,
error: "passKey not found"
}
}else{
// 当該パスキーのBin idからBinの中身を読み出す
const passKeyBinUrl = new URL(`${process.env.JSONBIN_BASEURL}/b/${passKeyBin.record}`);
const passKeyBinResponse = await fetch(passKeyBinUrl, {
headers: headers
});
const passKeyJson = await passKeyBinResponse.json();
console.log(passKeyJson);
// ユーザIDが正しいか検証
console.log("userhandle to be compared: " + userHandle);
console.log("expected userhandle : " + passKeyJson.record.userId);
if(userHandle !== passKeyJson.record.userId) {
// ユーザ名が異なる
return {
result: false,
error: "different user handle"
}
} else {
// ユーザ名一致
return {
result: true,
username: passKeyJson.record.username,
publicKey: passKeyJson.record.publicKey,
signCount: passKeyJson.record.signCount
}
}
}
}

まあ、もちろん前提として本来は登録時にCredential IDが重複していないことをチェック、かつユーザIDとの紐づけた上で登録する、というロジックを入れておかないと安全にはならないのでプロダクションで実装する人は注意しましょう。

雑ではありますが一通りパスキーの実装が理解できたので、そろそろ元々のOpenID Provider作りを再開していこうかと思います。



0 件のコメント: