여러 게임이 계정을 공유하는 통합 회원 시스템을 만들면서 JWT 인증과 세션 관리를 설계했다.
그 과정에서 계속 마주친 질문은 하나였다.
이 값은 복원할 일이 없는 값인가, 다시 원문으로 되돌려야 하는 값인가.
전자라면 느린 해시로 저장하고, 후자라면 AES-GCM으로 암호화한다.
이 기준을 잘못 잡으면 복호화 가능한 형태로 비밀번호를 저장하는 사고로 이어진다.
1. 비밀번호 저장
자주 헷갈리는 부분
// [위험] SHA-256으로 비밀번호 저장
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(password));
// [위험] MD5로 비밀번호 저장
var hash = MD5.HashData(Encoding.UTF8.GetBytes(password));
// [위험] AES로 비밀번호 암호화
var encrypted = Aes.Encrypt(password, key);세 방식 모두 문제가 있는데, 이유는 각각 다르다.
| 방식 | 문제 |
|---|---|
| SHA-256 | 너무 빨라서 무차별 대입에 취약 |
| MD5 | 빠름 + 충돌 발견됨 |
| AES 암호화 | 복호화 가능 → 키 유출 시 전부 노출 |
느린 해시를 써야 하는 이유
비밀번호 해시는 빨라서는 안 된다.
공격자가 초당 수십억 개를 시도할 수 있으면 어떤 비밀번호든 결국 뚫린다.
그래서 일부러 느리게 만든 해시 함수를 쓴다.
| 알고리즘 | 속도 | Salt | 권장 여부 |
|---|---|---|---|
| MD5 | 초당 수십억 | 지원 안 함 | 지양 |
| SHA-256 | 초당 수십억 | 직접 관리 필요 | 지양 |
| bcrypt | 초당 수천 | 내장됨 | 권장 |
| Argon2 | 초당 수백 | 내장됨 | 권장 |
bcrypt 사용법
// 해시 생성 (workFactor가 높을수록 느림)
var hash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12);
// 검증
var isValid = BCrypt.Net.BCrypt.Verify(inputPassword, storedHash);workFactor는 보통 12 정도면 된다.
13-14는 보안 요구사항이 높을 때 쓰고, 10 이하는 테스트용이다.
bcrypt 결과물:
$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.iANz2vJkW6LmGi
│ │ │
│ │ └── Salt + Hash
│ └── Work Factor
└── 알고리즘 버전Salt가 해시 안에 들어 있어서 따로 관리할 필요가 없다.
Argon2
bcrypt 다음 세대 알고리즘이다.
메모리 사용량도 조절할 수 있어서 GPU 공격에 더 강하다.
using Konscious.Security.Cryptography;
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = 4,
MemorySize = 65536, // 64MB
Iterations = 3
};
var hash = argon2.GetBytes(32);새 프로젝트면 Argon2를 쓰는 것이 좋고, 이미 bcrypt를 쓰고 있으면 굳이 옮길 필요는 없다.
둘 다 충분히 안전하다.
2. 데이터 암호화 - AES
비밀번호가 아닌 일반 데이터에는 대칭키 암호화를 쓴다.
대표적인 알고리즘이 AES다.
AES 모드
AES 자체는 블록 암호이고, 실제 사용할 때는 모드를 선택해야 한다.
| 모드 | 무결성 검증 | 권장 여부 |
|---|---|---|
| ECB | 지원 안 함 | 지양 |
| CBC | 지원 안 함 (별도 필요) | 레거시 호환용 |
| GCM | 지원함 | 권장 |
ECB가 안 되는 이유
같은 평문 블록이 같은 암호문을 만든다.
패턴이 그대로 드러난다.
평문: [AAA][AAA][BBB][AAA]
ECB: [XXX][XXX][YYY][XXX] ← 패턴 노출
CBC/GCM: [XXX][ZZZ][YYY][QQQ] ← 패턴 숨김"ECB 펭귄"으로 검색하면 이미지가 나온다.
펭귄 사진을 ECB로 암호화하면 윤곽이 그대로 보인다.
CBC 한계
패턴은 숨기는데 무결성 검증이 없다.
암호문 일부를 바꿔도 복호화는 성공하고, 변조된 평문이 그대로 사용된다.
CBC를 쓰려면 별도로 HMAC을 붙여야 하는데 번거롭다.
AES-GCM
GCM은 AEAD(Authenticated Encryption with Associated Data) 모드다.
암호화와 무결성 검증이 한 번에 된다.
using System.Security.Cryptography;
public static (byte[] ciphertext, byte[] tag, byte[] nonce) Encrypt(
byte[] plaintext, byte[] key)
{
var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
return (ciphertext, tag, nonce);
}
public static byte[] Decrypt(
byte[] ciphertext, byte[] key, byte[] nonce, byte[] tag)
{
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, 16);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}GCM에서는 변조된 암호문이 복호화 시점에 예외를 던진다.
그래서 별도로 HMAC을 붙일 필요가 없다.
Nonce 주의
GCM에서 같은 키로 같은 Nonce를 쓰면 보안이 깨진다.
고정 Nonce는 물론이고, 카운터 기반 Nonce도 충돌 위험이 있어 피해야 한다.
매번 랜덤 12바이트를 생성하는 것이 안전하다.
Nonce는 암호문과 같이 저장하거나 전송하면 된다.
비밀 값이 아니다.
3. 키 관리
암호화 알고리즘보다 키 관리가 더 어렵다.
하면 안 되는 것
// [지양] 코드 하드코딩
private const string Key = "my-super-secret-key-123";
// [지양] 설정 파일에 평문 저장
{
"EncryptionKey": "my-super-secret-key-123"
}
// [지양] 환경 변수에 평문 노출 (로그에 찍힐 우려)권장 방식
| 환경 | 방법 |
|---|---|
| 로컬 | .NET User Secrets, dotenv |
| 클라우드 | AWS KMS, Azure Key Vault |
| 온프레미스 | HashiCorp Vault |
AWS KMS 예시:
var kmsClient = new AmazonKeyManagementServiceClient();
var response = await kmsClient.GenerateDataKeyAsync(new GenerateDataKeyRequest
{
KeyId = "alias/my-master-key",
KeySpec = DataKeySpec.AES_256
});
var plaintextKey = response.Plaintext; // 암호화에 사용
var encryptedKey = response.CiphertextBlob; // 저장용Envelope Encryption 패턴:
- 마스터 키는 KMS가 관리
- 데이터 키는 마스터 키로 암호화해서 저장
- 실제 데이터는 데이터 키로 암호화
정리
| 상황 | 선택 |
|---|---|
| 새 프로젝트의 비밀번호 저장 | Argon2id |
| 기존 프로젝트의 비밀번호 저장 | bcrypt 유지 |
| 일반 데이터 암호화 | AES-256-GCM |
| 레거시 시스템과의 호환 | AES-CBC + HMAC |
| 키 보관 (클라우드) | KMS / Key Vault |
| 키 보관 (온프레미스 / 로컬) | Vault / User Secrets |
표에 없는 조합, 예를 들어 비밀번호를 AES로 암호화하는 방식은 처음부터 검토 대상이 아니다.
복호화가 가능하다는 사실 자체가 비밀번호 저장에서는 결함이기 때문이다.