パスキーの verified: true は「この署名が正しい」以上のことを言わない — 自前 OP に WebAuthn を実装して書いた 6 つの防御
自前の OpenID Connect プロバイダにパスキー(WebAuthn)を実装した。ceremony そのものは @simplewebauthn/server が処理してくれるが、ライブラリの検証が「済ませていないこと」の側に、認証の穴になる項目がいくつも残っていた。userHandle の交差検証、sign_count を競合なく前進させる書き込み、登録の時間窓、セッション更新に失敗したときの倒し方。どれも自分で書くことになった。
ライブラリに任せられるのは、6 手順のうち 1 手順だった
実装したのは 7 ルート(管理ページ、登録の begin/finish、rename、delete、ログインの begin/finish)で、既存の Go 実装からの移植として書いた。ライブラリは @simplewebauthn/server の v13。
書き終わってから、ログイン finish の処理を並べ直してみると、ライブラリが担当しているのは真ん中の 1 つだけだった。
登録側の設定も、あとから効いてくる。attestation は none(attestation の検証経路を走らせない)、user verification と resident key は両方 Required にした。Required にしたのは厳しくしたかったからではなく、Preferred が生む状態が扱えないからだ。
// RK=Required: DiscoverableLogin(usernameless)だけを実装しているので、
// 非 resident に黙って落ちる Preferred は「ログインできない dead row」を生む。
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
// go-webauthn CredentialParametersDefault() と同一の 10 種(ES/RS/PS/EdDSA)。
const SUPPORTED_ALGORITHM_IDS = [-7, -257, -258, -259, -37, -38, -39, -35, -36, -8];こちらが実装しているのは discoverable login(usernameless)だけなので、resident でない credential が登録されても、その鍵ではログインできない。Preferred は「登録は成功したのにログインに使えない行」を黙って作る。UV も同じで、Preferred にすると「パスキーで入れたが本人確認は済んでいない」セッションが混ざる。
verify は userHandle を突き合わせてくれない
discoverable login では、認証器が「どのユーザーの鍵か」を userHandle として返してくる。登録時にこちらが刻んだ user_id がそのまま戻ってくる値だ。ところが verifyAuthenticationResponse は、この値を検証しない。渡した公開鍵で署名が検証できるかどうかしか見ていないので、userHandle が誰を指していようと verified: true が返る。
こちらは credential_id で保存済みの鍵を引いているので、署名検証に入る前に「その鍵の所有者」と「アサーションが名乗っている所有者」が一致することを自分で要求した。
// userHandle は登録時に刻んだ user_id。@simplewebauthn の verify は userHandle を
// 突き合わせないため、防御的にここで一致を要求する(Go 版と同じ)。
const userHandle = body.response?.userHandle;
if (!userHandle) {
return null;
}
const handleUserId = new TextDecoder().decode(fromB64Url(userHandle));
if (handleUserId !== rec.userId) {
return null;
}
return rec;verified: true は「この署名はこの公開鍵で作られた」以上のことを言っていない。誰としてログインさせるかは、ライブラリの返り値の外側にある。
ロールバック検知は UPDATE の WHERE 句に置く
WebAuthn の sign_count は、認証器が署名するたびに増えるカウンタで、保存値より進んでいなければクローンされた認証器を疑う、というのが仕様の意図だ。ライブラリもこの比較はしていて、渡した credential の counter 以下なら Response counter value ... was lower than expected ... を投げる。
ただし、その比較対象はこちらが DB から読んで渡した値だ。読んでから書くまでの間に同じ鍵で別のログインが成立すれば、2 本とも同じ古い値と比較して両方通る。進んだ値を保存するのも、どのみちこちらの仕事になる。つまり素直に書くと「読む → 比較する → 書く」になり、同時実行に対して素通しになる。単文の UPDATE にして、ロールバックの判定条件を WHERE 句そのものに置いた。
UPDATE webauthn_credentials
SET sign_count = ?1, last_used_at = ?2
WHERE record_id = ?3
AND (
-- Apple iCloud 同期パスキーは常に 0 を返す。0→0 の no-op を許容しつつ、
-- 非ゼロは spec どおり strictly-greater のみ通す。
(sign_count = 0 AND ?1 = 0)
OR ?1 > sign_count
)条件が単純な ?1 > sign_count になっていないのが実務側の事情で、Apple の iCloud 同期パスキーは常に 0 を返してくる。0 を strictly-greater で弾くと、同期パスキーのユーザーが 2 回目から全員ログインできなくなる。だから 0→0 の no-op だけを通し、非ゼロは仕様どおり厳密に増加しているときだけ通す。
公開鍵そのものは、D1 の TEXT 列に置いた JSON(envelope)に base64url で入れている。ただし sign_count だけは envelope ではなく INTEGER 列を真実源にした。envelope は登録時点のスナップショットで、カウンタはログインのたびに進む値だから、同じ数字を 2 か所に持つと必ずどちらかが古くなる。読み出し時は常に列を優先する。
更新行数が 0 だったときは、それだけでは理由が分からない。ロールバックなのか、begin と finish の間に管理画面から鍵が消されたのかを区別するため、そのあと 1 回だけ EXISTS を引いて 2 つの例外に振り分けている。呼び出し側はどちらも 401 login failed を返すが、ログに残る文言は別にした。クローン疑いは possible cloned authenticator として warn、削除との競合は info で、あとから区別できる。
登録は begin と finish の両方で 10 分窓を見る
パスキーの登録は、長寿命の credential を静かに 1 本増やす行為だ。しかもこちらの実装では、失効させるのはパスワードリセット(忘れたときの再設定)だけで、ログイン中のパスワード変更では失効させていない(移植元の Go 実装に合わせた)。だから、乗っ取られたセッションで黙って 1 本植えられると、パスワードを変えただけでは追い出せない。認証から 10 分以内のセッションでしか登録を許さないようにした。
重要なのは、これを begin だけでなく finish でも検査していることだ。チャレンジを受け取った時点で fresh でも、認証器の操作を挟んでいる間に窓が閉じることはある。begin で発行したチャレンジが、stale になったあとで引き換えられないようにしてある。テストでは、begin を 200 で通したあとにセッションの authTime を過去へ書き換え、finish が reauth_required の 401 になり、かつ credential が保存されていないことまで固定した。
authTime そのものが無いセッションは stale として扱う。判定できないときに通してしまうと、この防御は「記録が壊れていれば素通り」になる。
フラグではなく、セッションのポインタを消して再認証させる
登録の時間窓とは別に、認可側にもう一段ある。特定のクライアントにだけ「パスキーで認証したセッションでなければ通さない」を課す仕組みで、WEBAUTHN_REQUIRED_CLIENT_IDS に入っているクライアントのときだけ、セッションの authMethod が passkey であることを要求する。空集合なら誰にも課さない。
満たさなかったときの倒し方は 2 通りに分かれる。prompt=none の非対話フローでは login_required を返すだけ(セッションは温存する)。対話フローでは、セッションから userID / authTime / authMethod を消してから /login へ回す。
「再認証が必要」というフラグを立てるのではなくポインタごと消すのは、ログインフォームが「もう認証済みだから」と短絡してしまうのを構造的に防ぐためだ。フラグ方式は、フラグを見ていない経路がひとつでもあれば素通りになる。加えて、この gate は同意画面のスキップ判定より前に置いた。skip_consent のクライアントが gate を飛び越えられる順序になっていると、意味がない。
セッション更新の失敗は、パスワードとは逆に倒す
ログイン成立時にセッショントークンを回転させるのは、セッション固定化攻撃への対策として標準的な処理だ。問題は、この回転に失敗したときにどうするか。
// パスキーは強い認証方式なので、固定化攻撃対策の token rotation は fail-closed。
// password の finishLogin は可用性優先で握るが、passkey は逆に倒す(Go 版と同じ)。
try {
await c.get('session').renewToken();
} catch (err) {
console.error('passkey-login-finish: session renew failed; refusing fail-open', {
userId: rec.userId,
err,
});
return textError(c, 500, 'internal error');
}パスワードログイン側では、ここは可用性を優先して握りつぶしている。パスキー側は逆に倒して 500 internal error で止めた。パスキーは強い認証方式として売っているのだから、その経路で「固定化対策には失敗したがログインは通した」を作るなら、強いと言う根拠がなくなる。同じ処理でも、認証方式によって倒す向きが違ってよい、という判断だ。
テストでは、セッションの回転が DELETE→INSERT であることを利用して sessions の DELETE をトリガでブロックし、ログインが 500 になることを固定した。fail-closed は「失敗しない限り誰も通らない経路」なので、意図的に失敗させないと検証できない。
10 種を広告して、3 経路をソフト authenticator で通した
登録オプションで広告している COSE アルゴリズムは 10 種(ES / RS / PS / EdDSA)ある。自分で選んだ数ではなく、移植元の Go 実装(go-webauthn の既定値)と同じ集合に揃えたものだ。移行の前後で受け入れる認証器の集合を変えたくなかったので、ここは減らす方向にも動かせない。
ここが厄介なのは、広告した数だけ検証経路が増えることだ。署名の検証は最終的にランタイムの crypto.subtle に降りるので、アルゴリズムごとに別の実装を通る。そして手元には、それぞれの鍵種を持つ認証器が揃わない。
そこで WebCrypto でソフトウェア認証器を書いた。P-256 の ES256(DER 署名)、RSA の RS256、Ed25519 の EdDSA の 3 種類の鍵を生成し、CBOR で COSE 公開鍵を組み、fmt: none の attestationObject を作る。これで登録からログインまでの round-trip を、実際の Workers ランタイム上のテストで 3 alg とも走らせられるようになった。パスキー関連で 18 本、全体で 187 テストが緑になった。
187 テストが緑でも「完了」とは書かなかった
この移行では少し前に、別の教訓を踏んでいる。パスワードハッシュの PBKDF2 で、ローカルのテストが全部通っているのに本番ランタイムだけが 500 を返した件だ。テストは Node の WebCrypto に backing された環境で走っていて、ランタイム固有の上限がそこには存在しなかった。
パスキーのテストは、Node ではなく Workers ランタイム(workerd)上のプールで走っている。3 alg の round-trip も、そこで実際に署名検証を通したものだ。それでも、進捗記録にはこのマイルストーンを「完了」と書かなかった。PBKDF2 の一件のあとは、緑のテストを新しい暗号検証経路の証明として受け取らないことにしたからだ。理由は 3 つある。
- オプション生成(begin)は本番ランタイムで動くことを確認できた。dev デプロイ後にレスポンスを見て、{publicKey:...} のラッパ・UV/RK=required・10 種の COSE alg 広告・issuer から導出された rp.id が出ていることまでは実物で取れている。
- 署名検証(finish)は取れなかった。検証経路を通すには本物の認証器が要る。Touch ID なら ES256、Windows Hello なら RS256、EdDSA の鍵はさらに別の個体が要る。ここから先は人間がブラウザの前に座らないと 1 バイトも動かない。
- とくに EdDSA は、pool-workers では通ったが本番 workerd では未確認、という状態で止まった。crypto.subtle がどのアルゴリズムをどう扱うかを決めているのは実行環境で、ローカルで緑になったことは本番で動くことの証明にはならない。
つまり、オプション生成は本番で検証済み、署名検証は未検証という線が引ける状態だった。ここを曖昧にして「パスキー実装完了」と書くと、あとで読んだ人(自分を含む)が、検証済みの範囲を実際より広く読む。
まとめ
WebAuthn のライブラリは、ceremony の暗号的な部分を正確にやってくれる。だがそれは「この署名はこの公開鍵で作られた」の証明であって、「このリクエストをこのユーザーとして通してよい」の証明ではない。誰の鍵か、その鍵は進んでいるか、この登録は正当なセッションからか、失敗したときどちらへ倒すか — 認証の可否を決めているのは、全部その外側に書いた条件だった。
そして、書いた防御が本番で動くことの証明は、テストとは別に必要になる。ローカルの実行環境をどれだけ本番に近づけても、「近い」は「同じ」ではない。人間が実機の前に座らないと通せない経路が残っているなら、そこは未検証と書いておくほうがいい。緑のテストだけでは、次に読む人への約束にならない。