java-recipes

ホーム セキュリティ › Sec-01

Sec-01: パスワードハッシング(PBKDF2)

javax.crypto java.security を使って、 PBKDF2 による安全なパスワードハッシングを実装します。 MD5 / SHA-1 の危険性とタイミング攻撃対策も解説します。

なぜパスワードをそのまま保存してはいけないのか

ユーザーのパスワードをデータベースに平文(そのままの文字列)で保存することは非常に危険です。 データベースが漏えいした場合、すべてのパスワードが攻撃者に知られてしまいます。 パスワードは必ず「ハッシュ化」して保存する必要があります。

しかし、MD5 や SHA-1 などの一般的なハッシュ関数はパスワード保存に適していません。 これらは「高速に計算できる」ことを目的に設計されているため、 GPU を使った総当たり攻撃(ブルートフォース攻撃)で短時間に解析されてしまいます。

PBKDF2 の仕組み

  • ストレッチング: ハッシュ計算を何万回も繰り返すことで、1回のハッシュ計算を意図的に遅くします。 OWASP は 2023 年時点で 310,000 回以上を推奨しています。
  • ソルト(salt): パスワードごとにランダムなバイト列(ソルト)を付加します。 同じパスワードでも異なるハッシュになるため、レインボーテーブル攻撃(事前計算済みハッシュ一覧を使った攻撃)を防ぎます。
  • タイミング攻撃対策: ハッシュ比較には MessageDigest.isEqual() を使います。 通常の ==equals() は最初の不一致で処理を打ち切るため、 応答時間の差から情報が漏れる恐れがあります(タイミング攻撃)。

サンプルコード

PasswordHashingSample.java
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.Base64;

public class PasswordHashingSample {

    private static final int ITERATIONS = 310_000; // OWASP 推奨(2023年)
    private static final int KEY_LENGTH = 256;     // ビット数

    // ソルト生成(毎回ランダム・16バイト以上推奨)
    public static byte[] generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[16];
        random.nextBytes(salt);
        return salt;
    }

    // PBKDF2 でパスワードハッシュを生成
    public static byte[] hashPassword(char[] password, byte[] salt)
            throws NoSuchAlgorithmException, InvalidKeySpecException {
        PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_LENGTH);
        try {
            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
            return factory.generateSecret(spec).getEncoded();
        } finally {
            spec.clearPassword(); // パスワード文字列をメモリからクリア
        }
    }

    // パスワード検証(定数時間比較でタイミング攻撃を防ぐ)
    public static boolean verifyPassword(char[] inputPassword, byte[] storedHash, byte[] salt)
            throws NoSuchAlgorithmException, InvalidKeySpecException {
        byte[] inputHash = hashPassword(inputPassword, salt);
        return MessageDigest.isEqual(inputHash, storedHash); // タイミング攻撃対策
    }

    // ❌ アンチパターン: MD5 は使ってはいけない
    public static String badHashMd5(String password) throws NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] hash = md.digest(password.getBytes());
        return Base64.getEncoder().encodeToString(hash);
    }

    public static void main(String[] args) throws Exception {
        System.out.println("=== PBKDF2 パスワードハッシュのデモ ===");

        char[] password = "MySecureP@ssw0rd".toCharArray();

        // ハッシュ生成
        byte[] salt = generateSalt();
        byte[] hash = hashPassword(password, salt);

        System.out.println("ソルト(Base64): " + Base64.getEncoder().encodeToString(salt));
        System.out.println("ハッシュ(Base64): " + Base64.getEncoder().encodeToString(hash));

        // 正しいパスワードで検証
        boolean valid = verifyPassword(password, hash, salt);
        System.out.println("正しいパスワード: " + valid); // true

        // 誤ったパスワードで検証
        boolean invalid = verifyPassword("wrongPassword".toCharArray(), hash, salt);
        System.out.println("誤ったパスワード: " + invalid); // false

        System.out.println("\n=== ❌ アンチパターン(MD5)===");
        System.out.println("MD5 ハッシュ: " + badHashMd5("password123"));
        System.out.println("※ MD5 は高速すぎて GPU による総当たり攻撃に脆弱です");
    }
}

Java 8 から PBKDF2WithHmacSHA256 が利用できます。パスワードは String ではなく char[] で扱い、使用後に clearPassword() でメモリからクリアするのが安全です。

よくあるミス・注意点

⚠️ MD5 / SHA-1 をパスワードハッシュに使う

MD5 や SHA-1 は高速に計算できるため、GPU を使うと1秒間に数十億回ものハッシュを試せます。 現代では短いパスワードなら数分で解析されます。パスワード保存には必ず PBKDF2・bcrypt・scrypt・Argon2 などの パスワードハッシュ専用アルゴリズムを使いましょう。

⚠️ ソルトを固定値にする

全ユーザーで同じソルトを使うと、同じパスワードは同じハッシュになります。 攻撃者は1つのレインボーテーブルですべてのユーザーのパスワードを解析できてしまいます。 ソルトは SecureRandom で ユーザーごとにランダム生成し、ハッシュと一緒に保存してください。

⚠️ == や equals() でハッシュ比較をする

通常の文字列比較は最初に不一致の文字を見つけた時点で処理を終了します。 このため、比較にかかる時間が入力によって変わり、応答時間の差から情報が漏れる 「タイミング攻撃」に脆弱になります。ハッシュ比較には必ずMessageDigest.isEqual() を使ってください。 これは常に一定時間で比較する「定数時間比較」を行います。

⚠️ パスワードを String で保持する

Java の String はイミュータブル(変更不可)なため、 使用後にメモリから消去できません。ガベージコレクションが実行されるまでメモリに残り続けます。 パスワードは char[] で扱い、 使用後に Arrays.fill(password, '\0')spec.clearPassword() でメモリをクリアしましょう。

テストする観点

  • 同じパスワード + 異なるソルトでハッシュを生成した場合、結果が異なること(ソルトの有効性)
  • 正しいパスワードで verifyPassword() を呼ぶと true が返ること
  • 誤ったパスワードで verifyPassword() を呼ぶと false が返ること
  • 空文字列のパスワードでもハッシュ化・検証が正常に動作すること(境界値)
  • 非常に長いパスワード(例: 1000 文字)でも正常に処理できること(境界値)
  • 生成されるソルトが毎回異なること(SecureRandom の有効性)
  • ソルトを変えると同じパスワードでも検証が false になること

GitHub でソースコードを見る →