Menu

Mobile navigation

OWASP ASVS から考えるパスワード要件

Web アプリに認証・認可機能を追加する機会が今までに何度かあった。 最もベーシックな認証方法としてパスワード認証があるのだが、そこではパスワードをどのように入力させるべきかとかどれくらい厳しくバリデーションすべきか、はたまたどのように保存すべきかということを毎回考えていた。

しかし、そもそもパスワードに関する要件はプロジェクトによって左右されるべき事項でもないと思うので、この際使いまわせるようにある程度まとめてみる。 IdP(Identify Provider)や多要素認証についてはまた別の機会にまとめる予定。

もちろん、プロジェクトによっては管理者が作成したアカウントしかログインできないなどの要件があるため全てに当てはまるガイドラインではない。 パスワード要件を決めるに当たっては認証に関する標準である OWASP ASVS V2 を参考にしている。

OWASP ASVS とは

OWASP は "Open Web Application Security Project" の頭文字を取ったもので、Web アプリのセキュリティを高めるためのツールや文書、ベストプラクティスを提供する非営利団体のことである。 Web アプリのセキュリティに関する基準を定めている団体は他にもいくつかあるが、OWASP は広く知られておりセキュリティ関連の記事では比較的よく見る。

ASVS(Application Security Verification Standard)は、OWASP が提供する Web アプリのセキュリティに関する標準を定義したフレームワークである。 これを使うことで開発者やテスター、企業などは、アプリケーションのセキュリティの要件を明確に定義、評価、検証することが可能になる。

OWASP のプロジェクトで他に気になっているのは、最も現代的で洗練された安全でない Web アプリをコンセプトにしている OWASP Juice Shop である。 一見普通のサイトだが中身は脆弱性がぎっしりと詰まっている。 課題形式でハッキングを行えるので良い勉強になりそう。

セキュアなパスワード要件

さて、パスワード関連の要件についてまとめていく。 OWASP ASVS 5.0 の他に NIST SP800-63BOWASP Cheat Sheet Series も参考にしている。

パスワードの長さは最低でも 8 文字以上

当然だが、パスワードの最小長はアプリケーションによって強制される必要がある。 8 文字未満のパスワードであればクライアント側のバリデーションで弾くようにする。

例えば、メジャーな IDaaS である Firebase Authentication の場合、パスワードは 6 文字以上であれば登録できるようになっている。 しかも Firebase 側でこのパスワードポリシーを変更することはできないため注意が必要だ1

ちなみにクレジットカード情報の取り扱いに関する国際的なセキュリティ基準である PCIDSS v4.0 における要件では 12 文字以上が推奨されている。 長ければ長いほどセキュアになるが、ユーザが手動で入力することを考えるとサービスによって要件は変わるかもしれない。

今の時代はコンピュータの性能が上がっていることもあり、8 文字程度のランダムなパスワードであってもブルートフォースアタックや辞書攻撃をするとボット対策を設けていなければ 3 日で解析されてしまう。 12 文字であれば 3 年はかかる計算になる。 これが 14 文字になると解析に数世紀かかるためパスワードは長ければ長いほどよい。

現時点では 14 文字以上だと大して変わらないように思えるが今後量子コンピュータが実用化されれば一気に状況が変わるかもしれない(広く使われている暗号が安全ではなくなる)。

以下のサイトにパスワードを打ち込むとパスワードが解析されるまでの時間を大まかに算出してくれる。

少なくとも 64 文字のパスワードを許可する

パスワードの最小長はある程度決めやすいが、パスワードの最大長はどれくらいにすれば良いのか意外と迷う。 ASVS には最低でも 64 文字と記してある。ただ、なぜ 64 文字なのかはわからなかった。 64 は確かにキリが良いけど。

現在、パスワードをデータベースに保存するのであればソルト化ハッシュで保存するのがベストプラクティスである。 だからこそ結局ハッシュ化するならパスワードの最大長はとにかく長ければ長い方が良いと考えがちだ。

しかし、無制限に長いパスワードを許すとハッシュ化する際の計算時間が膨大になって Dos 脆弱性を生み出す可能性がある。

パスワードのハッシュ化をする際は枯れた技術である bcrypt を使う場面が多いと思う(ハッシュ化ライブラリとしては多言語に対応しているので)。 bcrypt のパスワード文字数は基本的にどの言語のラッパーでも 72 文字までという制限がある(参考)。 その制限を解除する方法もあるにはあるが2、わざわざ危険を冒してまでパスワードの最大長を伸ばすにはそこまで意味がない。

となると、bcrypt を使う場合は 64 文字を最大長にするのが良さそう。 可能であれば Argon2id のような比較的新しいハッシュ関数を使って 100 文字以上の最大長にするべき。

パスワードを黙って切り捨てない

当たり前のことだがパスワード文字列の切り捨てや大文字、小文字の変換をしてはならない。 今ではさすがにそのようなサービスを見かけることはないけど、昔は そういったサービス があったとかなかったとか。

Unicode や空白を含むすべての文字の使用を許可する

巷に溢れたサービスでは基本的に半角英数字、気の利いたサービスだと記号までパスワード文字列に含んで良いと定義してある。

しかし、OWASP ASVS 的にはこれだけでは不十分であり、印刷可能な Unicode 文字をパスワードとして使用できなければならないと記してある。 もちろん、全ての Unicode 文字や空白を含む文字をパスワードに許可することは技術的には可能だが、実装上の課題や UX に影響を及ぼす可能性があり、なかなか難しい。

Unicode を正確にエンコードしたりデコードしたりするのは意外と落とし穴が多かったりしてバグの温床になる。 また、モバイルデバイスでは特殊な Unicode 文字や記号を入力するのは面倒なのでユーザのことを考えた実装とは言えない。

現実的には大文字、小文字、数字、および一般的な記号(たとえば !@#$%^&*())の組み合わせを許可するのが最も理に適っていると思う。

許可される文字の種類を制限するパスワード構成ルールを設けない

これは意外だった。 少なくとも 1 文字以上の数字、大文字、記号のような文字種を組み合わせてパスワードを作成するよう求めるサービスは今までに何度も遭遇した。

しかし、漏洩したパスワードデータベースの分析により、そのようなルールではユーザビリティと記憶難易度へ与える影響はあれど実際はそこまでパスワード強度に影響がないことがわかってきているらしい(参考)。 むしろ逆効果にさえなりうる。 ほとんどの人は似たようなパターン(最初の文字は大文字、最後の文字は記号、最後の 2 つは数字)を使用する傾向があるのでハッカーは辞書攻撃を仕掛けやすい。

複雑で短いパスワードよりも長めでシンプルなパスフレーズの方が強度としては高くなる。

パスワード変更時に現在のパスワードと新しいパスワードを要求する

前提としてユーザはアクティブなセッションで認証されている状態である。 もしパスワード変更時に現在のパスワードを要求しないと何が起こるかをイメージしてみよう。

仮に正当なユーザが公共のパソコンでログインしていたとする。 このユーザがログアウトするのを忘れてしまった場合、その後に公共のパソコンを利用するユーザによってパスワードを変更されてしまう可能性がある。

稀なケースではあるがハッキングされてパスワードが変更される可能性もあるのでそれを防ぐためにも現在のパスワードは要求するようにすると永久にログインできないユーザを生み出さずに済む。 その際はパスワードを忘れた人用のフローも用意する。

頻繁に使用されるパスワードと照合する

良いパスワードを選択させるというのはなかなか難しいしある程度限界がある。 かといって簡単なパスワードを許してしまっては、仮にアカウントが侵害された場合はその対応コストを運営側が支払わないといけない。

あくまでパスワードを決めるのはユーザである。 が、破られるであろうパスワードを拒否する権利もサービス側にはある。

OWASP ASVS では頻繁に使われるパスワードの少なくとも上位 3,000 個を照合するように勧めている。 その際は上位 100,000 件の侵害されたパスワードがまとめてある Pwned Passwords のデータセットを使う方法がおすすめ(PwnedPasswordsTop100k.json)。 ただ、文字数が少ないパスワードも含まれているので計算回数を少なくするためにも使用前は以下のようにフィルタリングして別ファイルを作成するのが良い。

fetch('https://www.ncsc.gov.uk/static-assets/documents/PwnedPasswordsTop100k.json')
    .then(response => response.json())
    .then(data => data.filter(item => item.length >= 8))
    .then(result => console.log(result))
    .catch(error => console.error('Error:', error));

上記のコードを実行することで 8 文字以上のパスワードを抽出して 47,350 件まで絞り込みを行えた。 もし API を使いたい場合は Pwned Password API を使えば OK。 そこは実際のプロジェクトの要件によって決める。

他には SecLists のパスワードリストを使う方法もある。

また上述したパスワードリストの他にもサービス固有のコンテキスト(このサイトであれば kkhys.me)などが含まれていないかも確認して、あれば登録をブロックする。

パスワード入力フィールドをマスクする

パスワード入力フィールドの属性に type=password を指定することでテキストが読み取られないように記号に置き換えられる。 そうすることでショルダーハッキングを防げる。

ただ、それだけだと入力したパスワードを確認したい場合に困るのでパスワードの表示・非表示を切り替えられるオプションを提供する。 そうすればスクリーンが盗み見られる可能性が低い場所にいる場合に入力内容を確認できる。 またはモバイルデバイス向けに個々に入力した文字を入力後に短時間だけ表示されるようにする方法もある。

パスワードのペーストを許可する

稀にパスワードのペーストが許可されていないサイトがある。 そうすることでパスワードの再利用を防ぐ目的があるのかもしれないが、今やパスワードマネージャや Apple のパスワード自動生成機能を積極的に使っている人が多くなってきている。 ペーストが許可されていないとそれらのツールを使えずに結果として貧弱なパスワードを設定してしまうことが想定される。

そのような本末転倒な事態を防ぐためにもパスワードのペーストは必ず許可する。 また、パスワードマネージャの使用を想定して autocomplete 属性 を適切に設定しておくとユーザビリティが高まる。

エラーメッセージと応答時間

パスワード認証の場合はログイン ID と合わせて入力を求めることが多いと思うが、その際のエラーメッセージに注意しないとログイン ID の列挙攻撃を許してしまう。 例えばログイン ID が正しくない場合とログイン ID は正しいがパスワードが間違っている場合に異なるメッセージを表示すると(以下例)、その違いによって攻撃者は必要な認証資格情報の半分を簡単に取得できてしまう。

  • ログイン ID が正しくないかつパスワードは正しい → ログイン ID が不明です
  • ログイン ID は正しいがパスワードが正しくない → パスワードが正しくありません

ログイン ID がメールアドレスの場合、パスワードほど特定するのは難しくないため有効性が確認されれば悪用される可能性もある。 対策としては「ユーザーIDまたはパスワードが間違っています」というようにどちらの認証資格情報が間違っているのかわからないようにする。

これらは初歩的な要件だが、注意しないといけないのは タイミング攻撃 である。

以下に参考となるコードを TypeScript で書いてみた。

/**
 * Checks if a user exists in the system.
 *
 * @async
 * @param username - The username of the user.
 * @returns A promise that resolves to `true` if the user exists, otherwise resolves to `false`.
 */
const userExists = async (username: string) => {
  // ...
  return false;
}
 
/**
 * Hashes the given password.
 *
 * @param password - The password to be hashed.
 * @returns The hashed password.
 */
const hash = (password: string) => {
  // ...
  return '';
}
 
/**
 * Looks up the credentials in the store.
 *
 * @param username - The username to lookup.
 * @param passwordHash - The hashed password to compare.
 * @returns A promise that resolves to a boolean value indicating whether the credentials exist in the store.
 */
const lookupCredentialsInStore = async (username: string, passwordHash: string) => {
  // ...
  return false;
}
 
/**
 * Authenticates a user with a given username and password.
 *
 * @param username - The username of the user to authenticate.
 * @param password - The password of the user to authenticate.
 * @throws If the username or password is invalid.
 * @returns A promise that resolves if the authentication is successful.
 */
const authenticate = async (username: string, password: string) => {
  if (await userExists(username)) {
    const passwordHash = hash(password);
    const isValid = await lookupCredentialsInStore(username, passwordHash);
 
    if (!isValid) {
      throw new Error("Invalid Username or Password!");
    }
 
  } else {
    throw new Error("Invalid Username or Password!");
  }
}

上記のコードではまずログイン ID に紐づくユーザがデータベースに存在するかを検索している。 ログイン ID が合致しなかった場合に早期リターンすることで比較的重いパスワードのハッシュ化計算を行わないというパフォーマンス面での意図がある。

一見良さそうに思えるが致命的な欠点として、ログイン ID が存在しなければすぐにレスポンスが返ってくるがログイン ID が存在する場合は前者よりも遅れてレスポンスが返ってきてしまう。 そうなるとユーザ名列挙攻撃が可能になってしまう。

そうならないためにも以下のコードのようにログイン ID やパスワードに関係なく同じプロセスを踏むようにする。

const authenticate = async (username: string, password: string) => {
  const passwordHash = hash(password);
  const isValid = await lookupCredentialsInStore(username, passwordHash);
 
  if (!isValid) {
    throw new Error("Invalid Username or Password!");
  }
}

秘密の質問を使わない

パスワードをリセットするときにユーザしか知り得ないような質問の回答を要求する方法がある(秘密の質問)。 この方法は NIST SP 800-63 で許容される認証要素として認められなくなったため使用しないようにする。

Google のセキュリティブログ に秘密の質問に関する問題点がわかりやすくまとめてある。 中でも以下の部分が興味深い。

Many different users also had identical answers to secret questions that we’d normally expect to be highly secure, such as "What’s your phone number?" or "What’s your frequent flyer number?". We dug into this further and found that 37% of people intentionally provide false answers to their questions thinking this will make them harder to guess. However, this ends up backfiring because people choose the same (false) answers, and actually increase the likelihood that an attacker can break in.

電話番号のような他人が推測できないような秘密の質問でも多くのユーザが同じ回答をしていたらしい。 そういった人たちは推測されにくくなると考えて意図的に偽の回答をしていたのだが、これが裏目に出て逆にハッキングされる危険性を高めていたとのこと。

推測されないような秘密の質問の回答を考えても結局思い出せずにアカウントの復旧ができなければ意味がない。 アカウントを回復させるプロセスとしては以下の 3 パターンがある。

  • URL トークン付きのメールを送信する
  • PIN 番号付きの SMS を送信する
  • バックアップコードを登録時に発行しておく

他にも OTP トークンを使った認証や FIDO を使った認証などあるが、実装が複雑になるため、まずはメールアドレスか SMS を使ったアカウント復旧プロセスを構築したほうが良いかもしれない。

ボットからの保護

ブルートフォースアタックや クレデンシャルスタッフィングパスワードスプレー といった攻撃は基本的にボットを使って自動的に行われる。 多要素認証(MFA)を実装すればパスワード関連の攻撃の大半を防げるが、サービスによっては MFA の使用を強制することが難しい場合もある。 そこで代替案としてログインスロットリングと CAPTCHA を使う方法がある。

ログインスロットリングは攻撃者が対話型の手段でパスワードを推測しようと試行を何度も行うのを防ぐための方法である。 ログイン時のアクションを計測しておき最大試行回数を上回ったらアカウントをロックする。

その際の注意点として攻撃者が異なる IP アドレスからログインを試行しても問題ないようにアクションは IP アドレスではなくアカウントと紐づけるようにする。

ログインスロットリングは有効な手段ではあるが、設計が難しいため慎重に導入しなければならない。 例えば、ユーザが誤って他のユーザのアカウントをロックしてしまったときはどうするかとかアカウントロックしてからロックアウトされるまでの期間をどれくらいにすべきかなど実装上考えることが多い。

CAPTCHA は Google の提供する reCAPTCHA が有名なので知っている人も多いと思う。 画像を選択させることでそれがボットではなく生身の人間であることを確認している。

最近では invisible 型の reCAPTCHA が主流になってきたこともあり、UX を妨げることもなくなってきた(画像やテキストの読み取りは結構ストレスになるので)。 比較的導入はしやすいが、自動化された手法を使用して解決できる弱点や、解決できるサービスにアウトソーシングできる弱点があるため予防策としてではなく、防御を多層にするものと考えた方が良い。

ログの監視

これもハッキング対策だが、最低でも以下のログを出力しておくこと。

  • ログイン途中で発生した全てのエラー
  • 全てのパスワードエラー(入力したパスワード)
  • 全てのアカウントロック

そうすることで攻撃や障害をリアルタイムに検出できる。 ただ、ログを出力しても気づかなければ意味がないので攻撃を検知したら Slack などにアラートを送信するフローを構築しておく。

さいごに

みなさん、パスワードマネージャを使いましょう。 多要素認証もできれば追加してください。

Footnotes

  1. とはいえ、Identity Platform にアップグレードすればパスワードポリシーを変更できる(参考)。変更したことで料金はかかるが実際の運用では必須だと思う

  2. bcryptの72文字制限をSHA-512ハッシュで回避する方式の注意点