はじめに
業務で同僚が作成したWebサービスのコードレビューをしていた際、パスワードが平文(ひらぶん)で保存されているのを見つけました。
すぐに修正してもらいましたが、その際になぜハッシュ化が必要なのか、どのようなアルゴリズムがあるのかを説明しましたので、振り返りも兼ねてまとめてみました。
なぜハッシュ化が必要なのか
平文保存のリスク
パスワードを平文で保存した場合、以下のようなリスクがあります。
- データベースが不正アクセスされたとき、全ユーザーのパスワードが一覧で流出する
- 内部不正によってパスワードが盗まれる
- 開発者が誤ってパスワードを閲覧、ログに出力、あるいはバックアップに含めてしまう可能性がある
- 流出したパスワードは他のサービスへの不正ログイン(パスワード使い回し攻撃)に悪用される
特にパスワードの使いまわしは現実に多くのユーザーが行っているため、1つのサービスからパスワードが流出すると、他のサービスも危険にさらされる可能性があります。
ハッシュ化による対策
ハッシュ関数は、任意の入力を固定長の文字列(ハッシュ値)に変換します。
ハッシュ化には以下の性質があります。
- 一方向性: ハッシュ値から元の入力を復元することが不可能
- 同一入力は常に同一ハッシュ値を出力する
- 入力が少し異なるだけでハッシュ値が大きく変わる
パスワードをハッシュ化して保存すると、データベースが流出しても元のパスワードを復元することが困難になります。
ログイン時は、入力されたパスワードを同じ手順でハッシュ化し、保存済みのハッシュ値と比較することで認証を行います。
ハッシュアルゴリズムの種類
MD5 / SHA-1(非推奨)
MD5やSHA-1はかつてよく使われていましたが、現在はパスワードの保存には適しません。
SHA-256 / SHA-512
SHA-256やSHA-512はSHA-2ファミリーに属し、現在も一般的な用途で使用されています。
しかし、これらも単体ではパスワード保存には不向きです。
- 高速すぎるため、GPUを使った総当たり攻撃に対して弱い
パスワード保存に使う場合は、後述するPBKDF2のようにソルト3とストレッチング4を組み合わせる必要があります。
PBKDF2 (Password-Based Key Derivation Function 2)
SHA-256にソルトとストレッチングを組み合わせたアルゴリズムです。
- ソルトを使用してレインボーテーブル攻撃を防止
- ストレッチング(反復計算)によって計算コストを増加させ、総当たり攻撃を困難にする
- Pythonの標準ライブラリ
hashlibで利用可能
bcrypt(推奨)
bcryptはパスワードハッシュ専用に設計されたアルゴリズムです。
- コストパラメータ(ラウンド数)によって計算速度を意図的に遅くできる
- ハッシュ値の中にソルトが含まれる
- ハードウェアが高速化しても、コストパラメータを上げることで対応できる
scrypt
scryptはbcryptをさらに改良したアルゴリズムです。
- 計算にメモリを大量に消費させることで、専用ハードウェア(ASICなど)による攻撃を困難にしている
- コストパラメータ・メモリサイズ・並列数を調整できる
Argon2(現在最も推奨)
Argon2は2015年のPassword Hashing Competition(PHC)で優勝したアルゴリズムです。
- 時間・メモリ・並列度の3つのパラメータを調整できる
- 現在のベストプラクティスとして広く推奨されている
- 3つのバリアント(Argon2d, Argon2i, Argon2id)があり、一般的にはArgon2idが推奨
Pythonでの実装例
今回は標準ライブラリで利用可能なPBKDF2、bcrypt、Argon2の3つのアルゴリズムについて、Pythonでの実装例を紹介します。
環境構築
今回は uv を使用して仮想環境を構築します。
mkdir password_hashing_example
cd password_hashing_example
uv venv
source .venv/bin/activatePBKDF2(SHA-256 + ソルト)
hashlib はPython標準ライブラリのため、追加インストールは不要です。main.py を作成し、以下の内容を記述します。
import hashlib
import os
import binascii
def hash_password(password: str) -> str:
# ランダムなソルトを生成(16バイト)
salt = os.urandom(16)
# ソルトとパスワードを組み合わせてSHA-256でハッシュ化
# PBKDF2でストレッチング(100万回)
dk = hashlib.pbkdf2_hmac(
hash_name="sha256",
password=password.encode("utf-8"),
salt=salt,
iterations=1_000_000,
)
# ソルトとハッシュ値を16進数文字列として結合して保存
return binascii.hexlify(salt).decode() + ":" + binascii.hexlify(dk).decode()
def verify_password(password: str, stored_hash: str) -> bool:
salt_hex, dk_hex = stored_hash.split(":")
salt = binascii.unhexlify(salt_hex)
dk = hashlib.pbkdf2_hmac(
hash_name="sha256",
password=password.encode("utf-8"),
salt=salt,
iterations=1_000_000,
)
return binascii.hexlify(dk).decode() == dk_hex
if __name__ == "__main__":
hashed = hash_password("my_secret_password")
print(hashed)
print(verify_password("my_secret_password", hashed)) # True
print(verify_password("wrong_password", hashed)) # False実行
uv run main.py出力
5d7acd699f9f4b57551fcb67d96d7827:ed5f3e4f22d53acf94288e4551b215b6ea516c16670eeb02cdd64e24c6f55169
True
Falsebcrypt
インストール
uv add bcrypt実装
main.py を以下の内容に更新します。
import bcrypt
def hash_password(password: str) -> bytes:
# コストパラメータは12が一般的な推奨値(大きいほど計算が遅くなる)
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode("utf-8"), salt)
def verify_password(password: str, hashed: bytes) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed)
if __name__ == "__main__":
hashed = hash_password("my_secret_password")
print(hashed)
print(verify_password("my_secret_password", hashed)) # True
print(verify_password("wrong_password", hashed)) # False実行
uv run main.py出力
b'$2b$12$tWYP.qqQo84MwgDIuwxCq.8Z6HB/SbLdxSuISZJ63v2FEco7f4EjG'
True
FalseArgon2
インストール
uv add argon2-cffi実装
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# デフォルトパラメータはArgon2id推奨値が設定済み
ph = PasswordHasher()
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
ph.verify(hashed, password)
return True
except VerifyMismatchError:
return False
if __name__ == "__main__":
hashed = hash_password("my_secret_password")
print(hashed)
print(verify_password("my_secret_password", hashed)) # True
print(verify_password("wrong_password", hashed)) # False実行
uv run main.py出力
$argon2id$v=19$m=65536,t=3,p=4$aSh/hi3677JFDNbCnY859w$HT7LypWy3HxrkZG93El8HPr9MkrSC5L7uHHOiQf+blw
True
Falseアルゴリズムの比較まとめ
| アルゴリズム | 評価 | 特徴 |
|---|---|---|
| MD5 | 非推奨 | 高速・衝突あり |
| SHA-1 | 非推奨 | 高速・衝突あり |
| SHA-256 (単体) | 非推奨 | 高速 |
| PBKDF2 (SHA-256) | 可 | Python標準ライブラリで使用可能 |
| bcrypt | 推奨 | コストパラメータで速度調整可能 |
| scrypt | 推奨 | メモリコストで専用ハードによる攻撃に強い |
| Argon2id | 最も推奨 | 時間・メモリ・並列度を調整可能 |
まとめ
- パスワードは平文でデータベースに保存してはならない
- 一方向性のハッシュ関数でハッシュ化して保存することでセキュリティを高められる
- MD5やSHA-1は速度が速すぎるためパスワード保存には不適切
- 新規開発では Argon2id を、既存システムでは bcrypt や PBKDF2 を使用することが推奨される
- Pythonでは
argon2-cffiやbcryptライブラリで簡単に実装できる


コメント