パスワードのハッシュ化 — 必要な理由・種類・実装例

セキュリティ

はじめに

業務で同僚が作成したWebサービスのコードレビューをしていた際、パスワードが平文(ひらぶん)で保存されているのを見つけました。
すぐに修正してもらいましたが、その際になぜハッシュ化が必要なのか、どのようなアルゴリズムがあるのかを説明しましたので、振り返りも兼ねてまとめてみました。

なぜハッシュ化が必要なのか

平文保存のリスク

パスワードを平文で保存した場合、以下のようなリスクがあります。

  • データベースが不正アクセスされたとき、全ユーザーのパスワードが一覧で流出する
  • 内部不正によってパスワードが盗まれる
  • 開発者が誤ってパスワードを閲覧、ログに出力、あるいはバックアップに含めてしまう可能性がある
  • 流出したパスワードは他のサービスへの不正ログイン(パスワード使い回し攻撃)に悪用される

特にパスワードの使いまわしは現実に多くのユーザーが行っているため、1つのサービスからパスワードが流出すると、他のサービスも危険にさらされる可能性があります。

ハッシュ化による対策

ハッシュ関数は、任意の入力を固定長の文字列(ハッシュ値)に変換します。
ハッシュ化には以下の性質があります。

  • 一方向性: ハッシュ値から元の入力を復元することが不可能
  • 同一入力は常に同一ハッシュ値を出力する
  • 入力が少し異なるだけでハッシュ値が大きく変わる

パスワードをハッシュ化して保存すると、データベースが流出しても元のパスワードを復元することが困難になります。
ログイン時は、入力されたパスワードを同じ手順でハッシュ化し、保存済みのハッシュ値と比較することで認証を行います。

ハッシュアルゴリズムの種類

MD5 / SHA-1(非推奨)

MD5やSHA-1はかつてよく使われていましたが、現在はパスワードの保存には適しません。

  • 計算速度が非常に速い → 総当たり攻撃(ブルートフォース)1やレインボーテーブル攻撃2に弱い
  • 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/activate

PBKDF2(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
False

bcrypt

インストール

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
False

Argon2

インストール

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-cffibcrypt ライブラリで簡単に実装できる

用語解説

  1. 総当たり攻撃(ブルートフォース攻撃): 可能なすべてのパスワードを試して正しいものを見つける攻撃手法。 ↩︎
  2. レインボーテーブル攻撃: 事前に大量のパスワードとそのハッシュ値を計算して保存しておき、ハッシュ値から元のパスワードを逆引きする攻撃手法。 ↩︎
  3. ソルト: ハッシュ化の際にパスワードに付加するランダムな値。レインボーテーブル攻撃を防止するために使用される。命名がおしゃれ。 ↩︎
  4. ストレッチング: ハッシュ化を複数回繰り返すことで、計算コストを増加させる手法。総当たり攻撃を困難にするために使用される。 ↩︎

コメント

タイトルとURLをコピーしました