비밀번호 해시 역할
PasswordHasher를 hash·matches 계약으로 확장하고 테스트 대역과 PBKDF2 어댑터, 로그인 실패 경계를 같은 실행 가능한 계약으로 검증합니다.
앞 문서의 PasswordHasher는 회원가입에 필요한 hash 하나만 가졌습니다.
로그인을 추가하려면 저장된 해시와 사용자가 입력한 원문이 일치하는지 확인하는 matches가 필요합니다.
인터페이스에 추상 메서드를 추가하면 기존 구현은 컴파일되지 않습니다.
회원가입 서비스는 여전히 hash만 호출하므로 바뀌지 않지만, TestPasswordHasher와 운영 어댑터는 확장 계약을 모두 구현해야 합니다.
hash와 matches를 같은 포트에 배치
hash는 저장할 인코딩을 만들고 matches는 그 인코딩을 해석해 원문 후보를 검증합니다.
호출자는 salt·비용·버전·결과 바이트 형식을 파싱하지 않습니다.
package board.member;
public interface PasswordHasher {
String hash(String rawPassword);
boolean matches(String rawPassword, String encodedPasswordHash);
}두 메서드는 원문 비밀번호를 strip() 하거나 대소문자 변환하지 않습니다.
가입 정책은 SignUpCommand가 검사하고, 해시 포트는 전달받은 정확한 문자열을 변환·검증합니다.
테스트 대역도 확장 계약 구현
앞 문서의 테스트 대역은 Java 문자열 해시 코드를 사용하므로 충돌 저항성, salt, 비밀번호 저장 안전성을 제공하지 않습니다.
matches를 추가해 서비스 흐름은 검증할 수 있지만 실제 인증 보안을 증명하지는 않습니다.
package board.member;
import java.util.Objects;
public final class TestPasswordHasher implements PasswordHasher {
private static final int MAX_RAW_PASSWORD_LENGTH = 1_024;
private static final int MAX_ENCODED_LENGTH = 64;
@Override
public String hash(String rawPassword) {
Objects.requireNonNull(rawPassword, "rawPassword must not be null");
if (rawPassword.length() > MAX_RAW_PASSWORD_LENGTH) {
throw new IllegalArgumentException("rawPassword is too long");
}
return "{test}" + Integer.toHexString(rawPassword.hashCode());
}
@Override
public boolean matches(String rawPassword, String encodedPasswordHash) {
if (rawPassword == null
|| rawPassword.length() > MAX_RAW_PASSWORD_LENGTH
|| encodedPasswordHash == null
|| encodedPasswordHash.length() > MAX_ENCODED_LENGTH) {
return false;
}
return encodedPasswordHash.equals(hash(rawPassword));
}
}이 대역의 equals 비교는 빠르고 결정적인 서비스 테스트만을 위한 것이며, 문자열 해시 코드 자체나 비교가 운영 인증에 안전하다는 뜻은 아닙니다.
PBKDF2 정책 값
운영 예제는 JDK의 PBKDF2WithHmacSHA256을 사용합니다.
정책 값은 생성 시 검증하고, 저장 문자열이 요구할 수 있는 반복 횟수·salt·결과 길이에도 상한을 둡니다.
아래 600,000회는 이 교재의 예제 정책값이며 모든 환경에 영구히 맞는 보편값이라는 뜻은 아닙니다.
이 v1은 더 약한 반복 횟수를 호환 목적으로 남기지 않고 현재 하한인 600,000회부터 허용합니다.
package board.member;
public record PasswordHashSettings(
int iterations,
int saltLengthBytes,
int hashLengthBytes
) {
public static final int MIN_ITERATIONS = 600_000;
public static final int MAX_ITERATIONS = 5_000_000;
public PasswordHashSettings {
if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS
|| saltLengthBytes < 16 || saltLengthBytes > 64
|| hashLengthBytes < 32 || hashLengthBytes > 64) {
throw new IllegalArgumentException(
"password hash settings are outside supported ranges");
}
}
public static PasswordHashSettings productionDefaults() {
return new PasswordHashSettings(600_000, 16, 32);
}
}설정 상한을 올릴 때는 공격자가 조작한 저장 문자열이 과도한 계산을 요구하지 않도록 검증 상한도 의도적으로 검토해야 합니다.
self-describing PBKDF2 어댑터
저장 형식은 알고리즘, 버전, 반복 횟수, salt, 결과를 $pbkdf2-sha256$v1$600000$<salt-base64url>$<hash-base64url>처럼 함께 기록합니다.
matches는 전체 길이와 고정 표식부터 확인하고, 반복 횟수 범위와 Base64url 정규형, salt·결과의 정확한 바이트 길이를 모두 통과한 뒤에만 PBKDF2를 실행합니다.
package board.member;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
public final class Pbkdf2PasswordHasher implements PasswordHasher {
private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
private static final String FORMAT = "pbkdf2-sha256";
private static final String VERSION = "v1";
private static final int MAX_RAW_PASSWORD_LENGTH = 1_024;
private static final int MAX_ENCODED_LENGTH = 512;
private static final Base64.Encoder ENCODER =
Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
private final PasswordHashSettings settings;
private final SecureRandom random;
public Pbkdf2PasswordHasher(PasswordHashSettings settings) {
this(settings, new SecureRandom());
}
Pbkdf2PasswordHasher(PasswordHashSettings settings, SecureRandom random) {
this.settings = Objects.requireNonNull(
settings, "settings must not be null");
this.random = Objects.requireNonNull(
random, "random must not be null");
}
@Override
public String hash(String rawPassword) {
requireHashInput(rawPassword);
byte[] salt = new byte[settings.saltLengthBytes()];
random.nextBytes(salt);
byte[] derived = null;
try {
derived = derive(rawPassword, salt, settings.iterations());
return "$%s$%s$%d$%s$%s".formatted(
FORMAT, VERSION,
settings.iterations(),
ENCODER.encodeToString(salt),
ENCODER.encodeToString(derived));
} finally {
clear(salt);
clear(derived);
}
}
@Override
public boolean matches(String rawPassword, String encodedPasswordHash) {
if (rawPassword == null
|| rawPassword.length() > MAX_RAW_PASSWORD_LENGTH) {
return false;
}
ParsedHash parsed = parse(encodedPasswordHash);
if (parsed == null) {
return false;
}
byte[] derived = null;
try {
derived = derive(rawPassword, parsed.salt(), parsed.iterations());
return MessageDigest.isEqual(parsed.hash(), derived);
} finally {
clear(derived);
clear(parsed.salt());
clear(parsed.hash());
}
}
private ParsedHash parse(String encodedPasswordHash) {
if (encodedPasswordHash == null
|| encodedPasswordHash.length() > MAX_ENCODED_LENGTH) {
return null;
}
String[] parts = encodedPasswordHash.split("\\$", -1);
if (parts.length != 6
|| !parts[0].isEmpty()
|| !FORMAT.equals(parts[1])
|| !VERSION.equals(parts[2])
|| !parts[3].matches("[1-9][0-9]{0,7}")) {
return null;
}
int iterations = Integer.parseInt(parts[3]);
if (iterations < PasswordHashSettings.MIN_ITERATIONS
|| iterations > PasswordHashSettings.MAX_ITERATIONS) {
return null;
}
byte[] salt = decodeCanonical(parts[4]);
byte[] hash = decodeCanonical(parts[5]);
if (salt == null || hash == null
|| salt.length != settings.saltLengthBytes()
|| hash.length != settings.hashLengthBytes()) {
clear(salt);
clear(hash);
return null;
}
return new ParsedHash(iterations, salt, hash);
}
private static byte[] decodeCanonical(String encoded) {
try {
byte[] decoded = DECODER.decode(encoded);
if (!ENCODER.encodeToString(decoded).equals(encoded)) {
clear(decoded);
return null;
}
return decoded;
} catch (IllegalArgumentException ignored) {
return null;
}
}
private byte[] derive(String rawPassword, byte[] salt, int iterations) {
char[] password = rawPassword.toCharArray();
var spec = new PBEKeySpec(password, salt, iterations,
settings.hashLengthBytes() * Byte.SIZE);
try {
return SecretKeyFactory.getInstance(ALGORITHM)
.generateSecret(spec)
.getEncoded();
} catch (GeneralSecurityException exception) {
throw new IllegalStateException(
"PBKDF2 is unavailable", exception);
} finally {
spec.clearPassword();
Arrays.fill(password, '\0');
}
}
private static void requireHashInput(String rawPassword) {
Objects.requireNonNull(rawPassword, "rawPassword must not be null");
if (rawPassword.length() > MAX_RAW_PASSWORD_LENGTH) {
throw new IllegalArgumentException("rawPassword is too long");
}
}
private static void clear(byte[] value) {
if (value != null) {
Arrays.fill(value, (byte) 0);
}
}
private record ParsedHash(int iterations, byte[] salt, byte[] hash) { }
}형식 오류와 지원하지 않는 버전, 비정상 길이·비용은 false로 수렴합니다.
반면 JDK에 알고리즘이 없는 환경은 입력 실패가 아니라 서버 구성 실패이므로 IllegalStateException으로 구분합니다.
PORT EXTENSION · ENCODED FORMAT
hash와 matches는 같은 포트의 두 방향이다
회원가입은 인코딩을 만들고 로그인은 같은 인코딩을 검증합니다. 서비스는 저장 형식을 파싱하지 않고 활성 어댑터에 두 방향을 모두 맡깁니다.
HASH-ONLY → HASH + MATCHES
포트 확장은 구현을 깨뜨리고 호출 서비스는 그대로 둔다
-
B49 계약
PasswordHasher.hash(rawPassword)만 있어 회원가입 인코딩을 만듭니다. -
의도적인 source break
matches(raw, encoded)를 추가하면 기존 구현은 새 메서드를 구현할 때까지 컴파일되지 않습니다. -
두 어댑터 갱신
TestPasswordHasher와Pbkdf2PasswordHasher가 같은 확장 포트를 구현합니다. -
클라이언트 영향 분리
MemberRegistrationService는 계속hash만,LoginService는matches만 호출합니다.
| 메서드 | 호출 시점 | 입력 | 출력·소유 책임 |
|---|---|---|---|
| hash | 가입·비밀번호 변경 | 변형하지 않은 원문 | 어댑터가 self-describing 인코딩 생성 |
| matches | 로그인 검증 | 변형하지 않은 원문 + 저장 인코딩 | 어댑터가 형식을 파싱하고 boolean 반환 |
| 검증 문 | 허용 조건 | 실패 결과 |
|---|---|---|
| 형식·버전 | $pbkdf2-sha256$v1$… · 전체 512자 이하 |
false · 파서 상세 비노출 |
| 반복 횟수 | 600000–5000000 범위의 정수 |
|
| Base64url | 패딩 없는 정규 인코딩으로 다시 썼을 때 동일 | |
| 바이트 길이 | 활성 설정의 salt·hash 길이와 정확히 일치 |
| 구현 | 같은 계약 | 구현별 보장 | 사용 범위 |
|---|---|---|---|
| TestPasswordHasher | hash + matches |
빠르고 결정적 · 충돌 가능 | 오케스트레이션 테스트 전용 |
| Pbkdf2PasswordHasher | hash + matches |
무작위 salt · 비용 · MessageDigest.isEqual |
검토된 정책의 운영 후보 |
저장 형식은 어댑터의 책임입니다. 서비스는 알고리즘·버전·비용을 읽지 않으며, 정책 갱신 판단은 별도 capability로 확장합니다.
로그인은 실해시 또는 더미 해시를 한 번 검증
이메일이 없을 때 즉시 끝내면 비밀번호 KDF 실행 여부 차이로 계정 존재를 추측할 수 있습니다.
서비스 생성 시 활성 PasswordHasher로 유효한 더미 해시를 한 번 만들고, 요청에서는 회원이 있으면 실제 해시를, 없으면 더미 해시를 선택해 matches를 정확히 한 번 호출합니다.
외부 실패 타입은 분기나 필드 없이 메시지를 고정합니다.
package board.member;
public final class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException() { super("invalid credentials"); }
}package board.member;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
public final class LoginService {
private static final int MAX_EMAIL_LENGTH = 254;
private static final String DUMMY_RAW_PASSWORD =
"dummy-password-for-timing-equalization";
private final MemberRepository members;
private final PasswordHasher passwordHasher;
private final String dummyPasswordHash;
public LoginService(MemberRepository members, PasswordHasher passwordHasher) {
this.members = Objects.requireNonNull(
members, "members must not be null");
this.passwordHasher = Objects.requireNonNull(
passwordHasher, "passwordHasher must not be null");
this.dummyPasswordHash = passwordHasher.hash(DUMMY_RAW_PASSWORD);
}
public long authenticate(String email, String rawPassword) {
Optional<Member> member = findMember(email);
String encodedHash = member.map(Member::passwordHash)
.orElse(dummyPasswordHash);
boolean passwordMatches = passwordHasher.matches(rawPassword, encodedHash);
if (member.isEmpty() || !passwordMatches) {
throw new InvalidCredentialsException();
}
return member.orElseThrow().id();
}
private Optional<Member> findMember(String email) {
if (email == null || email.length() > MAX_EMAIL_LENGTH) {
return Optional.empty();
}
String normalized = email.strip().toLowerCase(Locale.ROOT);
if (normalized.isEmpty() || normalized.length() > MAX_EMAIL_LENGTH) {
return Optional.empty();
}
return members.findByEmail(normalized);
}
}없는 이메일, 정규화 뒤 허용 길이를 벗어난 이메일, 틀린 비밀번호, 잘못된 저장 형식은 모두 같은 InvalidCredentialsException 메시지로 외부에 드러납니다.
더미 검증은 큰 KDF 실행 유무의 차이를 줄이지만 저장소 조회, 스케줄링, 캐시, 네트워크까지 포함한 요청 전체가 엄밀한 상수 시간이 된다고 보장하지 않습니다.
원문·저장 해시는 예외 메시지나 클라이언트 응답, 로그에 넣지 않습니다.
공통 해시 계약 테스트
공통 계약은 구현이 달라도 같은 원문은 일치하고 다른 원문은 거절하며, 앞뒤 공백을 정확한 입력으로 취급하고 잘못된 저장 형식을 안전하게 거절하는지 검증합니다.
package board.member;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
final class PasswordHasherTest {
private static final String RAW_PASSWORD = "secret-1234";
private static final PasswordHashSettings TEST_SETTINGS =
new PasswordHashSettings(600_000, 16, 32);
@Test
void 두_구현이_같은_호출_계약을_지킨다() {
for (PasswordHasher hasher : List.of(
new TestPasswordHasher(),
new Pbkdf2PasswordHasher(TEST_SETTINGS))) {
var spacedRaw = " raw password: 1234 ";
var encoded = hasher.hash(spacedRaw);
assertThat(encoded).isNotBlank().doesNotContain(spacedRaw);
assertThat(List.of(
hasher.matches(spacedRaw, encoded),
hasher.matches("wrong", encoded),
hasher.matches("raw password: 1234", encoded),
hasher.matches(RAW_PASSWORD, null),
hasher.matches(RAW_PASSWORD, "{unknown}value")))
.containsExactly(true, false, false, false, false);
}
}
@Test
void 같은_원문도_salt가_달라_인코딩이_다르다() {
var hasher = new Pbkdf2PasswordHasher(
TEST_SETTINGS, new SequenceSecureRandom());
var first = hasher.hash(RAW_PASSWORD);
var second = hasher.hash(RAW_PASSWORD);
assertThat(first).isNotEqualTo(second);
assertThat(hasher.matches(RAW_PASSWORD, first)).isTrue();
}
@Test
void 버전_비용_Base64url_바이트_경계를_파싱에서_거절한다() {
var hasher = new Pbkdf2PasswordHasher(TEST_SETTINGS);
var encoded = hasher.hash(RAW_PASSWORD);
var parts = encoded.split("\\$", -1);
var encoder = Base64.getUrlEncoder().withoutPadding();
var rejected = List.of(
encoded.replace("$v1$", "$v2$"),
encoded.replace("$600000$", "$599999$"),
encoded.replace("$600000$", "$5000001$"),
encodedWith(parts, parts[4] + "==", parts[5]),
encodedWith(parts,
encoder.encodeToString(new byte[15]), parts[5]),
encodedWith(parts, parts[4],
encoder.encodeToString(new byte[31])));
assertThat(rejected).allSatisfy(candidate ->
assertThat(hasher.matches(RAW_PASSWORD, candidate)).isFalse());
}
private static String encodedWith(String[] parts, String salt, String hash) {
return "$%s$%s$%s$%s$%s".formatted(
parts[1], parts[2], parts[3], salt, hash);
}
private static final class SequenceSecureRandom extends SecureRandom {
private int nextValue;
@Override
public void nextBytes(byte[] bytes) {
Arrays.fill(bytes, (byte) nextValue);
nextValue += 1;
}
}
}공통 계약은 보안 구현과 테스트 대역의 최소 호출 계약을 검증합니다.
서로 다른 salt를 주입했을 때의 인코딩 차이와 비용·형식 경계는 PBKDF2 전용 테스트가 별도로 소유합니다.
로그인 실패 표면과 호출 횟수 테스트
테스트는 회원 존재 여부와 이메일 유효성에 관계없이 matches가 정확히 한 번 호출되는지, 네 실패가 같은 예외로 수렴하는지 확인합니다.
package board.member;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
final class LoginServiceTest {
private static final String EMAIL = "member@example.com";
private static final String RAW_PASSWORD = "secret-1234";
private static final String DUMMY_RAW_PASSWORD =
"dummy-password-for-timing-equalization";
@Test
void 올바른_비밀번호는_회원_ID를_반환한다() {
var members = new MemoryMemberRepository();
var recording = new RecordingPasswordHasher();
Member saved = members.save(Member.signUp(
EMAIL, "회원", recording.hash(RAW_PASSWORD)));
var login = new LoginService(members, recording);
long memberId = login.authenticate(
" MEMBER@EXAMPLE.COM ", RAW_PASSWORD);
assertThat(memberId).isEqualTo(saved.id());
assertThat(recording.matchesCalls).isEqualTo(1);
}
@Test
void 네_실패는_같은_예외와_한_번의_matches로_수렴한다() {
String dummyHash = assertFailure(
null, "missing@example.com", RAW_PASSWORD);
assertThat(new TestPasswordHasher().matches(
DUMMY_RAW_PASSWORD, dummyHash)).isTrue();
assertFailure(null, "İ".repeat(254), RAW_PASSWORD);
String correctHash = new TestPasswordHasher()
.hash("correct-password");
assertFailure(correctHash, EMAIL, "wrong-password");
assertFailure("{broken}", EMAIL, RAW_PASSWORD);
}
private static String assertFailure(
String storedHash, String email, String rawPassword
) {
var members = new MemoryMemberRepository();
if (storedHash != null) {
members.save(Member.signUp(EMAIL, "회원", storedHash));
}
var recording = new RecordingPasswordHasher();
var login = new LoginService(members, recording);
assertThatThrownBy(() -> login.authenticate(email, rawPassword))
.isInstanceOf(InvalidCredentialsException.class)
.hasMessage("invalid credentials");
assertThat(recording.matchesCalls).isEqualTo(1);
return recording.lastEncodedHash;
}
private static final class RecordingPasswordHasher
implements PasswordHasher {
private final PasswordHasher delegate = new TestPasswordHasher();
private int matchesCalls;
private String lastEncodedHash;
@Override
public String hash(String rawPassword) {
return delegate.hash(rawPassword);
}
@Override
public boolean matches(String rawPassword, String encodedPasswordHash) {
matchesCalls += 1;
lastEncodedHash = encodedPasswordHash;
return delegate.matches(rawPassword, encodedPasswordHash);
}
}
}ONE VERIFY · UNIFORM FAILURE
로그인 실패와 구현 교체를 같은 계약으로 검증한다
회원은 실제 저장 값을, 부재·유효하지 않은 이메일은 활성 어댑터의 유효한 더미 해시를 한 번 검증합니다. 손상된 저장 값도 같은 외부 실패로 수렴시키되 요청 전체가 엄밀한 상수 시간이라고 과장하지 않습니다.
LOOKUP → SELECT HASH → MATCH ONCE → DECIDE
실제 해시와 더미 해시는 한 검증 지점으로 수렴한다
-
회원 조회
입력 또는 정규화 결과가 254자를 넘거나 비면 조회 전 empty로 두고, 그 밖에는
MemberRepository.findByEmail결과를 optional로 유지합니다. -
검증할 인코딩 선택
회원이 있으면 저장 해시를, 없으면 같은 활성 어댑터로 생성한 유효한 더미 해시를 고릅니다.
-
matches(raw, encoded)한 번존재·부재 두 분기 모두 정확히 한 번 호출하고 boolean을 받은 뒤 결론을 냅니다.
-
성공 또는 동일 실패
회원이 있고 일치할 때만 ID를 반환하며 나머지는
InvalidCredentialsException으로 수렴합니다.
| 상태 | matches 입력 | 호출 횟수 | 외부 결과 |
|---|---|---|---|
| 올바른 비밀번호 | 원문 후보 + 실제 저장 해시 | 1 |
회원 ID |
| 틀린 비밀번호 | 원문 후보 + 실제 저장 해시 | 1 |
invalid credentials |
| 없는 회원 | 원문 후보 + 유효한 더미 해시 | 1 |
invalid credentials |
| 유효하지 않은 이메일 | 원문 후보 + 유효한 더미 해시 | 1 |
invalid credentials |
| 잘못된 저장 형식 | 원문 후보 + 손상된 저장 값 | 1 |
invalid credentials |
REDUCED TIMING GAP
KDF 유무 차이 축소
없는 회원도 유효한 더미 인코딩을 검증하므로 큰 PBKDF2 실행 유무 차이를 줄입니다.
NOT STRICT CONSTANT-TIME
요청 전체 보장 아님
저장소·캐시·스케줄링·네트워크 차이는 남습니다. rate limit과 audit은 외부 운영 경계가 담당합니다.
| 테스트 층 | 공통 증거 | 구현별 추가 증거 |
|---|---|---|
| PasswordHasherTest | same raw true · different raw false · 공백 보존 · malformed false | 한 concrete test가 두 구현을 직접 순회 |
| 테스트 대역 | hash + matches 호출 계약 |
단순 equals 대역 · 보안성은 주장하지 않음 |
| PBKDF2 어댑터 | hash + matches 호출 계약 |
salt 차이 · bounded cost · 버전·형식 거절 |
| LoginService | success ID · 네 실패의 동일 예외 | found/missing 모두 matchesCalls == 1 |
같은 오류 형식과 더미 검증은 계정 존재 노출을 줄이는 한 층입니다. 원문·저장 해시는 메시지와 로그에서 제외하고, rate limit·audit·rehash 정책은 별도 capability와 운영 경계에 둡니다.
운영에서 남는 별도 책임
- 로그인 실패 응답은 계정 존재·비밀번호·저장 해시를 구분해 노출하지 않습니다.
- rate limit과 보안 감사는 HTTP·운영 경계에서 별도로 적용합니다.
- 해시 비용 갱신 여부는
needsRehash(encodedHash)같은 별도 capability로 표현하고LoginService가 형식을 직접 파싱하지 않게 합니다. - 운영 설정 변경과 기존 해시 호환 범위는 배포 전에 명시적으로 검증합니다.
다음 문서에서는 구현 선택이 회원가입 서비스 안에 들어갈 때 생기는 OCP·DIP 변경 파급을 다시 확인합니다.