본문 바로가기
[JAVA]

자바에서 AES 암호화 구현하기

by 황원용 2023. 10. 9.
728x90

📌 AES (Advanced Encryption Standard)

  • AES는 128비트, 192비트, 256비트의 키 크기를 지원하는 블록 암호화 알고리즘이다.
  • 안전성과 성능을 균형 있게 제공하기 위해 다양한 연산 및 변환 기법을 사용한다.

 

🤔 대칭키 (Symmetric Key) 암호화란?

  • AES는 대칭키 암호화 방식으로 작동한다.
    • 동일한 키를 사용하여 데이터의 암호화와 복호화를 수행한다는 의미이다.
  • 데이터의 기밀성을 유지하기 위해 비밀 키가 안전하게 공유되어야 한다.
    • 이것이 주된 단점인데 통신 상대 간에 비밀 키 교환의 어려움이 있다.
    • 이를 해결하기 위해 RSA와 같은 공개키를 이용하기도 한다.

 

📌 블록 암호 운용 방식(Block Cipher Modes of Operation)

 블록 암호는 특정한 길이의 블록 단위로 동작하기 때문에 가변 길이 데이터를 암호화하기 위해서는 먼저 이들을 단위 블록들로 나누어야 한다. 이때 그 블록들을 어떻게 암호화할지 정해야 하는데 이를 정의한 운용 방식이 블록 암호 운용 방식이다. 주요한 블록 암호 운용 방식에는 ECB, CBC, CFB, OFB, CTR 등이 있다.

 

ECB (Electronic Codebook) 모드

  • ECB 모드는 가장 간단하고 기본적인 운영 방식이다.
  • 평문을 고정된 크기의 블록으로 나누어 독립적으로 암호화한다.
  • 동일한 평문은 항상 동일한 암호문으로 변환되기 때문에 보안성이 낮다.
  • 작은 데이터 세트나 병렬 처리가 필요한 경우에 유용하다.

 

CBC (Cipher Block Chaining) 모드

  • CBC 모드는 이전 암호문 블록과 현재 평문 블록을 연결하여 XOR 연산을 수행하여 결과를 생성한다.
    • 배타적 논리합(eXclusive OR)이라고도 불리며, 두 개의 피연산자 중 하나만이 1일 때 1을 반환한다.(자세한 건 구글링😉)
  • 초기화 벡터(IV) 값이 필요하며 첫 번째 평문은 초기 벡터 값과 XOR 연산을 수행하여 처리하고 이후의 연산은 이전 평문의 암호문을 그다음 평문의 벡터값으로 사용하는 방식으로 처리한다.
  • 패딩 및 초기화 벡터 설정 등 추가 보안 고려 사항이 필요하지만 보안상 ECB보다 안전하기 때문에 일반적으로 많이 사용한다.

 

CFB (Cipher Feedback) 모드

  • CFB 모드는 스트림 알고리즘과 유사하게 작동한다.
  • 비트 단위로 데이터를 처리하며 이전 출력 값을 입력으로 사용하여 다음 출력 값을 생성한다.
  • 실시간 통신 등 스트림 형태의 데이터 전달에 유용하다.

 

OFB (Output Feedback) 모드

  • OFB 모드도 CFB와 비슷하지만 바이트 단위로 데이터를 처리하는 차이점이 있다.
  • 스트림 형태의 출력 값을 생성하기 위해 이전 출력 값을 입력으로 사용한다.

 

CTR (Counter) 모드

  • CTR 모드는 카운터 값과 키를 결합하여 스트림 형태의 출력을 생성하는 방식이다.
  • 백터 값 대신에 카운터 값이 들어가 보안성을 높인다.
  • 병렬 처리가 가능하므로 성능 면에서 우수한 성능을 제공한다.
  • 임의 접근(랜덤 엑세스) 가능한 파일 시스템 등에서 활용된다.

 

 

📌 Java에서의 AES 대칭키 생성

  • Java에서는 javax.crypto 패키지를 사용하여 AES 대칭키를 생성할 수 있다.
  • KeyGenerator 클래스와 SecretKey 클래스 등을 활용하여 편리하게 구현할 수 있다.
  • RSA 구현과 크게 다르지 않다.

 

Java에서의 대칭키 알고리즘을 이용한 데이터 암호화

  • Cipher 클래스를 사용하여 데이터를 암호화할 수 있다.
  • 주로 ECB (Electronic Codebook), CBC (Cipher Block Chaining) 등의 모드와 PKCS5Padding 패딩을 함께 사용한다.

 

🤔 예외 처리 및 보안 고려 사항

  • 올바른 패딩 모드와 초기화 벡터(IV) 설정이 필요하다.
  • 잘못된 패딩이나 IV 값을 사용할 경우 예외가 발생할 수 있으므로 적절한 예외 처리 로직이 필요하다.
  • 비밀 키 관리 및 안전한 저장소 등 보안 요소에도 신경써야 한다.

 

🛠️ 구현해보기

⌨️ AesUtil.generator() 메서드

public static SecretKey generator(int keySize) throws NoSuchAlgorithmException {

    KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
    keyGenerator.init(keySize); // 키 크기 설정 (128, 192, 256 등)
    return keyGenerator.generateKey();
}

// 사용 예시
SecretKey secretKey = AesUtil.generator(256);
String secretKeyStr = Base64.getEncoder().encodeToString(secretKey.getEncoded());
  • "AES"라고만 지정한 경우, Java에서는 Cipher 클래스의 기본값으로 CBC (Cipher Block Chaining) 모드와 PKCS5Padding 패딩이 사용된다.
  • 키 크기의 설정은 128, 192, 256으로 나뉜다.
  • 테스트에서는 256 비트의 키 사이즈를 선택했다.
  • 키를 설정하고 keyGenerator.generate() 메서드로 SecretKey 타입의 키를 리턴한다.
  • 이후 Base64나 hex 등으로 인코딩하여 String 값의 비밀키를 얻을 수 있다.
    • 여기서는 Base64로 인코딩하는 방식을 사용했다.

 

🤖 적절한 키 크기에 대한 뤼튼의 답변

128비트 AES 키

  • 128비트 AES 키는 충분한 보안 수준을 제공하면서도 빠른 암호화 및 복호화 속도를 가진다.
  • 많은 애플리케이션에서 기본적으로 사용되는 키 크기이다.
  • 대부분의 보안 요구사항을 충족시킬 수 있으며, 일반적으로 권장되는 기본 옵션이다.


192비트 AES 키

  • 192비트 AES 키는 추가적인 보안 강도를 제공한다.
  • 일부 규정 준수 요구사항이나 민감한 데이터 처리에 사용될 수 있다.
  • 암호화 및 복호화 속도가 128비트보다 느릴 수 있으므로 성능에 조금 영향을 줄 수 있다.


256비트 AES 키

  • 256비트 AES 키는 가장 높은 보안 강도를 제공한다.
  • 매우 민감한 데이터나 고도로 보안이 필요한 시스템에서 사용된다.
  • 암호화 및 복호화 속도가 상대적으로 느리고, 메모리 사용량이 증가할 수 있다.

 

 

⌨️ 암호화 메서드

public static String encrypt(String data, String secretKeyStr) {

    try {
    	// [1] 
        byte[] secretKeyBytes = Base64.getDecoder().decode(secretKeyStr);
		
        // [2-1] 시크릿 키의 16바이트를 초기화 벡터 값으로 사용 
        byte[] initVector = new byte[16]; 
        System.arraycopy(secretKeyBytes, 0, initVector, 0, initVector.length);
        
        // [3] 
        IvParameterSpec iv = new IvParameterSpec(initVector);

        // [4] 
        SecretKeySpec keySpec = new SecretKeySpec(secretKeyBytes, "AES");

        // [5] 
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);

        byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));

        // [6] 
        return Base64.getEncoder().encodeToString(encryptedBytes);
    } catch (Exception e) {
        throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
    }
}

public static EncryptedData encryptWithRandomIv(String data, String secretKeyStr) {
    try {
        byte[] secretKeyBytes = Base64.getDecoder().decode(secretKeyStr);

        // [2-2] 16바이트의 랜덤한 초기화 벡터 값을 생성 
        SecureRandom random = new SecureRandom();
        byte[] initVector = new byte[16]; 
        random.nextBytes(initVector);

        IvParameterSpec iv = new IvParameterSpec(initVector);

        SecretKeySpec keySpec = new SecretKeySpec(secretKeyBytes, "AES");

        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);

        byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));

        String encryptedDataStr = Base64.getEncoder().encodeToString(encryptedBytes);
        
        // [7]
        return new EncryptedData(encryptedDataStr, initVector);
    } catch (Exception e) {
        throw new RuntimeException(e); // 필요에 따라 예외 처리를 합니다
    }
}

private static class EncryptedData {
    private String encryptedDataStr;
    private byte[] initVector;

    public EncryptedData(String encryptedDataStr, byte[] initVector) {
        this.encryptedDataStr = encryptedDataStr;
        this.initVector = initVector;
    }
}
  1. 위의 사용 예시에서 Base64로 인코딩했으므로 대칭키(secret key)는 현재 스트링 타입이다. 이 키를 암호화에 사용하기 위해서는 SecretKey 타입으로 변경해야 한다. 이를 위해 우선 Base64로 디코딩하여 byte[] 타입으로 변경한다.
  2. 위에서 언급했듯이 자바에서 AES의 기본 설정 블록 암호 운용 모드는 CBC이다. CBC는 초기화 벡터 값이 들어가므로 이를 만들기 위한 작업이 필요하다.
    •  초기화 벡터 값 생성을 위한 작업
      • [2-1]의 경우 초기화 벡터로 시크릿 키의 처음 16바이트 값을 사용한다. 따라서 System.arraycopy 메서드로 시크릿 키의 처음 절반을 initVector 객체에 복사한다. 
        • 위에서 32바이트(256비트) 사이즈의 키를 생성했으므로 키의 절반에 해당하는 바이트 배열을 초기화 벡터 값으로 사용하는 것이다.
        • 이는 [2-2]의 랜덤 생성보다는 안전하지 않은 방법인데 키 값이 노출되면 초기화 벡터값도 함께 노출되는 것이므로 ECB 모드와 같은 보안성 문제를 가지게 된다.(초기화 벡터가 무의미하게 되기 때문)
        • 그러나, 만약 시크릿 키가 일회성으로 사용된다면(재사용이 없다면) 크게 문제가 없을 것 같다는 생각이 든다.
        • 이 방법의 장점은 대칭키의 경우 키와 더불어 블록 암호 운용 모드에 따라 초기화 벡터 값도 함께 전달이 되어야 하는데 시크릿 키 하나만 암호화하여 전달하면 된다는 편리함을 가진다는 점이 있다.
        • 편리함을 위해 보안을 어느정도 포기하는 것이므로 사용에 있어 신중을 가해야 할 것이다.
        • 이런 식으로 초기화 벡터값은 얼마든지 개발자 응용하여 만들 수 있다.(보안성은 별개의 문제지만)
      • [2-2]의 경우 초기화 벡터 값을 랜덤 클래스를 이용하여 난수로 생성한다.
      • 이 경우 시크릿 키와 초기화 벡터 값을 함께 전달해야지만 복호화가 가능하다.
    • 초기화 벡터 값은 16바이트이어야 한다. 이를 지키지 않을 경우 java.security.InvalidAlgorithmParameterException: Wrong IV length: must be 16 bytes long와 같은 에러가 발생한다.
  3. 초기화 벡터를 생성
  4. SecretKey와 알고리즘으로 keySpec에 대한 객체를 생성한다.
  5. 본격적인 암호화 단계이며 파라미터 값으로 "암호화 종류/ 블록 암호 운용 모드 / 패딩 스키마"를 설정하고, 초기화를 하는데 ENCRYPT_MODE로 암호화임을 명시하고, keySpec과 초기화 벡터 값을 지정한다. 이후 암호화 할 문자열의 바이트 타입을 파라미터 값에 넣어 암호화 한 byte[] 타입의 값을 얻는다.
  6. 최종적으로 Base64로 인코딩하여 문자열 타입으로 반환한다. 
    • 이 역시 hex로 인코딩해도 무방하다.
    • 어떤 방법이든 Decrypt 시 인코딩했던 방법과 같은 방법으로 디코딩하여 복호화하면 된다.
  7. [2-2]의 경우 iv도 함께 리턴해야 하므로 따로 이너 클래스를 만들었다.
    • Map 객체에 넣는 방법도 있다.
    • 편한 대로 하면 되겠다.

 

⌨️ 복호화 메서드

public static String decrypt(String encryptedData, String secretKeyStr) throws Exception {
    // [1]
    byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
    byte[] secretKeyBytes = Base64.getDecoder().decode(secretKeyStr);

    // [2-1]
    byte[] initVector = new byte[16];
    System.arraycopy(secretKeyBytes, 0, initVector, 0, initVector.length);

    IvParameterSpec iv = new IvParameterSpec(initVector);

    // [3]
    SecretKeySpec keySpec = new SecretKeySpec(secretKeyBytes, "AES");

    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);

    byte[] decryptedBytes = cipher.doFinal(encryptedBytes);

    // [4]
    return new String(decryptedBytes, StandardCharsets.UTF_8);
}

public static String decryptWithRandomIv(String encryptedData, String secretKeyStr, byte[] initVector) throws Exception {
    byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
    byte[] secretKeyBytes = Base64.getDecoder().decode(secretKeyStr);

	// [2-2]
    IvParameterSpec iv = new IvParameterSpec(initVector);

    SecretKeySpec keySpec = new SecretKeySpec(secretKeyBytes, "AES");

    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);

    byte[] decryptedBytes = cipher.doFinal(encryptedBytes);

    return new String(decryptedBytes, StandardCharsets.UTF_8);
}
  1. decrypt의 경우 문자열 타입의 암호화된 데이터와 시크릿 키를 먼저 byte[] 타입으로 변환해 준다.
    • 이때 encrypt 시 hex로 변환했다면 hextoByte로 디코딩해줘야 한다.
  2. [2-1]의 경우 위에서 언급했듯이 SecretKey의 절반을 초기화 벡터 값으로 사용한 것이므로 시크릿 키를 절반으로 잘라 initVector 객체에 복사하는 작업이다.(System.arraycopy 메서드 사용)
    • [2-2]의 경우 파라미터로 받은 초기화 벡터 값을 그대로 사용한다.
  3. 암호화와 같은 방식이며 차이점은 init() 메서드의 첫 번째 파라미터 값이 DECRYPT_MODE라는 점이다.
  4. 리턴은 new String으로 문자열 타입으로 변환한다. 이때는 Base64나 Hex로 인코딩하지 않는다. 인코딩을 하게 되면 원본 문자열과 값이 완전히 달라지게 되기 때문이다.

 

⌨️ 테스트 메서드

public static void aesTest(String text) throws Exception {
    SecretKey secretKey = AesUtil.generator(256);
//        String secretKeyStr = Base64.getEncoder().encodeToString(secretKey.getEncoded());
    String secretKeyStr = "o7H+tnx0rvExGwSviWMRhcMjO1RzUVU5+T4GbJUyFO0=";
    String encryptedStr = AesUtil.encrypt(text, secretKeyStr);

    System.out.println("### secretKeyStr : " + secretKeyStr);
    System.out.println("### encryptedStr : " + encryptedStr);

    String decryptedStr = AesUtil.decrypt(encryptedStr, secretKeyStr);

    System.out.println("### decryptedStr : " + decryptedStr);

    System.out.println("##############################################################");

    AesUtil.EncryptedData encryptedData = AesUtil.encryptWithRandomIv(text, secretKeyStr);

    System.out.println("### encryptedStrWithRandomIv : " + encryptedData.getEncryptedDataStr());

    String decryptedStrWithRandomIv = AesUtil.decryptWithRandomIv(encryptedData.getEncryptedDataStr(), secretKeyStr,
                                                   encryptedData.getInitVector());

    System.out.println("### decryptedStrWithRandomIv : " + decryptedStrWithRandomIv);
}




// 첫번째 결과(같은 시크릿 키로 반복해서 암호화)
### secretKeyStr : o7H+tnx0rvExGwSviWMRhcMjO1RzUVU5+T4GbJUyFO0=

### encryptedStr : A2fBugLvs3morl8RcEn4oYdN6d+SpSD6MJZzrh9ik3I= // 같은 값이 나옴
### decryptedStr : 안녕하세요.

##########################################################################

### encryptedStrWithRandomIv : ZLbKOxaWq+IePRi70aUcFrwm/V6r5ohM40llqQyWNh4= // 다른 값이 나옴
### decryptedStrWithRandomIv : 안녕하세요.

// 두번째 결과(같은 시크릿 키로 반복해서 암호화)
### secretKeyStr : o7H+tnx0rvExGwSviWMRhcMjO1RzUVU5+T4GbJUyFO0=

### encryptedStr : A2fBugLvs3morl8RcEn4oYdN6d+SpSD6MJZzrh9ik3I= // 같은 값이 나옴
### decryptedStr : 안녕하세요.

##########################################################################

### encryptedStrWithRandomIv : 8OiC+Tr387OkR9SCBovsFG2Oc7aCptqv6pgZMRvmAX4= // 다른 값이 나옴
### decryptedStrWithRandomIv : 안녕하세요.

 

📜 정리

  • 테스트 결과를 보면 초기화 벡터 값에 따라 encrypt 된 문자열 값이 다른 것을 확인할 수 있다.
  • CBC와 같이 초기화 벡터를 사용하는 블록 암호 모드의 핵심은 초기화 벡터 값과 키 값이 절대로 외부에 노출되어서는 안 된다는 것이다.
  • 만약 위에 내가 만든 방식처럼 초기화 벡터 값을 시크릿 키로 이용하여 만드는 경우 시크릿 키가 노출되면 끝장이다.
  • 또한, 초기화 벡터 값이 시크릿 키에 의해 만들어지므로 정적인 값이 유지된다. 이는 같은 문자열을 암호화할 때 계속 같은 암호문이 생성된다는 뜻이므로 역추적의 가능성을 열어두기 때문에 잘 생각하여 사용해야 한다.
    • 만약 일회성으로 시크릿 키를 만들고 사용한 후 재사용하지 않는다면 비교적 안전한 방법이 될 수 있을 것 같다.
  • 최선은 초기화 벡터 값 역시 랜덤으로 생성하여 암복호화하는 것이다.

 

 

참고

뤼튼

https://ko.wikipedia.org/wiki//블록_암호_운용_방식

728x90