passkey-auth-starter guide 09

09 攻撃と防御

この章では、ここまでの章で出てきた対策を、攻撃の側から振り返ります。「この攻撃は、どの仕組みの、どの行が防いでいるか」を一覧にしたものです。より詳しい一覧(行番号つき)と、防ぎきれない「残るリスク」は docs/threat-model.md にあります。

攻撃者は何ができると考えるか

対策を考える前に、攻撃者が何をできると想定しているかを決めておきます。これを脅威モデル(どんな攻撃者が何をしてくるかの想定)と呼びます。

  • インターネットから、好きな要求を好きなだけ送れる
  • 本物そっくりの偽サイトを作れる
  • どこかのサービスから漏れたメールアドレスとパスワードの組を持っている
  • 場合によっては、このサービスの DB のコピーを盗み出せる

一方で、サーバーそのものの乗っ取りや、利用者の端末の乗っ取りは、この見本の範囲外としています。

flowchart LR
  subgraph ATK["攻撃"]
    A1["総当たり"]
    A2["使い回しの組を試す"]
    A3["偽サイト"]
    A4["セッション奪取"]
    A5["DBの盗難"]
    A6["列挙"]
  end
  subgraph DEF["対策"]
    D1["レート制限とロック"]
    D2["漏えい一覧の照合とパスキー"]
    D3["パスキーのRP ID"]
    D4["Cookieの属性とHMAC"]
    D5["ハッシュだけを持つ"]
    D6["応答と時間をそろえる"]
  end
  A1 --> D1
  A2 --> D2
  A3 --> D3
  A4 --> D4
  A5 --> D5
  A6 --> D6

1. 総当たり

攻撃: 1 つのアカウントに対して、パスワードを次々に試す。

対策要件章
IP アドレスごとのレート制限(15 分あたり 30 回)S806
パスワードで 5 回続けて失敗したら 15 分ロック。ログイン・変更・再認証の失敗を合わせて数えるS8, S907
パスキーはロックの対象外(推測で当てられないので、ロックは締め出しにしか効かない)S907
判定と記録を照合の前に済ませ、同時の要求で上限をすり抜けさせないS907
15 文字以上を求め、推測に必要な回数を大きくするS203

IP ごとのレート制限は @fastify/rate-limit というライブラリに任せ、ログインや登録などのルートが個別に使います。

// 出典: src/server.ts:67-69
  // S8: IP ごとのレート制限。全体には掛けず、ログイン・登録などのルートが config.rateLimit で個別に使う
  const auth = options.authRateLimit ?? { max: 30, timeWindowMs: 15 * 60 * 1000 };
  app.decorate('authRateLimit', { max: auth.max, timeWindow: auth.timeWindowMs });

残るリスク: パスワードのロックは、メールアドレスを知っている第三者でも起こせます(利用者を 15 分ずつ締め出す嫌がらせ)。パスキーを登録している利用者はパスキーで入れるので、困るのはパスワードだけの利用者です。また、多数の IP を使う分散型の総当たりは、IP の制限では止まらず、アカウント単位のロックで速度を落とすだけになります。

2. 資格情報の使い回し

攻撃: 他のサービスから漏れた「メールアドレスとパスワード」の組を、そのまま試す(クレデンシャルスタッフィング)。

対策要件章
登録・変更・再設定で、漏えい済みパスワードの一覧と照合して拒否するS303
文字種や定期変更を強制しない(覚えられない規則は使い回しを増やす)S2, S403
パスキーを最初から選べる(パスキーには使い回せる秘密が無い)S505

3. フィッシング

攻撃: 偽サイトに誘導して、パスワードやワンタイムコードを入力させて盗む。

対策要件章
パスキーは RP ID でドメインに結びつく。認証器は偽サイトでは署名しないS505
サーバーも、署名がどのオリジンで作られたかを検証するS505
メールや SMS のコードをログインの要素にしない(コードは偽サイトに中継できる)S1108

サーバー側の検証では、期待するオリジンと RP ID を、ライブラリにそのまま渡しています。

// 出典: src/auth/passkey.ts:142-146
  return verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: config.origin,
    expectedRPID: config.rpId,

残るリスク: パスワードは、偽サイトに入力した時点で盗まれます。仕組みとしての根本対策は、パスキーへの移行です。

4. セッション奪取

攻撃: ログイン状態の Cookie を、盗む・推測する・前もって仕込む・他サイトから使わせる。

攻撃の形対策要件章
推測セッション ID は 256bit の乱数S704
XSS で Cookie を読むHttpOnly。利用者が付けた名前は textContent で表示するS704
通信の盗聴本番は Secure と __Host- 接頭辞S704
DB の盗難DB には ID の HMAC だけを保存するS704
セッション固定ログインのたびに新しい ID を発行するS704
CSRFSameSite=Lax と、Origin ヘッダの検証を全ルートに掛けるS1304
ログから拾うログに出す Cookie ヘッダを伏せるS12このあと
盗んだ Cookie でパスキーを足す追加の前に、直近 5 分以内の本人確認を求める—06

Origin の検証は、ルートごとではなく、アプリ全体に 1 か所で掛けています。

// 出典: src/server.ts:80-81
  // S13: 状態を変える要求は Origin ヘッダを検証する。全ルートに一度で掛け、付け忘れを無くす
  app.addHook('onRequest', verifyOrigin);

受け入れたリスク: ログインや再認証の直後 5 分以内の Cookie を盗まれると、その間にパスキーを足されえます。足されたパスキーはパスワード変更や再設定の後も残ります。ログインそのものを本人確認として数えるのは、登録の直後にそのままパスキーを足せるようにするためで、利便性との引き換えです。安全側を優先したいサービスでは、src/auth/session.ts の REAUTH_MAX_AGE_MS(5 分)を短くするか、startSession で本人確認の時刻を記録しない(追加の前に毎回再認証させる)形に変えられます。

5. DB の盗難

攻撃: DB のファイルを盗み、中身からパスワードや有効なセッションを取り出す。

値持ち方要件章
パスワードArgon2id + 利用者ごとのソルトS102
セッション IDHMAC-SHA256(鍵は DB の外)S704
再設定トークンSHA-256S1008
パスキー公開鍵だけS505

どれも「盗まれた値をそのまま送ってもログインできない」形です。00 章の設計原則「漏れても使えないものだけを持つ」は、この表のことです。

6. 列挙

攻撃: 応答の違いや時間の違いから、あるメールアドレスが登録済みかを調べる。

経路対策章
ログイン文言と状態コードをそろえ、未登録でもダミーのハッシュと照合して時間をそろえる07
ロック未登録のメールアドレスにも同じ規則でロックを掛ける07
パスワード再設定登録の有無にかかわらず同じ応答。メールは応答の後に送る08
パスキーのログイン開始のときに「このメールの鍵」を返さない(Discoverable Credential)05

受け入れたリスク: 新規登録では、登録済みのメールアドレスに 409 を返すので、そこから列挙できます。防ぐには確認メールを挟む登録方式が要り、v1.0 の範囲外としました。速度は IP ごとのレート制限で抑えています。変えるなら src/routes/signup.ts と src/routes/passkeys.ts の 409 の分岐です(06 章)。

ログに秘密を残さない(S12)

監査ログは、あとから「いつ・誰が・どこから」を調べるためのものです。しかし、ログ自体が漏れることもあります。そこで、ログにパスワード・セッション ID・再設定トークンの生値を書かないようにしています。

  • 監査ログの detail(付け足す情報)は、型で文字列・数値・真偽値に絞り、オブジェクトや配列は入らないようにしている(src/db/repo/audit-log.ts)。文字列の長さまでは型で縛れないので、生値を入れていないことはテスト(test/integration/audit.test.ts)で確かめている
  • アプリのログでは、Cookie と Authorization ヘッダを伏せている(src/server.ts の redact)
  • テストで、すべての流れを通したあとに、最も詳しいレベルのログ・監査ログ・DB のどこにも生値が無いことを確かめている(test/integration/audit.test.ts)

まとめ

  • 対策は「攻撃 → 仕組み → 行」の順にたどれるようにしておく
  • 1 つの対策に頼らず、レート制限とロック、SameSite と Origin のように重ねる
  • 防ぎきれないものは「受け入れたリスク」として、理由と変える場所を書き出す

この解説の原本は GitHub の docs/guide/09-攻撃と防御.md です(MIT ライセンス)。誤りや分かりにくいところは、GitHub の Issue でお知らせください。