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-63B OWASP 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などにアラートを送信するフローを構築しておく。

さいごに

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


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

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