2. 데이터 암호화

데이터 유출과 해킹 후 영향도를 생각했을 때 암호화한 비밀번호 값을 DB에 저장해야한다. 암호화된 상태로 저장된 비밀번호는 유출되더라도 원래 값을 알아내기 어렵고, 알아내기까지 상당한 시간이 소요되기에 비밀번호 유출로 인한 피해도를 줄일 수 있다.

 

이때 데이터를 암호화하는 방식에는 크게 단방향 암호화와 양방향 암호화가 있다.

 

2.1 단방향 암호화

단방향 암호화는 암호화한 데이터를 복호화할 수 없는 암호화 방식이다. 단방향 암호화는 해시 함수를 사용해서 데이터를 해시 값으로 변환한다. 해시 함수 알고리즘에는 SHA-256, MD5, BCrypt 등이 있다. 예시로 SHA-256 같은 경우는 원본 데이터를 어렵게 하기 위해 원본 데이터가 조금만 달라도 완전히 다른 해시 값을 생성한다.

 

단방향 암호화는 로그인 비밀번호 같은 문자열을 암호화하는 데 주로 사용되지만, 실제 암호화는 바이트 데이터를 기준으로 동작한다.

 

예시) SHA-256 알고리즘, 단방향 암호화하는 자바 코드

아래 코드에서 암호화 메서드(digest)의 입력 파라미터와 리턴 타입이 모두 바이트 배열이라는 걸 확인할 수 있다.

byte[] origin = input.getBytes("UTF-8"); // 입력 파라미터 byte 배열
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(origin); // byte 배열을 암호화한 후 byte[] 반환

 

DB 저장시 : 바이트 -> 문자열 변환, 바이트 배열을 16진수 표기법이나 Base64 표기법을 사용해 문자열로 표현.

암호화 : 문자열 -> 바이트 변환

 

예시) 문자열을 암호화해서 16진수 문자열로 변환하는 기능 구현

public static String encrypt(String input) {
    StringBuider hexString = new StringBuilder();
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(input.getBytes("UTF-8");
        for (byte b : hash) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return hexString.toString();
}

 

 

충돌 저항성(collision resistance)

충돌은 서로 다른 데이터가 동일한 해시 데이터를 갖는 경우를 말한다. 이는 해시 함수가 원본 데이터에 상관없이 일정한 길이의 해시 값을 생성하기 때문인데, 해시값 길이가 길어질 수록 충돌이 발생할 경우가 줄어들고 이 때 충돌 저항성이 생긴다고 말한다. 예시로 SHA-256과 SHA-512의 해시 값은 각각 256비트(32바이트), 512바이트(64바이트)이므로 SHA-256 대비 SHA-512가 충돌 가능성이 더 낮고, 충돌 저항성이 높다.

 

값의 비교

단방향 암호화는 해시 함수로 생성한 해시 값이 같다면 두 데이터가 같다고 간주한다. 대표적인 예시는 로그인 시 비밀번호인데, 회원가입시 입력한 비밀번호 문자열을 암호화해서 DB에 저장한 뒤 매 로그인시마다 입력한 비밀번호를 암호화한 결과를 DB에 저장된 비밀번호화 비교한다. 비교 결과가 동일하다면 로그인에 성공한다.

 

단방향 암호화는 원본 데이터로 복호화할 수 없기 때문에, 사용자가 비밀번호를 잊었을 때 기존 비밀번호를 알려주는 기능은 구현할 수 없다. 대신 시스템은 임의의 문자열로 비밀번호를 초기화하고, 사용자는 등록된 이메일이나 문자 메시지를 통해 초기화된 비밀번호를 받아 로그인하도록 하는 방식이다.

 

salt로 보안 강화하기

같은 해시 알고리즘을 사용하면 동일한 원본 데이터에 대해 항상 동일한 해시 값이 생성된다. 이 특성은 해시 값이 유출됐을 때 원본을 유추하기 쉽게 만든다. 예를들어 해커가 해킹한 해시값을 레인보우 테이블에서 비교해 원본 데이터를 유추하고 그 값으로 로그인까지 성공했다면 해커가 사용한 해시 알고리즘이 시스템 암호화 알고리즘과 동일하다는 것이고, 해커는 이를 악용해 다른 정보들까지도 유출할 수 있다.

 

  • 레인보우 테이블(rainbow table)
    • 다양한 문자열과 해시 값을 미리 계산해서 만든 표

 

이처럼 같은 원본 데이터에 대해 항상 동일한 해시 값을 생성하는 것은 보안에 취약하다. 해시 알고리즘은 이 취약점을 보완하기 위해 솔트(salt)를 사용한다. 솔트는 임의의 값이며, 암호화할 때 솔트를 함께 사용하면 솔트 값에 따라 결과 해시 값이 달라진다.

 

 

 

예시) 솔트를 사용해 암호화하는 코드

public static Sting encryptWithSalt(String input, String salt) {
    StringBuilder hexString = new StringBuilder();
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        digest.update(salt.getBytes()); // salt 추가
        byte[] hash = digest.digest(input.getBytes("UTF-8");
        for (byte b : hash) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) hexString.apend('0');
            hexString.append(hex);
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    return hexString.toString();
}

솔트를 사용해서 암호화한 해시 값은 salt 값을 포함해 해시화했기 때문에 레인보우 테이블에서 같은 원본 값을 찾기가 어렵다. 동일한 솔트와 동일한 알고리즘을 사용해서 표를 만들지 않는 한, 원본을 추측하기 어렵기 때문이다.

 

2.2 양방향 암호화

양방향 옴호화는 암호화와 복호화가 모두 가능한 방식이다. 서버에 접속할 때 사용하는 SSH 프로토콜이나 API 호출 시 사용한 HTTPS 처럼 보안이 중요한 데이터 송수신 과정에서 주료 사용된다. 대표적인 양방향 암호화 알고리즘으로는 AES, RSA가 있다.

 

양방향 암호화는 암호화 및 복호화 시 키(key)를 사용한다. 같은 알고리즘, 같은 원본 데이터라도 어떤 키를 사용하는에 따라 결과는 달라진다. 양방향 암호화는 대칭 키 방식과 비대칭 키 방식으로 나뉜다.

 

대칭키 암호화

암호화와 복호화 시 동일한 키를 사용하는 방법을 말한다. 즉, 암호화와 복호화를 수행하는 쌍이 같은 키를 공유해야 한다. 이 방식에서는 키가 유출되면 누구나 암호화된 데이터를 복호화할 수 있기 때문에 키의 보안이 매우 중요하다.

 

대칭키 암호화 알고리즘으로는 AES가 있다.

 

예시) AES 대칭 키 암호화 예

대표적인 대칭 키 암호화 알고리즘에는 AES가 있다. AES 알고리즘을 사용할 때는 다음의 두 값을 생성해서 공유한다.

  • 키(Key)
  • IV(initialization Vector, 초기화 벡터)

 

AES는 키 값으로 128bit(16byte), 192bit(24byte), 256bit(32byte) 중 하나를 사용한다. 키는 무작위로 생성해서 유추가 어려워야 한다. 예를 들어 256비트 키를 생성할 때는 무작위로 32바이트 배열을 생성하고 이를 보관한다.

 

임의의 키 생성하는 코드 예시이다.

public static byte[] generateSecretKey() throws Exception {
    KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
    keyGenerator.init(256); // 256비트 키 생성
    SecretKey secretKey = keyGenerator.generateKey(); // 키 생성
    return secretKey.getEncoded(); // 길이 32 바이트의 배열 반환
}

생성한 키는 바이너리 파일이나 문자열 형식으로 변환해서 보관하고 공유한다. 보관된 키를 이용해 암호화를 하려면, 해당 키 데이터로부터 SecretKy 객체를 만들어야 한다. 다음은 바이트 배열로부터 SecretKey를 생성하는 코드이다.

SecretKey key = new SecretKeySepc(bytes, "AES");

 

  • IV 벡터

같은 키를 사용해서 같은 데이터를 암호화하면 항상 같은 결과가 생성되고, 이는 공격자가 암호화 데이터 분석 단서를 줄 수 있어 보안적으로 위험할 수 있다. 이를 해결하기 위해 IV 벡터(초기화 벡터)를 사용한다.

 

IV는 임의의 바이트 배열로서, 암호화할 때 함께 사용되면 같은 키를 쓰더라도 결과값이 매번 달라져 패턴이 드러나는 것을 방지할 수 있다. 복호화할 때는 키와 함께 IV도 필요하기 때문에 IV 역시 안전하게 전달하거나 저장해야 한다.

 

  • 임의의 IV 생성 예제

AES 알고리즘은 길이가 16인 바이트 배열을 IV로 사용한다.

public static byte[] genIv() {
    byte[] iv = new byte[16];
    new SecrueRandom().nextBytes(iv);
    return iv;
}

 

 

  • 키, IV를 이용해 암호화 복호화하는 코드
public satic String encrypt(String plain, SecretKey key, byte[] iv) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); // 암호화 알고리즘/암호화모드/패딩 방식
    IvParameterSpec parameterSpec = new IvParameterSpec(iv);
    cipher.init(Cipher.ENCRYPT_MODE, key, parameterSpec); // 암호화 mode
    byte[] encrypted = cipher.doFinal(plain.getBytes("UTF-8")); // 암호화 실행
    return Base64.getEncoder().encodetoString(encrypted); // 바이트 배열은 Base64를 이용해 인코딩
}


public static String decrypt(String encrypted, SecretKey key, byte[] iv) throws Exception {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); // 암호화 알고리즘/암호화모드/패딩 방식
    IvParameterSpec parameterSpec = new IvParameterSpec(iv);
    cipher.init(Cipher.DECRYPT_MODE, key, parameterSpec); // 복호화 mode
    byte[] decoded = Base64.getDecoder().decode(encrypted); // 복호화 실행
    byte[] decrypted = cipher.doFinal(decoded);
    return new String(decrypted, "UTF-8");
}

단방향 암호화와 마차가지로 암복호화도 바이트 데이터를 대상으로 한다. 그래서 문자열을 암호화할 때는 바이트 배열로 변환한 뒤에 암호화를 했다. 암호화 결과인 바이트 배열을 리턴하는 타입은 3가지가 있는데, 1) 그 자체로 리턴하거나 2) Base64로 인코딩하거나 3) 16진수 표기법으로 문자열 표시하는 방법이 있다.

 

Cipher 객체 구할 때 인수 문자열에서 PKCS5Padding은 패딩 방식을 의미한다. 패딩 방식은 암호화 블록 길이에 맞지 않은 마지막 블록에 값을 채우는 방식을 의미한다. 암호화 모드에 따라 패딩이 필요 없기도 하다. 예를 들어 GCM 암호화 모드를 사용하면 패딩이 필요 없으므로 패딩 규칙으로 "NoPadding"을 사용한다.

 

 

비대칭키 암호화

암호화와 복호화 시 서로 다른 키를 사용하는 방법을 말한다. 비대칭키 암호화에서는 공개 키(public key)와 개인 키(pirvate key)를 생성한다. 암호화할 때는 공개 키, 복호화할 때는 개인 키를 사용하므로 공개 키가 유출되더라도 암호화된 데이터를 복호화할 수 었다. 따라서 대칭 키 암호화 방식에 비하면 비교적 보안적으로 안전하다고 볼 수 있다.

 

  • 공개 키
    • 누구에게나 공개할 수 있는 키
    • 데이터 암호화 시 사용
  • 개인 키
    • 키 소유자만 접근할 수 있는 키
    • 암호화된 데이터를 복호화할 때 사용

키 소유자는 (공개 키, 개인 키) 쌍을 생성한 뒤, 데이터 송신자에게 공개 키를 제공한다.

 

반대로 개인 키로 암호화하고 공개 키로 복호화할 수도 있다. 보통 개인 키로 데이터를 암호화하는 것은 신원 확인이나 서명과 같은 인증 목적으로 사용된다. 개인 키를 사용해서 인증을 수행하는 예로 SSH가 있다. SSH 서버는 개인 키를 이용한 인증 수단을 제공한다. SSH 서버에 공개 키(복호화 시 필요)를 등록하고, 클라이언트 서버에 접속할 때 개인 키(암호화 시 필요)를 이용해서 인증한다.

 

 

예시) SSH의 키 쌍을 이용한 사용자 인증 과정

클라이언트가 개인키, 서버가 공개 키를 가지고 있는 상황이다.

1. 클라이언트는 인증에 사용할 키 쌍의 ID를 서버에 전송한다.

2. 서버는 키 ID에 해당하는 공개 키를 authorized_keys 파일에서 찾는다.

3. 공개 키가 존재하면 임의의 숫자를 생성해서 공개 키로 암호화한다.

4. 암호화한 숫자를 클라이언트에 전송한다.

5. 클라이언트는 개인 키로 암호화된 숫자를 복호화한다.

6. 클라이언트는 복호화한 숫자(서버에서의 임의 숫자)와 공유 세션 키를 결합한 값의 해시를 구한다.

7. 클라이언트는 해시 값을 서버에 전송한다.

8. 서버는 클라이언트가 전송한 해시 값과 서버가 임의 숫자와 공유 세션 키로 생성한 해시 값이 같은지 비교한다.

9. 두 값이 일치하면 클라이언트를 인증한다.

 

=> 클라이언트가 보낸 id에 해당하는 공개키 서버에 존재하는지 먼저 확인,

서버가 임의의 숫자 암호화, 클라이언트가 그거 복호화하고 세션 키 결합해서 해시값으로 바꾸고

이 값을 서버가 다시 확인하면서 인증된 클라이언트임을 확인함

 

예시) RSA 알고리즘을 위한 키 쌍을 생성

비대칭 키 암호화는 공개 키 / 개인 키 쌍을 생성한 뒤에 공개 키를 공유한다.

KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048); // 키 길이 2048비트로 설정
KeyPair keyPair = keyGen.generateKeyPair();
PublicKey publicKey = keyPair.getPublic(); // 공개 키
PrivateKey privatekey = keyPair.getPrivate(); // 개인 키
byte[] publicKeyBytes = publicKey.getEncoded(); // 공개 키 바이트 배열
byte[] privateKeyBytes = privateKey.getEncoded(); // 개인 키 바이트 배열

공개키 : 바이트 배열 -> Base64 형식으로 인코딩해서 문자열로 공유하거나 바이트 배열을 파일 자체로 저장해서 공유한다.

개인키 : 바이트 배열 -> Base64 형식으로 인코딩 문자열로 공유하거나 바이트 배열 자체를 바이너리 형식으로 저장한다.

 

문자열이나 바이너리 형태로 저장한 공개 키와 개인 키는 다시 코드에서 사용할 수 있는 형태로 변한해여 한다. 

 

예시) 바이트 배열 -> PublicKey 타입, PrivateKey 타입 변환

public static KeyPair getKeyPairFromBytes(byte[] publicKeyBytes, byte[] privateKeyBytes) 
  throws NoSuchAlgorithmException, InvalidKeySpecException {
  KeyFactory keyFactory = KeyFactory.getInstance("RSA");
  PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes)); //byte[] -> PublicKey type
  PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySepc(privateKeyBytes));
  return new KeyPair(publicKey, privateKey);
}

 

예시) 공개키와 개인키로 암호화/복호화

 

+ Recent posts