Java 보안 — 암호화, 서명, TLS를 다루는 JCA-JCE 기초
비밀번호를 데이터베이스에 저장할 때 "해싱해야 한다"는 건 알겠는데, 자바에서 구체적으로 어떤 클래스를 어떻게 쓰면 되는 걸까요?
JCA(Java Cryptography Architecture) 는 해싱, 암호화, 서명, TLS 등 보안 관련 기능을 제공하는 Java 표준 프레임워크입니다. 알고리즘 이름만 지정하면 Provider가 실제 구현을 제공하는 구조라서, 알고리즘이 바뀌어도 코드를 크게 수정할 필요가 없습니다.
JCA/JCE 아키텍처
┌──────────────────────┐
│ 애플리케이션 코드 │
├──────────────────────┤
│ JCA API │ MessageDigest, Cipher, Signature, KeyStore...
│ (java.security, │
│ javax.crypto) │
├──────────────────────┤
│ SPI (Service Provider│ 실제 알고리즘 구현
│ Interface) │
├──────────────────────┤
│ Provider │ SunJCE, BouncyCastle 등
└──────────────────────┘
API 사용자는 알고리즘 이름만 지정하면, Provider가 실제 구현을 제공합니다.
해싱 — MessageDigest
기본 해싱
import java.security.MessageDigest;
public static String sha256(String input) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
// 바이트 배열을 16진수 문자열로 변환
return HexFormat.of().formatHex(hash);
}
파일 해싱
public static String hashFile(Path path) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (InputStream is = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
md.update(buffer, 0, bytesRead);
}
}
return HexFormat.of().formatHex(md.digest());
}
주요 해시 알고리즘
| 알고리즘 | 출력 크기 | 용도 |
|---|---|---|
| MD5 | 128bit | 체크섬 (보안 목적 사용 금지) |
| SHA-1 | 160bit | 레거시 (보안 목적 사용 금지) |
| SHA-256 | 256bit | 일반적인 보안 용도 |
| SHA-512 | 512bit | 더 높은 보안 |
비밀번호 해싱 — 일반 해시를 쓰면 안 되는 이유
SHA-256은 빠르게 설계된 해시 함수입니다. GPU로 초당 수십억 번 계산할 수 있기 때문에 비밀번호에 사용하면 무차별 대입 공격에 취약합니다. 비밀번호에는 의도적으로 느린 해시 함수를 사용해야 합니다.
// PBKDF2 — 반복 횟수를 높여 의도적으로 느리게 만드는 해시
public static String hashPassword(String password, byte[] salt)
throws Exception {
SecretKeyFactory factory =
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(
password.toCharArray(),
salt,
310_000, // 반복 횟수 — 높을수록 무차별 대입 비용 증가
256
);
byte[] hash = factory.generateSecret(spec).getEncoded();
return HexFormat.of().formatHex(hash);
}
솔트는 사용자마다 다르게 생성해야 합니다. 같은 비밀번호라도 솔트가 다르면 다른 해시가 나오므로, 레인보우 테이블 공격을 방어합니다.
byte[] salt = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(salt);
대칭 암호화 — Cipher (AES)
같은 키로 암호화와 복호화를 수행합니다. 현재 권장되는 모드는 AES-GCM 으로, 기밀성과 무결성을 동시에 보장합니다.
AES-GCM 암호화
핵심은 매번 새로운 IV(Initialization Vector)를 생성하는 것입니다. IV가 같으면 같은 평문이 같은 암호문이 되어 패턴이 노출됩니다.
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128;
public static byte[] encrypt(byte[] plaintext, SecretKey key)
throws Exception {
byte[] iv = new byte[GCM_IV_LENGTH];
SecureRandom.getInstanceStrong().nextBytes(iv); // ← 매번 새 IV
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] ciphertext = cipher.doFinal(plaintext);
// IV + 암호문을 합쳐서 반환 (IV는 비밀이 아님)
byte[] result = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length);
return result;
}
AES-GCM 복호화
암호문 앞부분에서 IV를 분리한 뒤 같은 키로 복호화합니다.
public static byte[] decrypt(byte[] encrypted, SecretKey key)
throws Exception {
byte[] iv = Arrays.copyOfRange(encrypted, 0, GCM_IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(encrypted, GCM_IV_LENGTH, encrypted.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
return cipher.doFinal(ciphertext); // 위변조 감지 시 AEADBadTagException 발생
}
GCM의 인증 태그 덕분에 암호문이 위변조되었는지도 자동으로 검증됩니다.
왜 ECB가 아닌 GCM인가
| 모드 | 특징 |
|---|---|
| ECB | 같은 블록 → 같은 암호문 (패턴 노출, 사용 금지) |
| CBC | IV 사용, 패턴 숨김. 하지만 무결성 보장 안 됨 |
| GCM | IV + 인증 태그 (기밀성 + 무결성 + 인증 모두 제공) |
비대칭 암호화 — RSA
공개키로 암호화, 개인키로 복호화합니다.
// 키 쌍 생성
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair keyPair = kpg.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
// 암호화 (공개키)
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encrypted = cipher.doFinal("비밀 메시지".getBytes(StandardCharsets.UTF_8));
// 복호화 (개인키)
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decrypted = cipher.doFinal(encrypted);
String message = new String(decrypted, StandardCharsets.UTF_8);
RSA는 대칭키에 비해 느리므로, 실무에서는 대칭키를 RSA로 암호화해서 교환하고, 실제 데이터는 대칭키(AES)로 암호화합니다 (하이브리드 암호화).
전자 서명 — Signature
// 서명 생성 (개인키)
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(data);
byte[] signature = signer.sign();
// 서명 검증 (공개키)
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(data);
boolean isValid = verifier.verify(signature);
서명의 목적:
- **인증 **: 서명자가 개인키 소유자임을 증명
- ** 무결성 **: 데이터가 변조되지 않았음을 확인
- ** 부인 방지 **: 서명자가 서명 사실을 부인할 수 없음
HMAC — 대칭키 기반 메시지 인증
public static byte[] hmacSha256(byte[] data, SecretKey key)
throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(key);
return mac.doFinal(data);
}
// API 요청 서명 검증
SecretKey apiKey = new SecretKeySpec(
"my-secret".getBytes(), "HmacSHA256");
byte[] expectedMac = hmacSha256(requestBody, apiKey);
boolean valid = MessageDigest.isEqual(expectedMac, receivedMac);
// 주의: Arrays.equals 대신 isEqual을 사용 (타이밍 공격 방지)
KeyStore — 키 관리
// KeyStore 생성 및 키 저장
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null); // 새 KeyStore 초기화
// 비밀키 저장
ks.setKeyEntry("my-aes-key", secretKey,
"password".toCharArray(), null);
// 파일로 저장
try (OutputStream os = Files.newOutputStream(Path.of("keystore.p12"))) {
ks.store(os, "storePassword".toCharArray());
}
// 키 로드
try (InputStream is = Files.newInputStream(Path.of("keystore.p12"))) {
ks.load(is, "storePassword".toCharArray());
}
SecretKey loaded = (SecretKey) ks.getKey(
"my-aes-key", "password".toCharArray());
TLS/HTTPS
SSLContext 설정
// 커스텀 TrustStore로 HTTPS 클라이언트 설정
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (InputStream is = Files.newInputStream(Path.of("truststore.p12"))) {
trustStore.load(is, "password".toCharArray());
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), new SecureRandom());
// HttpClient에 적용
HttpClient client = HttpClient.newBuilder()
.sslContext(sslContext)
.build();
mTLS (상호 인증)
// 클라이언트 인증서 설정
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(Files.newInputStream(Path.of("client.p12")),
"password".toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(
KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, "password".toCharArray());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(),
new SecureRandom());
SecureRandom — 암호학적 난수
// 기본 사용
SecureRandom random = new SecureRandom();
byte[] token = new byte[32];
random.nextBytes(token);
// 강력한 인스턴스 (블로킹될 수 있음)
SecureRandom strong = SecureRandom.getInstanceStrong();
// 토큰 생성
String sessionToken = HexFormat.of().formatHex(token);
java.util.Random은 시드를 알면 전체 시퀀스를 예측할 수 있으므로, 보안 목적으로는 반드시 SecureRandom을 사용합니다.
주의할 점
ECB 모드로 암호화하면 패턴이 그대로 노출된다
ECB(Electronic Codebook) 모드는 같은 평문 블록을 같은 암호문 블록으로 변환합니다. 이미지처럼 반복 패턴이 있는 데이터를 ECB로 암호화하면, 원본의 윤곽이 암호문에서도 보입니다. Cipher.getInstance("AES")만 쓰면 기본값이 AES/ECB/PKCS5Padding이므로 반드시 모드를 명시해야 합니다.
키를 소스 코드에 하드코딩하면 Git 히스토리에 영원히 남는다
한 번이라도 커밋된 키는 git filter-branch로 제거해도 이미 clone된 복사본에는 남아 있습니다. 환경 변수, KeyStore, AWS Secrets Manager 같은 시크릿 매니저를 사용해야 합니다.
MAC 비교 시 Arrays.equals를 쓰면 타이밍 공격에 노출된다
Arrays.equals()는 불일치를 발견하면 즉시 반환합니다. 공격자가 응답 시간 차이를 측정하면 올바른 MAC 값을 한 바이트씩 추론할 수 있습니다. MessageDigest.isEqual()은 항상 모든 바이트를 비교하므로 타이밍 공격을 방어합니다.
정리
| 항목 | 설명 |
|---|---|
| JCA/JCE | 알고리즘 독립적 보안 API. Provider가 실제 구현을 제공 |
| MessageDigest | 해싱 (SHA-256). 비밀번호에는 PBKDF2/bcrypt/Argon2 사용 |
| Cipher (AES-GCM) | 대칭 암호화. 기밀성 + 무결성 + 인증 동시 제공 |
| Signature | 전자 서명. 개인키로 서명, 공개키로 검증 |
| KeyStore | 키/인증서 안전 보관. PKCS12 형식 권장 |
| SecureRandom | 암호학적 난수 생성. java.util.Random은 보안 목적 사용 금지 |