パスワードを正しく保存する: bcryptとargon2
1. 概要
パスワードを間違った方法で保存することは、データベースが攻撃されたときにユーザーデータが流出する最も一般的な脆弱性の一つです。この記事は、パスワードハッシング用にMD5/SHA256を直接使用してはいけない理由、およびSpring SecurityでBCryptPasswordEncoderとArgon2PasswordEncoderを使用する方法を理解するのに役立ちます。この記事を読んだ後は、安全なPasswordEncoderを構成でき、動作をテストするテストを書くことができ、プロジェクトに適したアルゴリズムを選択できます。
2. SHA-256をそのまま使ってパスワードをハッシュ化してはいけない理由
SHA-256やSHA-512などの暗号ハッシュ関数は、できるだけ高速に実行するように設計されています。これはファイルの整合性を確認する際の利点ですが、パスワードに使用する場合は災難です。
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class NaiveHasher {
public static String sha256(String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(hash);
}
}
上記の関数は、中程度のGPU上で毎秒数十億のパスワードをハッシュ化できます。攻撃者はレインボーテーブルを持っているか、ブルートフォースするだけで元のパスワードを推測できます。特に123456やpassword1などの一般的なパスワードの場合はそうです。
2つの根本的な問題があります:
- ソルトなし: 2人のユーザーが同じパスワードを持っている場合、同じハッシュが生成され、レインボーテーブル攻撃の対象になりやすくなります。
- 非常に高速: 攻撃者を「遅く」する方法がありません。
注意: SHA-256にソルトを手動で追加し、その文字列を連結しないでください。それでも「work factor」がないため、まだ脆弱です。つまり、計算の難易度はハードウェアの性能向上に応じて増加しません。
3. パスワードハッシング関数とは何か
bcrypt、scrypt、argon2などのアルゴリズムはパスワードハッシング用に特別に設計されており、3つの特性があります:
- 自動ソルト: ハッシュ化するたびに、ランダムなソルトが生成され、出力ハッシュ文字列に直接埋め込まれます。
- 意図的に遅い(work factor): 難易度を増加させるパラメータがあり、ハードウェアが強力になった場合にブルートフォース攻撃に対抗します。
- メモリ困難(argon2/scryptの場合): 多くのRAMを消費し、GPU/ASICでの並列処理を非効率にします。
bcryptハッシュの結果は次のようになります:
$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
構造: $<version>$<cost>$<salt+hash base64>。ここで10はコストファクターで、2^10回のイテレーションを意味します。
4. Spring Securityでbcryptを使用する
4.1. 依存関係
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
BCryptPasswordEncoderはspring-security-cryptoにすでに含まれており、追加の依存関係は必要ありません。
4.2. Beanの宣言
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // コストファクター = 12
}
}
パラメータ12はイテレーション数を2^12に決定します。コストファクターが高いほど遅くなり、安全になりますが、ログイン時のCPU消費も増加します。値10~12は、ほとんどのアプリケーションにとって適切です。
4.3. 登録とログイン時の使用
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserRegistrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
public void register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword);
userRepository.save(new Customer(email, hashed));
}
public boolean authenticate(String email, String rawPassword) {
Customer customer = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("メールアドレスが見つかりません"));
return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
}
}
encode()はコール時にランダムなソルトを生成するため、同じrawPasswordでも毎回異なるハッシュが生成されます。これはバグではなく、正しい動作です。matches()は保存されたハッシュからソルトを自動抽出し、入力されたパスワードを再ハッシュ化して比較するため、ソルトを自分で管理する必要はありません。
4.4. 動作のテスト
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class BcryptPasswordEncoderTest {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
@Test
void shouldGenerateDifferentHashesForSamePassword() {
String hash1 = encoder.encode("S3cretPass!");
String hash2 = encoder.encode("S3cretPass!");
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
}
@Test
void shouldRejectWrongPassword() {
String hash = encoder.encode("S3cretPass!");
assertThat(encoder.matches("WrongPass!", hash)).isFalse();
}
}
このテストは2つの重要なことを確認しています: ランダムなソルトが異なるハッシュを生成し、異なるハッシュでもmatches()は元のパスワードを正しく認識します。
注意: bcryptは入力の長さを72バイトに制限しています。パスワードがこれより長い場合、超過部分は無視されます。通常のパスワード(72文字以下)では問題ありませんが、非常に長いパスフレーズを許可する場合はargon2を検討してください。
5. argon2の使用 — より最新の選択肢
Argon2は2015年のPassword Hashing Competitionで優勝し、OWASPから現在の最初の選択肢として推奨されています。bcryptよりも優れたGPU解読対策(メモリ困難)のおかげです。
5.1. 依存関係
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
Spring SecurityはArgon2PasswordEncoderのプロバイダーとしてBouncy Castleを使用しているため、この依存関係を追加する必要があります。
5.2. Beanの宣言
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// saltLength=16, hashLength=32, parallelism=1, memory=1<<16 (64MB), iterations=3
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
}
ファクトリメソッドdefaultsForSpringSecurity_v5_8()は、Spring Securityチームが検証した安全なパラメータ構成を返すため、メモリ/イテレーション数を自分で計算する必要はありません。
5.3. 出力の比較
$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...
構造にはバリアント(argon2id)、バージョン、メモリパラメータ(m)、イテレーション(t)、並列度(p)が含まれ、その後ソルトとハッシュが続きます。bcryptより透明性が高いです。
5.4. 2つのエンコーダを比較するテスト
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class Argon2PasswordEncoderTest {
private final Argon2PasswordEncoder encoder =
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@Test
void shouldVerifyCorrectPassword() {
String hash = encoder.encode("MyP@ssw0rd123");
assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
assertThat(encoder.matches("wrong", hash)).isFalse();
}
}
6. DelegatingPasswordEncoderで複数のエンコーダを同時にサポート
実際には、システムがアルゴリズムを時間の経過とともに変更する必要があるかもしれません(例えば、bcryptからargon2への移行)。既存のハッシュを破壊することなく。Spring Securityはこの解決策をすでに提供しています。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
createDelegatingPasswordEncoder()は、ハッシュ文字列内の{bcrypt}、{argon2}、{noop}などのプレフィックスを読み取り、正しいアルゴリズムを選択するエンコーダを作成します。デフォルトではbcryptを使用して新しいパスワードをエンコードしますが、別のアルゴリズムで作成された既存のハッシュでもmatches()は正しく動作します。
{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...
このプレフィックスのおかげで、段階的に移行できます: 既存ユーザーはログイン可能なままで、新しく登録またはパスワード変更するユーザーは最新のアルゴリズムでハッシュ化されます。
7. bcryptとargon2の簡単な比較
| 項目 | bcrypt | argon2 |
|---|---|---|
| リリース年 | 1999年 | 2015年(PHC優勝) |
| GPU/ASIC対策 | 十分 | より優れている(メモリ困難) |
| 入力制限 | 72バイト | 実質的に制限なし |
| 普及度 | 非常に高い、広くサポート | ますます普及中 |
| OWASP推奨 | 第2の選択肢 | 第1の選択肢 |
新しいプロジェクトで古いシステムに制約されていない場合は、argon2idを選択してください(argon2idバリアントはSpring Securityのデフォルトで、サイドチャネル攻撃とGPU攻撃のバランスが取れています)。既存システムが長年bcryptで安定して動作している場合は、すぐに移行する必要はありません。bcryptのコストファクターを12~14に上げても、ほとんどのアプリケーションにとって十分に安全です。
8. よくあるエラー
- ソルト+SHA-256を自分で実装する: セクション2で述べたように、「work factor」がないため、最新のブルートフォース攻撃に対して弱いままです。
- コストファクターが低すぎる:
BCryptPasswordEncoder()のデフォルトは10ですが、テスト時に4~5に下げて本番環境での変更を忘れてしまう傾向があります。 String.equals()でハッシュを比較する: ハッシュ文字列を手動で比較しないでください。常にPasswordEncoderのmatches()を使用してください。これらのライブラリは定時間比較を行い、タイミング攻撃を回避します。- ソルトを別のDB列に保存する: 不要です。bcryptとargon2の両方がソルトをハッシュ出力文字列に埋め込んでいます。
- ログインレート制限を実装しない: ハッシュがどれだけ強力でも、レート制限またはアカウントロックアウトがないと、オンラインブルートフォースが可能です。
注意: 本番環境では
{noop}エンコーダ(プレーンテキスト)を使用しないでください。ローカルテストまたはデモのみに出現する必要があります。
9. まとめ
パスワードを正しくハッシュ化することは、SHA-256のような高速ハッシュ関数ではなく、このために設計されたアルゴリズム(bcryptまたはargon2)を使用することを意味します。Spring Securityはこれを簡単にします: 単にPasswordEncoderビーンを宣言し、保存時にencode()を呼び出し、認証時にmatches()を呼び出すだけで、ソルトを自分で管理する必要はありません。アルゴリズム間での柔軟な移行が必要な場合、DelegatingPasswordEncoderは後方互換性のある安全な選択肢です。bcryptまたはargon2を選択するかにかかわらず、強力なハッシング化は単なる防御レイヤーの1つであることを忘れずに。レート制限とMFAを追加してこそ、本当に安全な認証システムが実現します。