ちょっと話題の記事

OWASPに学ぶパスワードの安全なハッシュ化

パスワードをデータベースに安全に保存するには、ハッシュ関数にArgon2idを用い、味付けにソルト・ペッパーを使いましょう。
2021.04.27

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

ユーザー認証が必要なWebアプリケーションできってもきれないのがパスワードの保存方法です。 平文のままデータベースにパスワードを保存するといったずさんなパスワード管理は重大なセキュリティインシデントに繋がります。

「OWASP Top 10」でも有名なWebアプリケーションセキュリティ団体のOWASPがパスワードを安全に保存するためのガイダンスを公開していたので、簡単に紹介します。

Password Storage - OWASP Cheat Sheet Series

tl; dr

仮にアプリケーションやデータベースが不正アクセスされた状況を想定し、オフライン攻撃を遅らせるにはどうすればよいでしょうか。

パスワードにソルトを付与し、 ハッシュ関数 Argon2id をメモリー 15 MiB、実行時間 2、並列数 1 のパラメーターで呼び出したハッシュ値を保存します。

ペッパーを用いると、さらに強度を高められます。

パスワードを安全に保存するガイドライン

ガイダンスの目的について

OWASP Cheat Sheet の冒頭から、ガイダンスの目的を引用します。

アプリケーションやデータベースが侵害されても、攻撃者がパスワードを取得できないように保存することが不可欠です。最近の言語やフレームワークの大半は、パスワードを安全に保存するための機能を内蔵しています。

攻撃者は、保存されたパスワードのハッシュを取得した後、常にオフラインでハッシュをブルートフォースすることができます。防御側としては、可能な限りリソースを消費するハッシュアルゴリズムを選択することで、オフライン攻撃を遅らせることができるだけです。

このチートシートでは、パスワードの保存に関連して考慮しなければならない様々な分野についてのガイダンスを提供します。(DeepL 翻訳)

It is essential to store passwords in a way that prevents them from being obtained by an attacker even if the application or database is compromised. The majority of modern languages and frameworks provide built-in functionality to help store passwords safely.

After an attacker has aquired stored password hashes, they are always able to brute force hashes offline. As a defender, it is only possible to slow down offline attacks by selecting hash algorithms that are as resource intensive as possible.

This cheat sheet provides guidance on the various areas that need to be considered related to storing passwords.

どのハッシュ関数を選ぶ?

要件によって、適切なハッシュ関数を選択します。

ハッシュ関数の独自実装は控えましょう。

FIPS 140-2 準拠なら PBKDF2

アメリカのFIPS 140-2(暗号モジュールのためのセキュリティ要求) 準拠が必要な場合、PBKDF2 を利用します。

以下を指定します。

  • 擬似乱数関数(PRF)
  • ストレッチング回数

PRF に NIST 推奨の HMAC-SHA-256 を用いる場合、310,000回繰り返します。

Python から使う

Python の passlib のライブラリからは、passlib.hash.pbkdf2_sha256 関数を使います。

インストール

$ python3 -m pip install passlib

ハッシュ化

>>> from passlib.hash import pbkdf2_sha256
>>> # generate new salt, hash password
>>> hash = pbkdf2_sha256.using(rounds=310000).hash("password")
>>> hash
'$pbkdf2-sha256$310000$B0CIESIEAACA0Nrb2xsjpA$mj0kEF.otr1BMQvx9p0YudBgml2qraJzQ.FhWBwFVMg'
>>> hash.split('$')
['',
 'pbkdf2-sha256', # アルゴリズム
 '310000',        # ストレッチング回数
 'B0CIESIEAACA0Nrb2xsjpA', # ソルト
 'mj0kEF.otr1BMQvx9p0YudBgml2qraJzQ.FhWBwFVMg'] #ダイジェスト

>>> # verify password
>>> pbkdf2_sha256.verify("password", hash)
True

デフォルトのストレッチング回数が 29000 回だったため、310,000 回に増やしています。

制約がないなら Argon2

制約がない場合、Argon2 を利用します。

Argon2 は 2015年の Password Hashing Competition で1位を獲得した比較的新しいハッシュ関数です。

  • GPU や専用のハードウェア(FPGA/ASIC) 攻撃に強い Argon2d
  • サイドチャンネル攻撃に強い Argon2i
  • Argon2d と Argon2i のハイブリッド型の Argon2id

と3つのモードがあり、特殊なアプリケーション要件がない限り、ハイブリッド型の Argon2id を使用します。 接尾辞 の d/i は、メモリアクセスが入力データに依存する(dependet)/しない(independet)を表します。

特殊な要件の例として、暗号通貨のマイニングには Argon2d が向いています。

以下を指定します。

  • 使用するメモリ量(m)。単位はKiB
  • 実行時間(t)
  • 並列数(p)

OWASP では以下の組み合わせが推奨されています。

  • m=15 MiB, t=2, p=1
  • m=37 MiB, t=1, p=1

時間(t)とメモリ=空間(m)にはトレードオフの関係があり、この2つは同じ強度です。

Python から使う

Python の passlib のライブラリからは passlib.hash.argon2 関数を使います。

インストール

$ python3 -m pip install passlib argon2-cffi

ハッシュ化

>>> from passlib.hash import argon2
>>> # generate new salt, hash password
>>> h = argon2.using(time_cost=2, memory_cost=15*1024, parallelism=1).hash("password")
>>> h
'$argon2id$v=19$m=15360,t=2,p=1$1TpHCAEAwDiHcA7BmPN+Dw$AzV28vxp1nfxf+IbYsKJrw'
>>> h.split('$')
['',
 'argon2id', # アルゴリズム
 'v=19',     # Argon2 のバージョン
 'm=15360,t=2,p=1', # m:メモリ。単位は KiB。t:実行時間、p :並列数
 '1TpHCAEAwDiHcA7BmPN+Dw', # ソルト
 'AzV28vxp1nfxf+IbYsKJrw'] # ダイジェスト

>>> # verify password
>>> argon2.verify("password", h)
True

デフォルトパラメーターは

  • 使用するメモリ量 : 102400 KiB = 100 MiB
  • 実行時間 : 2
  • 並列数 : 8

だったため、 OWASP の推奨値に変更しています。

参考までに、IETF ドラフト"The memory-hard Argon2 password hash and proof-of-work function" の推奨値も紹介します。

第1候補

  • 使用するメモリ量 : 2 GiB
  • 実行時間 : 1
  • 並列数 : 4

If you are OK with a uniformly safe option, but not tailored to your application or hardware, select Argon2id with t=1 iteration, p=4 lanes and m=2^(21) (2 GiB of RAM), 128-bit salt, 256-bit tag size.

This is the FIRST RECOMMENDED option.

>>> h = argon2.using(
  time_cost=1, 
  memory_cost=2**21, 
  parallelism=4, 
  salt_size=128, 
  digest_size=256).hash("password")

第2候補(メモリが潤沢にない場合)

  • 使用するメモリ量 : 64 MiB
  • 実行時間 : 3
  • 並列数 : 4

If much less memory is available, a uniformly safe option is Argon2id with t=3 iterations, p=4 lanes and m=2^(16) (64 MiB of RAM), 128-bit salt, 256-bit tag size.

This is the SECOND RECOMMENDED option.

>>> h = argon2.using(
  time_cost=3, 
  memory_cost=2**16, 
  parallelism=4, 
  salt_size=128, 
  digest_size=256).hash("password")

Argon2 を使えないなら bcrypt

Argon2 を使えない場合の選択肢が、前世紀から存在する bcrypt です。

以下を指定します。

  • ストレッチング回数:最低10(実際は 2**N 回繰り返す)

Python から使う

Python の passlib のライブラリからは passlib.hash.bcrypt 関数を使います。

インストール

$ python3 -m pip install passlib bcrypt

ハッシュ化

>>> from passlib.hash import bcrypt
>>> h = bcrypt.hash("password")
>>> h
'$2b$12$DQkDDAUCAWbl58kynw9Dn.BefrZ1mHyQeNu/yqRadCOii7BH.sjoa'
>>> h.split('$')
['',
 '2b', # アルゴリズム
 '12', # r:ストレッチング回数。
 'DQkDDAUCAWbl58kynw9Dn.BefrZ1mHyQeNu/yqRadCOii7BH.sjoa'] # {22バイトのソルト}{31バイトのダイジェスト}

>>> # verify password
>>> bcrypt.verify("password", h)
True

デフォルトパラメーターが最低要件を満たしています。

72 バイト制限を回避するには?

bcrypt 実装には 72バイトの入力制限があり、超えている場合は先頭の 72 バイトだけがハッシュに利用されます。

ハッシュ時に truncate_error=True を渡すと、入力文字列が削られている時に例外(passlib.exc.PasswordTruncateError)を投げます。

例えば、寿司の絵文字は4バイトです。この文字を使い、72バイトを超えた入力文字列を渡してみましょう。

>>> s = "?"
>>> len(s.encode())
4

# 72バイト上限いっぱい
>>> bcrypt.using(truncate_error=True).hash("?"*18)
'$2b$12$Ed7Cpo9PtRNbnu2dC9pTNu8pcCt9Fk6mnX5MyIZGXmNzT00qef8BS'

# 76バイト
>>> bcrypt.using(truncate_error=True).hash("?"*19)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    ...
    raise exc.PasswordTruncateError(cls)
passlib.exc.PasswordTruncateError: Password too long (bcrypt truncates to 72 characters)

72 バイトを超えると、エラーが発生しましたね。

この入力制限を回避するために、パスワードを一度ハッシュ化して bcrypt に渡すケースがしばしば見受けられます。

Python の passlib のライブラリの passlib.hash.bcrypt_sha256 関数がまさにこの用途であり、同関数のバージョン2では前処理のハッシュ関数として HMAC-SHA2-256 を使っています。

>>> from passlib.hash import bcrypt_sha256
>>> h = bcrypt_sha256.hash("password")
>>> h
'$bcrypt-sha256$v=2,t=2b,r=12$4xAWmMBtQ43Xb/sTVzZTa.$ipYIrEcUTTF3guiAXv02C7Vrs64bu1G'
>>> h.split('$')
['',
 'bcrypt-sha256', # アルゴリズム
 'v=2,t=2b,r=12', # v:バージョン,t:タイプ, r:ストレッチング回数。
 'cQ2zsj23rdvcurkYgo7Uv.',  # ソルト。HMAC-SHA2-256 の鍵にも利用
 'L/3FD5gEgVSwnH1xbny7XHcy1ViNxgC'] # ダイジェスト

>>> # verify password
>>> bcrypt_sha256.verify("password", h)
True

とはいえ、bcrypt が入力文字列を削る可能性があることについて、passlib は以下のようなふわっとした理解のまま、代替案として bcrypt_sha256 を提供しています。

Furthermore, bytes 55-72 are not fully mixed into the resulting hash (citation needed!).

https://passlib.readthedocs.io/en/stable/lib/passlib.hash.bcrypt.html#security-issues

パスワードをハッシュ化などで前処理すると、password shucking や 正しいハッシュ関数を選択しなかったことにより(その1その2)、安全性が低下する恐れが指摘されています。

また、bcrypt ハッシュ時に入力文字列が72バイトに削られても、ランダムな72バイトがあれば、パスワードとしては十分な強度という考えもあるようです。

より現代的な Argon2 を検討しましょう。

パスワード保存時に考慮すべきこと

以下では、パスワードを安全に保存する上で重要な考え方を紹介します。

パスワードはハッシュ化して保存

パスワードを平文のままデータベースに保存すると、データベースが不正アクセスされたときに、パスワードが丸見えです。

ユーザー認証のためには、入力されたパスワードが正しいことがわかれば十分であり(一方向)、ハッシュ値から元のパスワードを知る必要(双方向)はありません。

パスワードのハッシュ値を保存しましょう。

パスワードは平文のまま保存せずソルト化する(salting)

パスワードをそのままハッシュ化すると、同じパスワードからは同じハッシュ値が生成されます。

User Password Hash Digest
Alice asdf f5ac81
Bob asdf f5ac81

このようなハッシュ値に対しては、事前にハッシュを計算する レインボーテーブル攻撃が有効なことが知られており、一度ハッシュ値からパスワードが割り出されると、連鎖的に同じパスワードを利用している他のユーザーのパスワードも復元できてしまいます。

そのため、パスワードにユーザーごとに異なるランダムな文字列を付与してハッシュ関数の入力値に用い(HASH(PASSWORD + SALT))、同じパスワードであっても、ユーザーごとに異なるハッシュ値が出力されるようにします。

この文字列をソルトと呼びます。

User Password Salt Hash Digest
Alice asdf b3b6 a3c5d5
Bob asdf 5cdc 9732db

ソルトはハッシュ値とともに平文で保存します。

ペッパー化(peppering)でよりセキュアに

ソルト化をより安全にする隠し味がペッパー化です。

ハッシュ値とソルトを含むデータベースが不正アクセスされると、攻撃に十分な情報が揃っています。

そこで、ハッシュ値をそのままデータベースに保存するのではなく、HMAC・対称暗号化して保存します。その時に用いる鍵をペッパーと呼びます。NIST ではこのペッパーに「秘密のソルト(secret salt)」という用語があてられています。

ソルトはパスワードごとに異なる値を用いますが、ペッパーはシステム全体で共通の値です。HSMやヴォールト系サービスを用いてデータベースとは分けてセキュアに保存します。

ソルトとペッパーの違い

ソルトとペッパーの違いを簡単にまとめます。

味付け ユニーク 保存方法
ソルト パスワードごとにユニーク データベースにハッシュ値とともに平文で保存
ペッパー システム全体でユニーク データベースの外でセキュアに保存

Dropbox はソルトとペッパーを利用

次の Dropbox 技術ブログによると、2016年9月時点の Dropbox は、パスワードをソルト化とペッパー化のコンボで保存していました。

How Dropbox securely stores your passwords - Dropbox

具体的には

  1. パスワードを SHA512 でハッシュ化
  2. このハッシュ値をソルトとともに bcrypt でハッシュ化
  3. 最後にペッパーを鍵に AES256 で暗号化

というようにパスワードを多段で保護してデータベースに保存していました(AES256(BCRYPT(SHA512(PASSWORD) + SALT), PEPPER))。

※ 図はブログ記事より引用

Dropbox はあくまでもペッパーの実例として紹介しました。

bcrypt の項でお伝えしたように、bcrypt 処理前にパスワードをハッシュ化する処理を挟むと、バグや脆弱性の要因になる可能性が高まります。このアプローチは避けてください。

ストレッチング(work factors)

HASH(HASH(HASH(INPUT))) というように、ハッシュ値の計算を繰り返すことをストレッチングと呼びます。

繰り返し回数を増やすことで、ハッシュ値をクラッキングする計算コストが増えます。一方で、サーバーのCPU負荷も増えるため、バランスが大事です。

bcrypt の場合、ストレッチング回数を調整するパラメーターの最低推奨値は 10 で、実際の繰り返し回数は 2^10=1024 です。多くのハッシュ関数では、繰り返し回数を 2^N の形で指定します。

レガシーなハッシュ方法をアップグレードするには?

OWASP のガイダンスには、md5 などが使われたレガシーなハッシュ方法をアップグレードする方法も記載されています("Upgrading Legacy Hashes¶")。

シンプルな方法は、システムリプレース後の初回ログイン時に、新しいハッシュ関数でハッシュ値を更新します。

ユーザーは特別なアクションが不要な一方で、ユーザーが再ログインするまでは古いセキュアでないハッシュ値が残るという潜在リスクがあります。

このリスクを軽減するため、冬眠ユーザーのパスワードを強制的にリセットすることが考えられます。 ただし、大量のユーザーにパスワードの再設定を促すと、サポート業務に負荷がかかったり、ユーザーにシステムで不正侵入があったと疑わせてしまうリスクもあります。

別の方法としては、古いハッシュ値を新しいハッシュ関数で再ハッシュします(bcrypt(md5(PASSWORD + SALT) + SALT))。 システムリプレース後の初回ログイン時に、古いハッシュ関数を経ずに、パスワードから直接ハッシュ値を計算します。

最後に

本エントリーでは、パスワードを安全に保存するための 2021年版 OWASP ガイドラインを紹介しました。

Password Storage - OWASP Cheat Sheet Series

パスワードを安全に保存するポイントは以下です

  • 適切なハッシュ関数(Argon2/bcrypt/PBKDF2)を適切なパラメーターで実行
  • パスワードにはユーザー固有のソルトを付与してハッシュ化
  • オプションで、ハッシュ値をシステム固有の秘密の鍵(ペッパー)でHMAC・暗号化

同等の情報が IPA と IETF Common Authentication Technology Next Generation ワーキンググループのドラフトにもあります。

自分で評価をせず、巨人の肩の上に乗りましょう。

参考