🔐

パスワードはおしまい! 認証はパスキーでやろう

2024/01/20に公開
3

はじめに

パスワードは古来より認証に良く使われる方法ですが、その運用の難しさからセキュリティの懸念とその対策としての運用の複雑さ(複雑で長い文字列、90日でパスワード変更など)が要求される大きく問題をもった仕組みです。

その根本的な解決策としてFIDO Allianceを中心に推進されている 「パスワードレス」 が注目されています。これはPINや生体認証とデバイス認証を使ったMFAからなっており、フィッシングやパスワード流出に強い上に、ユーザも複雑なパスワードを覚えなくて良い、という大きなメリットがあります。最近はこの流れでPassKeyというものが登場し、Apple/MS/Googleのプラットフォーマが対応したことで、本格運用に乗せれるフェーズになってきました。というわけで以下に解説動画を作ったのですが、動画中で時間の都合で触れきれなかったところや、JavaScriptによる実装のサンプルを記事にしたいと思います。
https://www.youtube.com/watch?v=y6jPpuZe37s

なお、前半はパスキーの概念の解説なので、すでに知ってるよ、という人は実装編までジャンプ!

注意事項

  • この記事に利用しているコードはサンプルです。セキュリティの懸念があるのでそのまま本番に利用しないでください
  • 本文にも書いてますが認証は可能な限り自分で実装を避けましょう

TL;DR

  • PasskeyはパスワードレスなWebアプリを作るための仕様
  • 認証器にクレデンシャルを保存し、サイトとはチャレンジレスポンスで認証
  • 原理的にフィッシングサイト等に強く、利用も簡単
  • JSで比較的簡単に実装できる

パスキーとは?

パスワードの様々な課題

長さと複雑さ

パスワードは実装も簡単なので古くから使われている認証方式です。パスワードのセキュリティとしてまず思いつくのが「長さと複雑さ」ですよね? NIST SP800-63では最低8桁、NISCでは最低10桁としています。実際、以下に引用する表のように6桁以下のパスワードは複雑であっても瞬時に解かれてしまいます。

ref: https://www.lrm.jp/security_magazine/secure_password/
通常のWebサイト等の運用ではそもそもその回数の試行をすればロックがかかるので、必要な回数は試せないでしょうが、パスワードには十分な長さが要求さる事は良く分かります。複雑さも大事ですが長さが無いと意味無いので注意をしましょう。

辞書攻撃

こうしたブルートフォース攻撃に備えて十分な長さは必須ですが、それだけでは不十分です。例えば8桁で大文字小文字記号を含む「1qaz@WSX」は、一見複雑で十分な長さですが、果たして強固なパスワードでしょうか?

実際これをパスワードの強度チェックサイトで調べr手見ると以下のように脆弱と判定されます。

ref: https://password.kaspersky.com/jp/

これは以下のように英語キーボードで考えれば分かりやすいのですがキー入力順なんんですね。 「くぁwせdrftgyふじこlp」 みたいなもんです。

こうしたキー入力に依存したパスワードは 辞書攻撃 で簡単に解かれます。以下は2021年の「SMTP Auth 攻撃 Top 100」です。
https://vwnet.blob.core.windows.net/smtpauthattack/Attack_Pass_Top100(2021).htm
「password」みたいな超典型なものはもちろん「P@ssw0rd」みたいなひねったつもりのやつも辞書に載っているので即座に解かれてしうのが分かりますね。もし、これにリストアップされてるパスワードを使っているなら鍵をかけてないのと同義と理解をしましょう。複雑なパスワード要件を満たしつつ人間が覚えやすいからつい使っちゃいますが、つまり狙われやすいということでもあります。

パスワードリスト攻撃

ブルートフォースにも辞書攻撃にも耐えれる、長く複雑で類推困難なパスワードを作れたとしましょう。しかし、まだ課題はあります。それは人類の記憶力と怠惰です。こうしたパスワードを覚えるのは大変なので、ついつい複数のサイトで同じものを利用したくなりませんか? しかし、そうするとどこかセキュリティの弱いサイトからパスワードが漏れてしまうと、例えば使いまわしている銀行やECサイトなど重要なサイトのパスワードも同時に漏れることになります。そもそもフィッシングサイトの可能性もありますよね? パスワードは流出するものなのです。

このような攻撃をパスワードリスト攻撃と呼びます。以下のサイトやChromeやFirefoxのパスワードマネージャを使えば、どのサイトへのパスワードが既に流出しているかを検知することができます。
https://haveibeenpwned.com/
おそらく、1つくらいは流出が見つかったのでは? 正直、サイト管理者の視点としては 「攻撃者は正しいIDとパスワードの組み合わせを知っている」 という事が前提になります。そのくらいパスワードの管理は難しいのですね。

パスワードをまともに運用するには、複雑で予測不可能なパスワードをすべてのサイト別々で設定する必要があります。理論上はセキュアな運用をパスワードでも実現できますが、それは人類にはあまりにも難しい運用です。せめて各種ブラウザやLastPass1Passwordのようなパスワードマネージャの利用が必須ですね。

従来的なMFAの課題

MFAとは?

もはや問題しかないパスワードなので、近年のセキュリティでは少なくともインターネットに面した部分でパスワードのみの運用はご法度です。NIST SP800-63をはじめ多くのスタンダードでもMFAの利用を推奨しています。

MFAは知識要素/所有要素/生態要素といった異なる要素を2つ以上組み合わせる認証方式です。

最近よく利用されるのはパスワードといった従来の知識要素に加えて、SMSを使ったOTPや、GoogleやMicrosoftの認証アプリを使い所有要素チェックします。

ref: https://atmarkit.itmedia.co.jp/ait/articles/1411/26/news120.html

これらは電話番号やスマートフォンといった所有要素を利用するので、インターネット越しの攻撃に対して非常に強固です。現代で最も警戒するべきはインターネット越しの攻撃なのでセキュリティは大幅に強化されます。

OTPの課題1 - めんどくさい

OTPの課題はまず「めんどくさいこと」ですw
各サイトを登録するのはQRコードなので良いとして、ログインの度にスマホの認証アプリを起動したりて対象のサービスを探して数字を覚えて、PC等で入力するのは中々に面倒です。あるいはSMSとかで届くのをまってコピーしたいですね。

この めんどくさい というのは馬鹿にしたものではなくて、こうした手間からユーザの利用が中々進まないという問題があります。不便なもの安全でも普及しないのです。AzureADやGoogle認証、あるいはOktaのようにPushに対応するケースが増えれば良いのですが、これはあんまり普及が進んでないのが現状。

OTPの課題2 - 高度なフィッシングサイト

所有要素を絡めたMFAはインターネットからの攻撃に非常に強固と言いましたが、実は突破されるケースもあります。それがAdversary in the middle(AiTM攻撃) です。

ref: https://www.cybertrust.co.jp/blog/certificate-authority/client-authentication/aitm-phishing-and-mfa.html
簡単にいうとフィッシングサイトが実際のサイトに最初のパスワード認証とかを本物サイトにパススルーするため、本物のサイトから発信されたSMSやPush配信にOKをしてしまうため、偽サイトから本物のサイトへの認証が成功してしまう攻撃です。TOTPの場合はもっと単純にパスコードをそのまま本番にフィッシングサイトが渡せば攻撃が成功していまいますね。

それなりにフィッシングサイトを作りこむ必要はありますが、ユーザが見破るのは中々に困難なので、MFAであっても注意が必要な攻撃となっています。

パスワードと流出 - 管理者の視点

さて、少し視点を変えて利用者ではなく、サイト管理者の視点を考えてみましょう。あなたは自分でサービスを作り運営しているとします。SNSでもECでもその他でも何でも良いですが、ユーザ登録が必要な事も多いですよね? そうなると当然あなたが管理するサーバにはパスワード等の秘密の情報(むろん平文ではなく最低限ハッシュ化されてるはずですが)が保存されることになります。これが万一にも流出してしまうと大変ですよね? Yahooニュースに載ってしまうかもしれません! という分けで多くのセキュリティ対策は必須です。
XSSやSQLインジェクションなどのアプリ側の対策は万全ですか? 利用しているOSやミドルウェアのパッチは最新化されてる? リモートアクセスにはMFAが適用されていますか? アンチマルウェアはサーバにもPCにも入れてる? EDRはどう? WAFは導入済み? 監査ログはとってる? SIEMは入れてる? SOCは設立されてる? そもそもガバナンスやインシデントレスポンスのフローは整備済み?

という感じで様々なセキュリティ対策が要求されお金も相応に必要になります。まあ、もちろん個人情報を持ってる時点でパスワードだけを殊更に重要視する必要はないかもしれませんし、現実として「出来る範囲で」になるわけですが、どんなに小さなサイトでもユーザ登録があれば持たざる得ないのでサイト管理者としても厄介な情報、それがパスワードなんです。ちゃんと管理しないといけないので。

Passwordlessという考え方

こうしたパスワードの様々な問題に対しての根本的な対策パスワードを使わないという選択、すなわちパスワードレスです。

パスワード以外の認証方式というと、古くから、チャレンジ/レスポンス認証が使われています。これは基本情報技術者試験とかにも載ってるくらい有名な認証方式ですね。特に公開鍵を利用した公開鍵認証(あるいはその発展形)はSSHなど身近なところでもよよく利用されています。

秘密鍵と公開鍵はペアになっており、ある公開鍵で暗号化された内容はペアの秘密鍵でしか復号出来ません。その性質を利用して、あらかじめ公開鍵をサーバ側に渡しておき、ログイン時にサーバはまずランダムな文字列(チャレンジ)をクライアントに渡します。クライアントは渡されたチャレンジを自分の秘密鍵で暗号化してサーバに返し、サーバ側は保持してる公開鍵で複合した文字列と自分が渡した文字列を比較することで認証を行います。この認証方式のポイントは機密情報がクライアントから外に出ないことです。ネットワーク上にもサーバ上にも秘密の情報は流れないので原理的に安全ですし、ユーザもスマートフォンなどを認証器として利用出来るようになったため、普及の目途が立ちました。

なお、「MFAの次の技術!」 みたいな言われ方をすることもありますが、厳密にいえばMFAの一種と捉える方が正確でしょう。

たんに従来的なMFAが 「パスワード+所有要素 or 生体要素」 だったのに対し、パスワードレスと呼ばれるものは 「所有要素 + 生体要素 or 知識要素(PIN)」 という実装の違いですね。

FIDO/WebAuthn/PassKey

FIDO2

FIDO2はパスワードレス認証の標準化を行うFIDO Allianceが策定した認証規格です。それまでにも色々規格があったのですがこれが現在の決定版ですね。

ref: https://fidoalliance.org/仕様概要/?lang=ja

例えばWindows Helloを利用すれば指紋やWebカメラ、あるいはPINコードでログインできますが、これもFIDO2に準拠となります。

Windows 11のCPUがかなり最新のものしか対応しなかった理由にTPM2.0のサポートの必須化があります。TPM (Trusted Platform Module) とは通常のメモリやSDDとは違うセキュアな保存領域にもなるチップで、例えばWindows Helloはここに秘密鍵を保存します。このような機能は多くのセキュリティの基礎になるのでAndroidやiPhoneなどのスマートフォン向けのSoCの中にもセキュリティチップが搭載されてきています。

ref: https://josysnavi.jp/2019/josys-bk-now_bitlocker-tpm_190125

FIDO2は包括的なパスワードレスの仕様なので、WindowsなどのOSやアプリへのログインなど多くのシーンを想定したものですが、その中でブラウザとWebアプリの利用に特化した仕様がWebAuthnです。

コメントで教えていただきましたが、FIDO, FIDO2, WebAuthnの関係は以下のように整理するのが良いそうです。※ritouさんありがとうございます。

  • FIDO : 公開鍵暗号方式 + ローカル認証を組み合わせた認証方式
  • FIDO2 : FIDOをWebアプリケーションでも利用可能にするための規格であり、以下の2つにより実現される
    -- WebAuthn : RPが利用するブラウザAPI のこと
    -- CTAP(図中ではCTPAと表記されていました) : ブラウザとAuthenticatorの間のプロトコル

WebAuthn

WebAuthnはFIDO認証を行うクライアントをブラウザにさせることで、サーバサイド(RP)とJSを実装するだけで簡単にパスワードレスを実現出来る仕組みです。ややこしい認証器とのやり取りをブラウザが行ってくれるため比較的簡単に安全な認証を作ることができます。

以下のサイトにあるようにブラウザCredentialsContainer APIを使ってYubiKeyやスマホなどの認証器とやり取りをし、サーバ側にはREST認証情報をやり取りします。やりとりするのはあくまで公開鍵やサーバの情報など機密性の低いものしかないのが安心ですね。
https://developer.mozilla.org/ja/docs/Web/API/Web_Authentication_API

WebAuthnの特徴としては以下を上げることができます。

  • パスワードリスト攻撃に強い(=流出に強い)
  • ユーザもサイト管理者も管理の手間が少ない
  • フィッシングに強い

すでに何度も述べている通りWebAuthnはチャレンジレスポンスをベースとしたパスワードレス認証なので、原理的にパスワードリスト攻撃を気にする必要がありません。ユーザも複雑なパスワードをサイト毎に管理する手間がなくなりますし、サイト管理者もパスワード流出の恐怖に怯える必要が無くなります。

これだけではなく、もう一つの重要な点として 「フィッシングに強い」 というものがあります。先程話したAiTM攻撃はもちろんですが、最近のフィッシングサイトは巧妙になっており見かけや個人の注意で判断することは困難です。WebAuthnでは登録してあるRP ID(ドメインやオリジン)と公開鍵が一致していることを確認し、かつRP が認証要求に指定したRP IDと現在アクセスしている環境が一致しているかどうかを検証されるので、「適切なサイトか否か」 を機械的に区別出来るのが大きなメリットです。(コメントでritouさんに補足を頂きました。ありがとうございます。)

ref: https://www.yubion.com/post/パスキー(passkeys)ってなんだろう?(前編)

PassKey

先ほどからWebAuthnという謎の言葉を使っていますが、表題にもある 「PassKey」 というものならよく聞く、という人は結構いるんじゃないでしょうか? こんなニュースもよく目にします。こちらもパスワードレスの仕組みとして紹介されるので混乱しますよね。
https://www.techno-edge.net/article/2023/10/24/2133.html

実はPassKeyはWebAuthnの次世代規格というかある種の改良型の仕様となります。
WebAuthnでは認証情報認証器にしかないため、例えばYubiKeyを紛失したり、スマホの機種変更の時にクレデンシャルを移行できず少し面倒です。合鍵のように複数の認証器を事前に登録していたり、パスワードなどの別の認証方法があれば良いのですが、それもなかなか難しい。というわけで、そうした情報をクラウドにおいてしまえば良いのでは? という発想が従来のPassKeyです。ただ、最近はWebAuthnと別の呼び方も紛らわしいので、従来のWebAuthnも含めてPassKeyと呼び、狭義のPasskeyとしてローカルな認証器の代わりにクレデンシャルを保存するクラウドをマルチデバイスFIDOクレデンシャル(MDC) とも言います。

ref: https://www.yubion.com/post/パスキー(passkeys)ってなんだろう?(後編)

このMDCとしてGoogleやApple、MicrosofotのようなOSのプラットフォーマーや1Passwordのようなパスワードマネージャベンダが進めています。まだ発展途上でOSを超える場合は結局別のパスキーを登録したほうが便利な場合も多いですが、パスワードや従来型MFAよりは確実に便利なので十分に実用の段階に入ってきたと考えます。

なお、原理的にMDCには秘密情報が保存されてしまうため、ここがハッキングされるリスクを考えると狭義のWebAuthnに比べると狭義のPassKeyはセキュリティで劣ります。ただ、少なくともOSプラットフォーマー達はすでに多くのユーザ情報を持っており、それを守るために多大な投資をしていますので非常にセキュリティは高いです。また、下記に記載されてる通り暗号化して保存されています。
https://developers-jp.googleblog.com/2022/11/security-of-passkeys.html
なので、実質的なセキュリティとしては問題ないかと思います。パスワードリスト攻撃のように弱いところから漏れる、という類のものではないですし。

パスワードリスト攻撃 フィッシングサイト クレデンシャル流出 ユーザ利便性 サイト管理者のコスト
パスワード
MFA(パスワード + OTP)
WebAuthn
パスキー

ちなみに、こうした狭義のPassKeyとは別に従来のWebAuthnも含めてPassKeyと呼ぶ形に最近は変わったようです。まあ、利用者からすると混乱しますしね。ちょっと注意は必要ですが、遠からず狭義のPassKeyに全体が寄る気もするので現時点だけの混乱の気もします。

パスキーを自分のサービスに導入するには?

さて、概念的なことが分かったので、早速どのようにして自分のサイトに組み込むかを考えていきたいと思います。実装方法はざっくりと分けると以下の3つです。

  • SNS認証
  • IDaaS
  • 自前の認証

SNS認証と便宜上呼んでるものはOIDCなどを使ってGoogleやX(旧Twitter)の認証機能を利用するものです。大手ベンダーがたくさんの投資をかけて行っている認証メカニズムなのでセキュリティ上も非常に安心ですしユーザとしても手間が少ないです。GoogleやGitHubなどPassKeyに対応したサービスもあるので、そちらを使うことでパスワードレスを実現できます。

IDaaSはOktaやAzureAD、あるいはAuth0やFireabase認証など使う方式です。SNS認証は便利なのですが、企業ブランディングとして採用しづらいケースもであります。また、完全にGoogleなど外部サイトに依存してしまうので「パスワードは利用せずにパスワードレスのみにしたい」などのコントロールを自社でやることも出来ません。そのためこれらのサービスを使うのは非常に有用です。特にOktaやAzureADはリスクベース認証やConditional Accessなど非常に強力で柔軟なセキュリティ機能も持っていますし導入も簡単です。コンシューマ向けのサービスであればAuth0が最近PassKeyに対応したので、こちらを利用するのが良いかと思います。こちらの記事とかに解説がありました。
https://zenn.dev/maronn/articles/passkey-in-auth0

最後に自前認証です。IDaaSなどのクラウドがどうしても利用出来ない時には自前で実装する方法が考えられます。クライアントサイドはJSを普通に叩いても良いですし、HANKOSimpleWebAuthnなどを使うことで比較的簡単に実装が出来ます。

ただ、個人的には認証周りは気を付ける事も多いですし、可能ならSNS認証やIDaaSを使えるなら使うことを推奨します。リスクベース認証とか強力なセキュリティもつくしね。

パスキーを実装してみよう!

前置き

個人的なおすすめは前述の通りSNS認証/IDaaSの利用なのですが、理解のためには自分で簡単な実装をしておくことも重要なので、実際に書いたコードを解説していきたいと思います。

今回作ったコードは以下にあります。
https://github.com/koduki/example-passkey

クライアントサイドはなるべく素のJSで書いて、サーバ側は検証など一部面倒なところはSimpleWebAuthnを使いつつも、基本的にはJSをそのまま使って、仕様のイメージがしやすいようにしました。ただし、コードを単純化するために例外処理やセキュリティなどは意図的に省いて書いてあるので、本番環境にそのまま適用にするのは避けてください。あくまで勉強用ないしは参考程度で。

アカウント登録を実装する

まずはアカウント登録のためのページを作成します。import先の内容は後程解説しますが、入力フォームとしてアカウントIDを受け取り、サインアップボタンでをクリックするとcreateUser(userName)というメソッドでサーバに作りたいユーザの情報を渡しています。
WebAuthn/PassKeyはあくまで 認証のための仕組 であってユーザ管理のための仕組みでは無いので、通常のパスワード登録の部分以外は普通のユーザ作成フローを済ませておく必要があります。
今回はアカウントIDしか登録項目がありませんが、他にもメールアドレスとか住所とかプロフィール情報を格納したいケースもあるかと思います。また、メールアドレスを登録する場合はこちらも従来通り本人のメールアドレスかを確認するために、メールを利用したワンタイム認証を挟むのを忘れないようにしましょう。とりあえず以下のような画面を作ります。

<body>
    <header>
        <h1>Passkey Example - SignUp</h1>
    </header>
    <div class="container">
        <p><input id='txtName' type="text" placeholder="アカウントID"></input></p>
        <p><button id="btnSignup">サインアップ</button></p>
    </div>

    <script type="module">
        import { createUser, registCredential } from './static/regist_helper.js';

        document.querySelector('#btnSignup').addEventListener('click', async () => {
            const userName = document.getElementById('txtName').value;
            console.log(await createUser(userName));

            const registResp = await registCredential();
            console.log(registResp)
            if (registResp.ok) {
                console.log("signup:success");
                location.href = "index.html"
            } else {
                console.log("signup:fail");
            }
        });
    </script>
</body>

続いて処理はregistCredentialに移ります。こちらがデバイス登録の本体を実装したコードになっています。フローにするとこんな感じですね。

registCredentialの中身は以下のようになっています。

export async function registCredential() {
    // RPからサイト情報や認証方式、チャレンジを取得
    const registOptionsResp = await fetch('/generate-registration-options');
    const registOptions = await registOptionsResp.json();

    // 認証器へ送信して、鍵を生成してRP情報と共に保存
    const credential = await navigator.credentials.create({
        publicKey: buildOptoinsForRegistDevice(registOptions),
    });

    // 暗号化したチャレンジや公開鍵など認証器の戻りをRPに送信して検証して、RPに登録
    const verificationResp = await fetch('/verify-registration', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(buildOptionsForRegistPR(credential)),
    });
    return verificationResp;
}

まず、RPにオプションの要求をします。これはどのような情報をデバイスに格納する必要があるかをRPサーバに問い合わせる処理でchallenge, rpName, rpId, userの情報からなるJSONが返されます。サーバサイドは以下のような実装になっています。

app.get('/generate-registration-options', (c) => {
    const user = users[c.get('session').get("currentUserId")];
    const options = buildRegistrationOptions(rpName, rpID, user);
    c.get('session').set('currentChallenge', options.challenge);

    return c.json(options);
});

function buildRegistrationOptions(rpName: string, rpId: string, user: any) {
    return {
        challenge: isoBase64URL.fromString(genRandomStr(32)),
        rp: { name: rpName, id: rpId },
        user: {
            id: user.id,
            name: user.username,
            displayName: user.username
        }
    };
}

事前にセッションに格納しておいたユーザ情報とRPの情報、そしてチャレンジを返します。チャレンジの値は何でもかまいませんが、推測できないように十分な長さを持つランダムな値が望ましいです。また、ここで作成したチャレンジは、この後の検証フェーズでも利用するのでセッションに格納しておきます。

それでは、クライアントサイドに戻って、この情報を元に以下のようにデバイスの登録情報を作成します。内容としては登録するRPやユーザの情報、そして利用できる署名のアルゴリズムや認証器のタイプを指定します。

function buildOptoinsForRegistDevice(registOptions) {
    // 認証器へのパラメータを初期化
    return {
        challenge: base64url2ab(registOptions.challenge), // ArrayBuffer型に注意
        rp: { // FIDOで言う認証器を受け入れるサイトのこと(Relying Partyの略)
            name: registOptions.rp.name, // RP名
            id: registOptions.rp.id // ユーザの登録・認証を行うドメイン名
        },
        user: {
            id: s2ab(registOptions.user.id), // RP内でユーザーを一意に識別する値(ArrayBuffer型に注意)
            name: registOptions.user.name, // ユーザー名
            displayName: registOptions.user.displayName // ニックネーム
        },
        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: s2ab(registOptions.user.id), // 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:ローカル認証を許可しない(所有物認証)
        },
    }
}

この際、いくつかの項目はテキストやBASE64URLをArrayBuffer(=バイト配列)に変換してやる必要があるので注意が必要です。この情報をブラウザのnavigator.credentials.create APIを使ってデバイスの登録を行います。例えばWindows PCであればこんな感じでWindows Helloの画面が出てくるはずです。こちら登録すればパスキーがRPに対して作成され認証器に秘密鍵等も生成されます。

次に認証器の秘密鍵を使ってオプションで受け取ったチャレンジを署名にして、公開鍵と共にRPサーバに送ります。

RP側で公開鍵を使って署名からチャレンジを生成し、それがサーバ側で保持しているものと同一であることを確認します。クライアント側のコードは以下の通り。

// 暗号化したチャレンジや公開鍵など認証器の戻りをRPに送信して検証して、RPに登録
const verificationResp = await fetch('/verify-registration', {
	method: 'POST',
	headers: { 'Content-Type': 'application/json' },
	body: JSON.stringify(buildOptionsForRegistPR(credential)),
});
....
function buildOptionsForRegistPR(credential) {
    return {
        id: ab2base64url(credential.rawId),
        type: credential.type,
        rawId: ab2base64url(credential.rawId), 
        response: {
            clientDataJSON: ab2base64url(credential.response.clientDataJSON),
            attestationObject: ab2base64url(credential.response.attestationObject)
        }
    };
}

サーバ側は以下のようになります。セッションからオプションを返したときに生成したチャレンジを取得し、こちらとクライアントから渡された値を検証することで、正しいレスポンスであることを確認しています。なお、クライアントからはバイナリ形式で渡されるので解析を簡単にするためにSimpleWebAuthnのverifyRegistrationResponseをここでは使っています。

app.post('/verify-registration', async (c) => {
    const body: RegistrationResponseJSON = await c.req.json();
    const user = users[c.get('session').get("currentUserId")];
    const expectedChallenge = c.get('session').get('currentChallenge');
    const verified = verifyRegstration(user, body, expectedChallenge, rpID, expectedOrigin);

    c.get('session').set('currentChallenge', '');
    return c.json({ verified });
});

async function verifyRegstration(user: any, body: any, expectedChallenge: string, rpId: string, expectedOrigin: string) {
    let verification: VerifiedRegistrationResponse;
    const opts: VerifyRegistrationResponseOpts = {
        response: body,
        expectedChallenge: `${expectedChallenge}`,
        expectedOrigin,
        expectedRPID: rpId,
        requireUserVerification: true,
    };
    verification = await verifyRegistrationResponse(opts);

    const { verified, registrationInfo } = verification;

    if (verified && registrationInfo) {
        // 中略
    }

    return verified;
}

検証が成功した場合は、ユーザ情報に新規デバイスとして登録します。今回は簡略化のためにセッションのユーザ情報に対して操作をしていますが、実際は永続化されるDB等を利用するべきでしょう。

if (verified && registrationInfo) {
	const { credentialPublicKey, credentialID, counter } = registrationInfo;
        const existingDevice = user.devices.find((device) =>
            isoUint8Array.areEqual(device.credentialID, credentialID)
        );

        if (!existingDevice) {
            /**
             * Add the returned device to the user's list of devices
             */
            const newDevice: AuthenticatorDevice = {
                credentialPublicKey,
                credentialID,
                counter,
                transports: body.response.transports,
            };
            user.devices.push(newDevice);
        }
}

これで登録が正常に完了すればOKですね!

ログインを実装する

さて、次はログインです。基本的には先ほどのアカウント登録とは同じようなコードになります。まずは以下のようなログイン画面というかトップページを作ります。

<body>
    <header>
        <h1>Passkey Example - Index</h1>
    </header>
    <div class="container">
        <p><a href="signup.html">アカウントを作成</a></p>
        <p><a href="" id="linkLogin">ログイン</a></p>
    </div>
</body>

<script type="module">
    import { auth } from './static/auth_helper.js';

    document.querySelector('#linkLogin').addEventListener('click', async (event) => {
        event.preventDefault();

        const verificationResp = await auth();
        if (verificationResp.ok) {
            console.log("login:success");
            location.href = "profile.html"
        } else {
            console.log("login:fail");
        }
    });
</script>

ログインボタンを押すと以下のようなポップアップが立ち上がりPassKeyを選択出来ます。

authメソッドで認証を行い成功すればプロフィールページに移動しています。
authメソッドでは認証を行っていますがフローは以下のように基本的には登録時と同じでOptionの取得、認証器とのやりとり、RPで署名を検証、という流れになります。

ではメソッドの中身を見ていきましょう。

// チャレンジやPRに登録されている情報を取得
const authOptionsResp = await fetch(url_auth_options);
const authOptions = await authOptionsResp.json();

// チャレンジを検証し、PRから渡された情報にマッチするサイトが認証器に登録されているか検索
const credential = await navigator.credentials.get({
	publicKey: buildOptionsForVerifyDevice(authOptions),
	mediation: 'optional'
});
function buildOptionsForVerifyDevice(authOptions) {
    return {
        challenge: base64url2ab(authOptions.challenge),
        userVerification: "preferred"
    };
}

流れとしては登録時と同じくまずはオプションを取得します。ここで取得した情報をもとにnavigator.credentials.getをすることで、Windows HelloやAndroidでの生体認証やPINが要求されるので、こちらを入力するとクレデンシャルが取得できます。

// チャレンジなど認証器の戻りをRPに送信して検証
const verificationResp = await fetch('/verify-authentication', {
	method: 'POST',
	headers: { 'Content-Type': 'application/json' },
	body: JSON.stringify(buildOptionsForVerifyRP(credential)),
});
function buildOptionsForVerifyRP(credential) {
    return {
        id: ab2base64url(credential.rawId),
        type: credential.type,
        rawId: ab2base64url(credential.rawId),
        response: {
            clientDataJSON: ab2base64url(credential.response.clientDataJSON),
            authenticatorData: ab2base64url(credential.response.authenticatorData),
            signature: ab2base64url(credential.response.signature)
        }
    };
}

取得したクレデンシャルとチャレンジから作った署名をRPに渡します。そして、RP側では以下のように検証します。

app.post('/verify-authentication', async (c) => {
    const body: AuthenticationResponseJSON = await c.req.json();
    const user = users[c.get('session').get("currentUserId")];
    const expectedChallenge = c.get('session').get('currentChallenge');
    const verified = verifyAuth(user, body, expectedChallenge, rpID, expectedOrigin);

    c.get('session').set('currentChallenge', '');
    return c.json({ verified });
});

async function verifyAuth(user: any, body: any, expectedChallenge: string, rpId: string, expectedOrigin: string) {
    let dbAuthenticator;
    const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId);
    // "Query the DB" here for an authenticator matching `credentialID`
    for (const dev of user.devices) {
        if (isoUint8Array.areEqual(dev.credentialID, bodyCredIDBuffer)) {
            dbAuthenticator = dev;
            break;
        }
    }
...const opts: VerifyAuthenticationResponseOpts = {
        response: body,
        expectedChallenge: `${expectedChallenge}`,
        expectedOrigin,
        expectedRPID: rpId,
        authenticator: dbAuthenticator,
        requireUserVerification: true,
    };
    verification = await verifyAuthenticationResponse(opts);
 ...}

登録時と同じくバイナリがリクエストには含まれているので、SimpleWebAuthnのverifyAuthenticationResponseをそのまま利用しています。こちらで渡された署名を登録していた公開鍵で検証し、元のチャレンジと一致するかを確認します。

まとめ

最近ちょっぴり話題のPassKeyに関して登場の経緯や基本的な仕組み、実装サンプルに関してまとめてみました。

Passkeyは決して完璧ではありませんが、おそらく現在人類が持っている最もマシな認証方式です。
正直、パスワードを完全に排したピュアなPassKeyのみのサイトを作るのは新規サービスであってもまだ少しチャレンジングです。とても面白そうですけれど。ただ、運営者も利用者も早く慣れてよりセキュアで簡単な認証を実現する世界にしていきたいので、使えるところからどんどん使っていきたいですね。

個人的にはVRが好きなのでVRアプリでのパスワード入力がパスキーによって解決することも強く期待します! 顔認証や虹彩認証とは言わないので、PINになるだけでも...

それではHappy Hacking!

Discussion

kodukikoduki

パスワード、MFA、パスキーの比較表が間違ってたので修正

ritouritou

2点ほど気になった点がありましたのでコメントさせていただきます。

FIDO2, WebAuthnについて

FIDO2は包括的なパスワードレスの仕様なので、WindowsなどのOSやアプリへのログインなど多くのシーンを想定したものですが、その中でブラウザとWebアプリの利用に特化した仕様がWebAuthnです。

  • FIDO : 公開鍵暗号方式 + ローカル認証を組み合わせた認証方式
  • FIDO2 : FIDOをWebアプリケーションでも利用可能にするための規格であり、以下の2つにより実現される
    • WebAuthn : RPが利用するブラウザAPI のこと
    • CTAP(図中ではCTPAと表記されていました) : ブラウザとAuthenticatorの間のプロトコル

という整理をする方が正しいかと思います。

実はPassKeyはWebAuthnの次世代規格というかある種の改良型の仕様となります。

FIDOクレデンシャルが同期ができるようになったことなど、Authenticator側の進化に合わせてWebAuthnも仕様策定が続いています。例えば現在策定中のLevel3では同期に関するフラグがサポートされていたりします。

https://www.w3.org/TR/webauthn-3/

フィッシング耐性について

WebAuthnでは登録してあるドメイン(=RPID)と公開鍵が一致していることを確認するので 「すでに登録したサイトか否か」 を機械的に区別出来るのが大きなメリットです。

フィッシングを防ぐためには何が必要でしょうか

  • 正規のサイトとクレデンシャルの管理を厳密に行う
  • 正規のサイトにのみ認証を行う

という2点かと思います。
前者は記載されていますが、後者について触れられていません。

後者を実現するために、Client(ブラウザ)が仲介して RP が認証要求に指定したRP ID(ドメインやオリジン)と現在アクセスしている環境が一致しているかどうかを検証してくれる 、というのがFIDO2におけるフィッシング対策の仕組みです。

参考にしていただければ幸いです。

kodukikoduki

ご指摘ありがとうございます。フィッシングとWebAuthnの部分、反映させて戴きました。

FIDOクレデンシャルが同期ができるようになったことなど、Authenticator側の進化に合わせてWebAuthnも仕様策定が続いています。例えば現在策定中のLevel3では同期に関するフラグがサポートされていたりします。

ここの部分、もし良ければ少し教えていただきたいのですが、これはパスキーと元々呼ばれていたMDCとかの話とは別に、新しい同期の仕組みが作られている、という事なのでしょうか?