チームのブログ
News

Windows Hello が WebAuthn PRF に対応した ─ prf.enabled を信じてはいけない理由

ko

Ko Ohashi

windows-hello-prf-support.svg

パスキー(WebAuthn)の PRF 拡張を使うと、生体認証や PIN をきっかけに「端末の中にある秘密から計算された値」を受け取れます。暗号化されたデータの鍵を、パスワードなしで開くための材料として使える仕組みです。

これまで Windows では、OS 標準の Windows Hello がこの拡張に対応していませんでした。それが 2026 年に入って動くようになりました。実際に試してみると、対応判定のところにひとつ大きな落とし穴がありました。この記事では PRF の基本と、Windows Hello で使うときに実装者がハマりやすい点をまとめます。


PRF 拡張とは

通常のパスキー認証で返ってくるのは、「この人は、登録済みの端末で本人確認をした」という署名だけです。

PRF 拡張(prf)を指定すると、認証のついでに 32 バイトの値が返ってきます。これは認証器の中にある秘密と、アプリ側が渡した入力(salt)から計算されたものです。性質は次の 3 つです。

  • 同じパスキーに同じ salt を渡すと、毎回同じ値が返る(決定的)
  • パスキーか salt が違えば、まったく別の値になる
  • 計算に使われた認証器の中の秘密そのものは、外に出ない

つまり「本人が端末で認証したときだけ取り出せる、端末ごとの秘密の値」です。パスキーの普通の使い方が「本人確認」だけなのに対し、PRF は鍵の材料になるのが違いです。

仕組みとしては、CTAP2 の hmac-secret 拡張を WebAuthn から使えるようにしたものです。なお、アプリが渡した salt はそのまま使われず、仕様上 "WebAuthn PRF" という文字列と組み合わせてハッシュされてから認証器に渡されます。Web サイトが、OS ログインなど別の用途の HMAC を勝手に計算できないようにするためです。


なぜ PRF が欲しいのか

保存データを暗号化しているサービスでは、ログインと鍵の扱いが分かれがちです。

パスワードでログインする場合は、そのパスワードを鍵を開く材料にも使えます。ところが SSO(OIDC や SAML)を入れると、事情が変わります。IdP での認証が成功して分かるのは「この人が本人である」ことだけで、鍵を開くための秘密は何も手に入りません。結局、SSO のあとに別途パスワードを入力させる、という二度手間が残ります。

PRF はこの隙間を埋められます。一般的な構成は次の図のようになります。

windows-hello-prf-support-prf-flow.svg

図の 3 と 4、つまり鍵を導出して開く処理をどこでやるかで、大きく 2 つの型に分かれます。

  • ブラウザで開く型: PRF の値をブラウザの外に出さず、ブラウザ内で鍵を開く。サーバーは包まれた鍵を預かるだけになる
  • サーバーで開く型: PRF の値(または導出した鍵)をサーバーに送り、サーバー側で鍵を開く。鍵をDB等に保存することはないが、サーバーが一時的に鍵に触れる

どちらを選ぶかで、「サービス事業者も中身を見られない」と言えるかどうかが変わります。後者でも、事業者のサーバーが侵害を許してもDB等中にある情報だけでは中身が見られれないという意味で効果はあります。

対応状況の変化

PRF が使えたのは、長らく macOS / iOS の iCloud キーチェーン、Android や Chrome の Google パスワードマネージャー、それに YubiKey のようなハードウェアキーが中心でした。Windows Hello は対象外で、Windows ユーザーには「Chrome で Google パスワードマネージャーに保存してください」と案内するしかありませんでした。

状況が変わったのは 2026 年に入ってからです。2 月ごろから、新しい Windows 11 のビルドで Windows Hello の PRF が動いたという報告がコミュニティに出始めました。

ただし、よく参照される対応状況の表や開発者ガイドの多くは、この記事を書いている時点でも「Windows Hello は非対応」のままです。調べ物の段階で古い情報に当たり、「Windows では使えない」と判断してしまう人は多いと思います。

実機での確認

次の環境で確かめました。

  • Windows 11(26200.9457)
  • Microsoft Edge 153
  • 保存先:Windows Hello(「このWindowsに保存」、TPM によるデバイス束縛パスキー)

登録時の attestation に含まれる AAGUID は 08987058-cadc-4b81-b6e1-30de50dcbe96 で、これは Windows Hello(Hardware)として知られている値です。ちゃんと Windows Hello に保存されたことが確認できます。

結果は次のとおりです。


操作

返ってきた値

登録(create())

prf: { enabled: false }

認証(get())1 回目

32 バイトの値

認証(get())2 回目

1 回目と完全に一致

認証時には PRF の値がきちんと返り、同じ salt で 2 回取っても同じ値でした。鍵の材料として必要な性質は満たしています。筆者の環境では、この仕組みを使った実際の SSO ログインまで動作を確認しました。

問題は 1 行目です。

ハマりどころ

1. enabled: false なのに動く

登録時の getClientExtensionResults().prf.enabled が false なのに、認証時には値が返ります。

PRF の対応判定は、ほとんどの実装で次のように書かれていると思います。

1const ext = credential.getClientExtensionResults();
2if (ext.prf?.enabled) {
3 // PRF 対応
4} else {
5 // 非対応として扱う
6}


このコードだと、Windows Hello のユーザーは全員「非対応」と判定されます。実際には使えるのに、です。私たちも以前はこの判定をしていて、Windows Hello のユーザーは登録できませんでした。

Windows がこうなる理由として、Windows が返す登録時のデータに hmac-secret の有無を示すフラグが立たない、という報告が以前からあります。一方で CTAP2 の仕様は、登録時に要求されたかどうかに関係なく、認証時に hmac-secret に対応してよいとしています。つまり仕様違反ではなく、「登録時のフラグでは判断できない実装」があるということです。

Edge 153 はかなり新しいバージョンなので、これはブラウザが古いせいではなく、OS 側の挙動だと考えられます。ブラウザを更新しても解消しません。

2. 保存先が Microsoft パスワードマネージャーだと PRF が返らない

Windows では、パスキーの保存先として Windows Hello とは別に Microsoft パスワードマネージャー(クラウドで同期されるパスキー)を選べます。こちらに保存されたパスキーでは、PRF の値が返りませんでした。

ややこしいのは、どちらに保存するときも似たような Windows のダイアログが出ることです。「Windows Hello で登録したつもりが、実はGoogleパスワードマネージャーのパスキーだった」ということが起こります。私たちの環境では、登録時は enabled を返すのに認証時には値を返さない、という組み合わせも見ました。これも 1 の判定方法では見抜けません。

3. 「このWindowsに保存」が出なくなることがある

一度は「このWindowsに保存」を選べたのに、あるとき Microsoft パスワードマネージャーしか選択肢に出なくなりました。パスワードマネージャーを使い始めたことや、設定の変更がきっかけになるようです。

保存先は「設定 > アカウント > パスキー > 詳細オプション」で確認・変更できます。ここで Windows デバイスへの保存が無効になっていると、どのサイトでも Windows Hello が選べません。利用者の環境で勝手に変わりうる設定なので、案内文を用意しておくと問い合わせ対応が楽になります。

4. 同じ userHandle で別のパスキーを作ると上書きされる

たとえば、2 要素認証用のパスキーと、PRF で鍵を開くためのパスキーを別々に登録するとします。このとき同じ RP ID と同じ userHandle(user.id)を使うと、iCloud キーチェーンや Windows Hello は「同じアカウントのパスキー」とみなし、後から作ったもので前のものを上書きします。用途ごとに userHandle を分けておく必要があります。

5. 非 discoverable を指定しても discoverable になる

登録時に residentKey: "discouraged" を指定しましたが、credProps で確認すると discoverable(rk: true)で作られていました。Windows 側が PRF(hmac-secret)を通すために discoverable を必須にしているのではないか、という報告もありますが、原因はまだ確認できていません。「PRF を使うなら非 discoverable は選べないかもしれない」という前提で設計しておくのが安全です。

実装パターン

登録直後に get() で確かめる

enabled が当てにならないなら、実際に値が返るか確かめるしかありません。流れは次の図のとおりです。

windows-hello-prf-support-prf-probe.svg

パスキーを作った直後、DB に保存する前に、そのパスキーだけを指定して `get()` を 1 回呼びます。

1async function probePrf(rawId, rpId, label) {
2 try {
3 const assertion = await navigator.credentials.get({
4 publicKey: {
5 challenge: crypto.getRandomValues(new Uint8Array(32)),
6 rpId,
7 allowCredentials: [{ type: "public-key", id: rawId }],
8 userVerification: "required",
9 extensions: {
10 prf: { eval: { first: new TextEncoder().encode(label) } },
11 },
12 },
13 });
14 const first = assertion.getClientExtensionResults().prf?.results?.first;
15 return first ? "ok" : "authenticator_unsupported";
16 } catch (e) {
17 return "probe_failed";
18 }
19}

このときのチャレンジはブラウザで作る使い捨てで構いません。目的は「値が返るか」の確認だけで、署名の検証は後の本番の認証でサーバーが行います。

確認で失敗したパスキーは保存しないので、PRF の使えないパスキーが DB に残りません。前述の「登録時は enabled なのに認証時は返さない」実装もここで弾けます。失敗の理由を分けておくと、「ブラウザが非対応です」「保存先を Windows Hello に変えてください」のように、利用者への案内を出し分けられます。

代わりに、登録時に認証のダイアログが 2 回出ます(その後のログインを含めると 3 回)。ここは割り切りとして受け入れました。

salt と HKDF で用途を分ける

PRF に渡す salt は、アプリ名・用途・バージョンを含む固定の文字列にしておきます。

1const label = "myapp:e2ee:v1";

受け取った 32 バイトはそのまま鍵にせず、HKDF で用途ごとの鍵を導出します。info にワークスペースなどの識別子を入れれば、同じ PRF の値から、ワークスペースごとに別の鍵が作れます。

1const ikm = await crypto.subtle.importKey("raw", prfFirst, "HKDF", false, ["deriveKey"]);
2const kek = await crypto.subtle.deriveKey(
3 {
4 name: "HKDF",
5 hash: "SHA-256",
6 salt: hkdfSalt,
7 info: new TextEncoder().encode("myapp:kek:v1|workspace:" + workspaceId),
8 },
9 ikm,
10 { name: "AES-KW", length: 256 },
11 false,
12 ["wrapKey", "unwrapKey"]
13);

鍵は「包んで」持つ

PRF の値からデータ鍵を直接作るのは避けてください。Windows Hello のパスキーは端末に束縛されているので、PC の故障や紛失、Windows Hello の PIN の再設定で、二度と同じ値を取り出せなくなります。そうなるとデータも開けなくなります。

代わりに、ランダムに作ったデータ鍵を、複数の KEK でそれぞれ包んで(ラップして)保管します。

1データ鍵
2 ├─ 端末 APRF 由来の KEK で包んだもの
3 ├─ 端末 BPRF 由来の KEK で包んだもの
4 └─ 回復用の秘密で包んだもの

どれか 1 つで開ければよいので、端末を追加しても包みが 1 つ増えるだけで済みます。端末を失っても、ほかの端末や回復手段から開けます。

まとめ

  • Windows Hello でも、新しいビルドでは PRF が使えるようになった
  • ただし登録時の prf.enabled は false を返すので、このフラグで対応を判定すると Windows Hello のユーザーを全員取りこぼす
  • 対応の判定は、登録直後に実際に get() して値が返るかで確かめるのが確実
  • 保存先が Microsoft パスワードマネージャーだと PRF は返らない。Windows の設定で保存先が変わることもある
  • 端末束縛の鍵は失われうるので、データ鍵は複数の手段で包んで持つ

PRF は、パスキーを「本人確認の道具」から「鍵を開く道具」に広げる拡張です。Windows が対応したことで、主要なプラットフォームがほぼ揃いました。ただ、実装ごとの振る舞いのばらつきはまだ大きいので、仕様のフラグより実際の挙動を確かめる作りにしておくのが安全です。