[백엔드] 보안 및 암호화 관련 필수 지식

목차

1. 단방향 암호화

1. 단방향 암호화의 개념

  • 암호화된 데이터를 다시 평문으로 복호화할 수 없는 암호화 방식을 의미.
  • 주로 해시(Hash) 함수를 사용하여 데이터를 고정된 길이의 해시 값으로 변환.
    • 대표적인 알고리즘: SHA-256, BCrypt 등.
  • 입력값이 같다면 항상 동일한 해시 값이 출력되므로, 이를 통해 데이터의 일치 여부를 검증함.
  • 주의사항: 출력된 해시 값의 길이가 길다고 해서 무조건 안전한 것은 아님.
    • 입력값 자체가 ‘123456’처럼 단순하고 짧으면, 생성 가능한 해시 값의 경우의 수도 적어짐.
    • 따라서 해시 길이 자체가 암호화 강도를 절대적으로 보장하지는 않음.

2. 충돌 저항성 (Collision Resistance)

  • 서로 다른 두 입력값이 우연히 동일한 해시값을 갖는 경우(충돌)를 찾아내는 것이 현실적으로 불가능해야 한다는 성질임.
  • 이론적으로는 해시 충돌을 찾을 가능성이 존재하지만, 이를 계산하는 데 천문학적인 시간과 비용이 소요되어 사실상 불가능함을 의미함.
  • 강도에 따라 약한 충돌 저항성과 강한 충돌 저항성으로 분류됨.

3. 솔팅 (Salting)

  • 해시 함수 적용 전, 원본 데이터에 무작위 문자열(Salt)을 추가하여 해싱하는 기법임.
  • 동일한 원본 데이터라도 Salt에 따라 매번 다른 해시 값이 생성되므로, 레인보우 테이블(Rainbow Table) 공격을 효과적으로 방어함.
    • 레인보우 테이블: 해커가 미리 계산해 둔 입력값과 해시 값의 매핑 테이블.
  • 동작 과정
    • 1) 사용자별로 고유한 무작위 문자열(Salt) 생성 (예: 8z#k9!a).
    • 2) 비밀번호 등 입력 값의 앞이나 뒤에 Salt를 결합함.
    • 3) 결합된 문자열을 해싱함.
    • 4) DB에 생성된 해시 값과 Salt 값을 함께 저장함 (Salt 유실 시 검증 불가).
  • Salt 값 자체는 비밀이 아니므로 DB에 평문으로 저장해도 무방하나, 반드시 사용자마다 서로 다른 값을 부여해야 함.
  • (참고) BCrypt 알고리즘은 내부적으로 Salt 생성 및 관리를 자동으로 수행해주므로 개발 편의성과 보안성이 높음.

4. 주요 해시 알고리즘 (SHA-256 vs BCrypt)

1) SHA-256

  • NSA(미국 국가안보국)에서 설계한 SHA-2 계열의 해시 함수.
  • 어떠한 입력 값이라도 256비트(16진수 기준 64자) 길이의 값으로 출력함.
  • 특징: 복잡한 수학 연산을 통해 결과 값을 매우 빠르게 도출함 (초당 수억~수십억 번 계산 가능).
  • 장점: 대용량 파일의 위변조 검증, 블록체인 등 데이터 무결성 확인에 효과적임.
  • 단점: 연산 속도가 너무 빨라 GPU를 이용한 무차별 대입 공격(Brute Force)에 취약하므로, 비밀번호 저장용으로는 부적합함.

flowchart LR
    A[입력] --> B[SHA-256 블록 처리]
    B --> C[256-bit 해시 출력]
    

2) BCrypt

  • 1999년에 오직 비밀번호 저장을 목적으로 설계된 해시 함수임.
  • 의도적으로 해시 계산에 시간이 걸리도록 설계하여 무차별 대입 공격을 차단함.
  • 특징: 알고리즘 내부적으로 임의의 Salt를 생성하여 비밀번호와 결합 후 해싱함 (Spring Security 기본 권장).
  • 장점: 보안성이 매우 높음.
  • 단점: CPU 사용량이 많아 동시 접속자가 폭증할 경우 서버 리소스 부담이 될 수 있음.

flowchart TD
    A[비밀번호] --> B[Salt 자동 생성]
    B --> C[Cost Factor 적용]
    C --> D[반복적인 Blowfish 기반 연산]
    D --> E[Salt 포함 해시 결과]
    

(참고) 해시 연산이 빠르면 왜 위험할까?


flowchart LR
    A[공격자] --> B[비밀번호 후보 생성]
    B --> C[해시 계산]
    C --> D{해시 일치?}
    D -->|No| B
    D -->|Yes| E[비밀번호 발견]

    style C fill:#ffcccc
    
  • 공격자는 초당 수십억 번의 연산이 가능한 장비를 이용해 무차별 대입을 시도함.
  • SHA-256 같은 고속 알고리즘은 8자리 비밀번호가 단 몇 초~분 만에 뚫릴 위험이 있음.
  • 따라서 비밀번호 보호를 위해서는 연산 속도를 조절할 수 있는 BCrypt 등을 사용해야 함.

2. 양방향 암호화

1. 양방향 암호화의 개념

  • 암호화와 복호화가 모두 가능한 방식으로, SSH, HTTPs 프로토콜 등에서 널리 사용됨.
    • 대표 알고리즘: AES, RSA.
  • 키(Key)를 사용하여 암호화 및 복호화를 수행하며, 키 관리 방식에 따라 대칭키와 비대칭키로 구분됨.
    • 대칭키 방식: 암호화와 복호화에 동일한 키를 사용함.
    • 비대칭키 방식: 암호화(공개키)와 복호화(개인키)에 서로 다른 키를 사용함.

2. AES 대칭키 암호화

  • 미국 표준 기술 연구소(NIST)에서 제정한 표준 대칭키 암호화 알고리즘임.
  • 데이터를 128비트 블록으로 나누고, 복잡한 수학적 연산(치환, 이동, 믹싱, XOR)을 반복하는 블록 암호(Block Cipher) 방식임.
  • 키 길이에 따라 AES-128, AES-192, AES-256으로 나뉘며, 키가 길수록 라운드 횟수가 증가해 보안성이 강화됨.
  • 장점: 구조가 단순하여 처리 속도가 매우 빠르고, 현재까지 알려진 효율적인 해킹 방법이 없음.
  • 단점: 송신자와 수신자가 동일한 키를 공유해야 하므로, 키 배송(전달) 과정에서의 보안 취약점이 존재함.

3. RSA 비대칭키 암호화

  • 전자상거래, 인터넷 뱅킹 등에서 널리 쓰이는 공개키 기반 암호화 알고리즘임.
  • ‘매우 큰 두 소수의 곱을 구하기는 쉽지만, 그 곱을 소인수분해하는 것은 매우 어렵다’는 수학적 난제에 기반함.
  • 장점: 공개키는 공개되어도 무방하므로 키 배송 문제가 해결되며, 개인키를 이용한 전자 서명(신원 인증)이 가능함.
  • 단점: 복잡한 수학 연산으로 인해 AES 대비 처리 속도가 매우 느리며, 보안성을 위해 매우 긴 키(2048비트 이상)를 사용해야 함.

4. [실전] Java 구현 예제 (AES/RSA)

Spring Boot 4.0.2 (Java 17+) 환경에서 외부 라이브러리 없이 javax.crypto, java.security 패키지만을 활용한 구현임.

  • 변환(Transformation) 문자열 구조: 알고리즘 / 운용모드 / 패딩
  • AES-256 설정: AES/GCM/NoPadding
    • GCM 모드: 데이터의 기밀성뿐만 아니라 무결성(인증)까지 동시에 보장하며 병렬 처리가 가능함.
    • NoPadding: GCM은 스트림 암호처럼 동작하므로 패딩이 불필요함.
    • IV(초기화 벡터): 매 암호화마다 랜덤 생성하여 동일 평문도 다른 암호문이 되도록 함.
  • RSA 설정: RSA/ECB/OAEPWithSHA-256AndMGF1Padding
    • OAEP Padding: 기존 PKCS1Padding의 취약점을 보완한 최신 표준 패딩 방식임.
package com.example.demo.util;

import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

@Component
public class CryptoUtils {

    // 1. AES-256 Encryption (Symmetric Key)
    private static final String AES_ALG = "AES";
    private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding";
    private static final int GCM_IV_LENGTH = 12; // GCM 표준 권장 IV 길이
    private static final int GCM_TAG_LENGTH = 128; // 인증 태그 길이 (bits)

    // AES 암호화 (IV 자동 생성 및 포함)
    public String encryptAes(String plainText, String secretKeyStr) {
        try {
            byte[] keyBytes = secretKeyStr.getBytes(StandardCharsets.UTF_8);
            SecretKey secretKey = new SecretKeySpec(keyBytes, AES_ALG);

            byte[] iv = new byte[GCM_IV_LENGTH];
            new SecureRandom().nextBytes(iv);

            Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);

            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));

            // IV와 암호문을 결합하여 반환
            byte[] combined = new byte[iv.length + encrypted.length];
            System.arraycopy(iv, 0, combined, 0, iv.length);
            System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);

            return Base64.getEncoder().encodeToString(combined);
        } catch (Exception e) {
            throw new RuntimeException("Failed to encrypt data using AES.", e);
        }
    }

    // AES 복호화 (IV 분리 후 복호화)
    public String decryptAes(String cipherText, String secretKeyStr) {
        try {
            byte[] combined = Base64.getDecoder().decode(cipherText);
            byte[] keyBytes = secretKeyStr.getBytes(StandardCharsets.UTF_8);
            SecretKey secretKey = new SecretKeySpec(keyBytes, AES_ALG);

            byte[] iv = new byte[GCM_IV_LENGTH];
            byte[] encrypted = new byte[combined.length - GCM_IV_LENGTH];
            System.arraycopy(combined, 0, iv, 0, iv.length);
            System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length);

            Cipher cipher = Cipher.getInstance(AES_TRANSFORMATION);
            GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);

            return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("Failed to decrypt data using AES.", e);
        }
    }

    // 2. RSA Encryption (Asymmetric Key)
    private static final String RSA_ALG = "RSA";
    private static final String RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";

    // RSA 암호화 (Public Key 사용)
    public String encryptRsa(String plainText, String publicKeyBase64) {
        try {
            byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
            X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALG);
            PublicKey publicKey = keyFactory.generatePublic(spec);

            Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
            cipher.init(Cipher.ENCRYPT_MODE, publicKey);
            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            throw new RuntimeException("Failed to encrypt using RSA Public Key.", e);
        }
    }

    // RSA 복호화 (Private Key 사용)
    public String decryptRsa(String cipherText, String privateKeyBase64) {
        try {
            byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
            PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALG);
            PrivateKey privateKey = keyFactory.generatePrivate(spec);

            Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION);
            cipher.init(Cipher.DECRYPT_MODE, privateKey);
            byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText));
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("Failed to decrypt using RSA Private Key.", e);
        }
    }
}

3. HMAC 데이터 검증

HMAC (Hash-based Message Authentication Code)

  • 원본 데이터에 비밀키를 결합한 후 해시 함수를 적용하여 생성하는 메시지 인증 코드임.
  • 클라이언트가 보낸 메시지의 무결성(위변조 여부)인증(발신자 확인)을 동시에 보장하기 위해 사용됨.
  • 동작 과정
    • 1) 발신자가 원본 메시지 + 비밀키를 조합해 해시 값(MAC)을 생성함.
    • 2) 원본 메시지와 MAC을 수신자에게 전송함.
    • 3) 수신자는 자신이 가진 비밀키로 동일하게 해시 값을 생성하여, 수신된 MAC과 일치하는지 비교함.
  • 활용 사례: API 인증(헤더 서명), JWT(Signature 검증), 세션 쿠키 위변조 방지 등.
  • 장점: 구현이 단순하고 연산 효율이 좋음.
  • 단점: 송수신자가 비밀키를 안전하게 공유해야 하는 키 관리 이슈가 존재함.

[실전] Java HMAC-SHA256 구현 예제

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class HmacExample {
    private static final String HMAC_SHA256 = "HmacSHA256";

    public static String generateHmac(String message, String secretKey) {
        try {
            // 1. 알고리즘과 비밀키 설정
            SecretKeySpec signingKey = new SecretKeySpec(
                secretKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
            
            // 2. Mac 인스턴스 초기화
            Mac mac = Mac.getInstance(HMAC_SHA256);
            mac.init(signingKey);

            // 3. HMAC 계산 및 Base64 인코딩 반환
            byte[] rawHmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(rawHmac);

        } catch (Exception e) {
            throw new RuntimeException("Failed to generate HMAC.", e);
        }
    }
}

이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬