久しぶりの投稿です。
はじめに
昨今、様々なサイトがどんどんパスキーに対応しはじめてきました。
まだまだパスキーがデフォルトになっていくには時間が掛かりそうですが、どのような仕組みでパスキーを実装するのか、早めにキャッチアップしておくのも悪くないと思い、パスキーについて色々と調べてみました。
パスキーとは?
パスワードの代わりに、自分の持つデバイスによる生体認証やパターンを用いて認証を行う方法のことです。
次世代認証技術であるFIDO(Fast IDentity Onlineの略で、「ファイド」と呼びます)を使った認証方式(詳細は後述)で、Apple、Google、MicrosoftがFIDOを普及させるために命名したブランド名になります。
FIDOとは?
脆弱なパスワードは安全ではありません。
2段階・2要素認証を採用してもそれを有効にするユーザーは少なく、昨今では2段階認証を突破する攻撃も増加傾向にあります。
それなら、パスワードを無くしてしまおう(パスワードレスにしよう)といった動きがありました。
このパスワードレスに注目し、期待されるのがFIDOと呼ばれる次世代認証技術です。
FIDOはパスワードを用いないで、サーバー側に公開鍵を保存し、スマートフォンなどの自分の持つデバイス側に秘密鍵を持たせるいわゆる「公開鍵認証方式」で認証を行います。加えて、パスワードに代わってスマートフォンなどの自分の持つデバイス側で生体認証やパターンを用いて認証も行うため、いわゆるパスワードの流出などが無く安全な認証ができる技術となっています。
現在は、FIDO2が最新になっており、FIDO含めて「FIDO Alliance」と呼ばれる団体が標準仕様を決めています。
2022年にはApple、Google、Microsoftの3社がこの団体に参入しており、Appleの発表会であるWWDC 2022で「パスキー」が発表されました。
パスキーはどうやって実装する?
FIDO2には、スマートフォンなどの自分の持つデバイスをブラウザのJavaScript APIから呼べるようにした「WebAuthn API」と呼ばれる技術があります。
この「WebAuthn API」の以下を使うことでパスキーの登録・認証を行うことができます。
・ navigator.credentials.create()
: パスキーの登録で使用します。
・ navigator.credentials.get()
: パスキーを使った認証で利用します。
なお、2018年以降、主要なブラウザではすでに「WebAuthn API」が利用可能です。
「WebAuthn API」の使い方ですが、まずはパスキーを使った登録(サインアップ)の流れを見てみましょう。
- サーバーからCSRFトークンを取得する。
- WebAuthn APIの
navigator.credentials.create()
でCSRFトークン含めて(challenge)認証器へ送信する。 - 認証器側で、秘密鍵の対となる公開鍵を用意する。
- ブラウザ情報(challenge, origin情報など)と公開鍵を受け取る。
- ブラウザ情報(challenge, origin情報など)と公開鍵をそのままサーバー側へ送信する。
- サーバー側でブラウザ情報(challenge, origin情報など)と公開鍵の正当性を検証。公開鍵はサーバー側で保存する。
- 登録をOKとする。
流れとしては、上記のような感じです。
多くの場合として、公開鍵を保存時に、ユーザーの情報(サインアップ時に入力するユーザー情報)も含めて保存します。
次に、パスキーを使った認証(サインイン)の流れを見てみましょう。
流れとしては、登録とほぼ同じです。違いとしては、公開鍵がすでにサーバー側に存在するため、もう公開鍵をサーバー側に送る必要がありません。
- サーバーからCSRFトークンを取得する。
- WebAuthn APIの
navigator.credentials.get()
でCSRFトークン含めて(challenge)認証器へ送信する。 - 認証器側で、秘密鍵を使って署名する。
- 認証器の情報やRPなどの情報、署名を受け取る。
- 認証器の情報やRPなどの情報、署名をそのままサーバー側へ送信する。
- サーバー側で保存済みの公開鍵を使って署名を検証。
- 認証をOKとする。
ためしに実際に実装してみる
パスキーを使った登録(サインアップ)
1. サーバーからCSRFトークンを取得する。
これは、サーバー側で生成するCSRFトークンを利用します。
2. WebAuthn APIの navigator.credentials.create()
でCSRFトークン含めて(challenge)認証器へ送信する。
const PublicKeyCredentialCreationOptions = {
challenge: base64url.decode(csrf), // CSRF対策(ArrayBuffer型に注意)
rp: { // FIDOで言う認証器を受け入れるサイトのこと(Relying Partyの略)
name: 'passkey-sample', // RP名
id: 'localhost' // ユーザの登録・認証を行うドメイン名
},
user: {
id: base64url.decode(mail), // RP内でユーザーを一意に識別する値(ArrayBuffer型に注意)
name: 'sample', // ユーザー名
displayName: 'passkey-sample' // ニックネーム
},
pubKeyCredParams: [ // RPがサポートする署名アルゴリズム上から優先的に選択する
{alg: -7, type:"public-key"}, // -7 (ES256)
{alg: -257, type:"public-key"}, // -257 (RS256)
{alg: -8, type:"public-key"} // -8 (Ed25519)
],
excludeCredentials: [{ // 同じデバイスを複数回重複して登録させないためのパラメーター
id: base64url.decode(mail), // idがすでに登録済みであればエラーにする。
type: "public-key", //
transports: ['internal'] // 別端末をつかった認証。 他にも usb, nfc, ble, smart-cardなどがある。
}],
authenticatorSelection: { // 登録を許可する認証器タイプを制限する際に利用
authenticatorAttachment: "platform", // platform:端末に組み込まれている認証器(FaceID、生体認証など)のみを指定。cross-platform:USBやNFCなどを含めた外部端末の認証器(Yubikeyなど)のみを指定。
requireResidentKey: true, // 認証器内にユーザー情報を登録するオプション。Discoverable Credentialにするかどうか。
userVerification: "preferred" // 認証器によるローカル認証(生体認証、PINなど)の必要性を指定。 required:ローカル認証を必須。preferred:可能な限りローカル認証。discouraged:ローカル認証を許可しない(所有物認証)
},
}
// 認証器へ送信
const cred = await navigator.credentials.create({
publicKey: PublicKeyCredentialCreationOptions,
})
3. 認証器側で、秘密鍵の対となる公開鍵を用意する。
認証器側で持つ秘密鍵の対となる公開鍵を生成します。
4. ブラウザ情報(challenge, origin情報など)と公開鍵を受け取る。
await navigator.credentials.create()
から以下のようなレスポンス結果が得られます。
{
authenticatorAttachment: "platform",
id: "xxxx",
rawId: ArrayBuffer,
response: {
attestationObject: ArrayBuffer, // 公開鍵が格納されている
clientDataJSON: ArrayBuffer, // ブラウザ情報(challenge, origin情報など)
}
type: "public-key"
}
5. ブラウザ情報(challenge, origin情報など)と公開鍵をそのままサーバー側へ送信する。
4.のレスポンス結果をそのままサーバー側へ送信します。
response
の attestationObject
や clientDataJSON
に何が含まれているかは、以下記事がとても参考になりました。
https://engineering.mercari.com/blog/entry/2019-06-04-120000/#AttestationObject-のフォーマット
6. サーバー側でブラウザ情報(challenge, origin情報など)と公開鍵の正当性を検証。公開鍵はサーバー側で保存する。
サーバー側で4.のレスポンス結果を受け取り、検証します。
とはいえ、自力でやるととても大変なので、Node.jsの場合であれば、SimpleWebAuthnというライブラリを使えば簡単にできます。
const expectedChallenge = req.session.csrfToken;
const expectedOrigin = 'http://localhost:3000';
const expectedRPID = 'localhost';
const verification = await verifyRegistrationResponse({
response: req.body, // 4.のレスポンス結果の検証
expectedChallenge, // challengeの検証
expectedOrigin, // originの検証
expectedRPID, // RPIDの検証
});
const { verified, registrationInfo } = verification;
if (!verified) {
throw new Error('User verification failed.');
}
verifyRegistrationResponse()
のレスポンスの結果として、 verified
と registrationInfo
が取得できます。
verified
には、検証が成功したか失敗したかが boolean
で入っています。
registrationInfo
には、以下情報が格納されています。
-
credentialID
: 資格情報の一意の識別子(bytes) -
credentialPublicKey
: 公開鍵バイト。後続の認証署名の検証に使用されます。(bytes) -
counter
: これまでにこのサイトで認証が使用された回数(number)
そのため、基本的に registrationInfo
をそのままDB等に保存して、以降は認証時にこの公開鍵を使用します。
7. 登録をOKとする。
上記のverified
が true
でregistrationInfo
の情報をDB等に保存できたら、登録をOKとします。
パスキーを使った認証(サインイン)
1. サーバーからCSRFトークンを取得する。
登録の時と同様。サーバー側で生成するCSRFトークンを利用します。
2. WebAuthn APIの navigator.credentials.get()
でCSRFトークン含めて(challenge)認証器へ送信する。
const publicKeyCredentialRequestOptions = {
challenge: base64url.decode(csrf), // CSRF対策(ArrayBuffer型)
allowCredentials: [], // 認証時に利用可能なPublicKeyCredentialを限定させることができる。
userVerification: "preferred" // 認証器によるローカル認証(生体認証、PIN入力など)の必要性を指定。
}
const cred = await navigator.credentials.get({ // ここでもPublickKeyをもらう
publicKey: publicKeyCredentialRequestOptions,
mediation: 'optional'
})
3. 認証器側で、秘密鍵を使って署名する。
認証器側で秘密鍵を使って署名します。
4. 認証器の情報やRPなどの情報、署名を受け取る。
await navigator.credentials.get()
から以下のようなレスポンス結果が得られます。
{
authenticatorAttachment: "platform",
id: "xxxx",
rawId: ArrayBuffer,
response: {
authenticatorData: ArrayBuffer, // 認証器の情報。
clientDataJSON: ArrayBuffer, // challengやRPの情報
signature: ArrayBuffer, // 秘密鍵によって署名されたデータ
}
type: "public-key"
}
5. 認証器の情報やRPなどの情報、署名をそのままサーバー側へ送信する。
4.のレスポンス結果をそのままサーバー側へ送信します。
response
の authenticatorData
や clientDataJSON
、 signature
に何が含まれているかは、以下記事がとても参考になりました。
https://engineering.mercari.com/blog/entry/2019-06-04-120000/#Assertion-の検証
6. サーバー側で保存済みの公開鍵を使って署名を検証。
const expectedChallenge = req.session.csrfToken;
const expectedOrigin = 'http://localhost:3000';
const expectedRPID = 'localhost';
const verification = await verifyAuthenticationResponse({
response: req.body, // 4.のレスポンスの検証
expectedChallenge, // challengeの検証
expectedOrigin, // originの検証
expectedRPID, // RPIDの検証
authenticator: {
credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey), // DBに保存した公開鍵
credentialID: isoBase64URL.toBuffer(cred.id), // DBに保存した資格情報の一意の識別子
counter: cred.counter, // DBに保存した認証が使用された回数
},
requireUserVerification: false,
});
const { verified, authenticationInfo } = verification;
if (!verified) {
throw new Error('User verification failed.');
}
const { newCounter } = authenticationInfo;
verifyAuthenticationResponse()
のレスポンスの結果として、 verified
と authenticationInfo
が取得できます。
verified
には、検証が成功したか失敗したかが boolean
で入っています。
authenticationInfo
更新された counter
つまり、更新された認証が使用された回数の情報が格納されています。
これを、再度DBに保存し直します。
7. 認証をOKとする。
上記のverified
が true
なら、認証をOKとします。
さいごに
まだまだパスキーの情報が少なく、私が書いた記事も、もしかすると間違いがあるかもしれません。
しかし、昨今の「Google ChromeでiCloudキーチェーンに対応し、パスキー情報もiCloudキーチェーンに保存できるようになる」「Googleアカウントで「パスキー」が標準になった」などのニュースが続き、パスキーの流れが止まりません。。。
私自身も、まだまだパスキーについて学習不足なので、色々と情報収集に努めたり、実際に実装してみたり等、挑戦してみようと思います。