[JCA] Cipher 클래스를 이용한 AES 대칭키 암복호화

2022. 7. 22. 15:06Java/Basic

반응형
  1. Cipher
  2. 피드백 모드
    1. EBC
    2. CBC
  3. 코드 예제
    1. SealedObject 객체를 이용한 AES 암복호화
    2. Cipher의 doFinal을 이용한 암복호화

1. Cipher

1.1. 대칭키 암호 알고리즘

대칭키 암호 알고리즘은 동일한 키를 이용하여 암호화/복호화를 하는 암호 알고리즘을 의미합니다.
대칭키는 동일한 키를 정보를 교환할 당사자끼리 교환해야하며, 키가 있을 경우 암/복호화가 가능하기 때문에 이러한 성질 때문에 비밀키라고도 부릅니다.

대표적인 대칭키 암호 알고리즘으로는 AES, Blowfish, Camellia, SEED, ARIA 등이 있습니다.


1.2. Cipher

javax.crypto 패키지에 속하며 대칭키 암/복호화 기능을 제공합니다.

CipherOutputStream 을 이용하여 byte단위로 암복호화하거나 고정된 Block단위로 암/복호화합니다.
그 중, 이 포스팅에서는 블록 단위(고정된 크기)로 암호화하는 방식을 다룹니다.


Cipher 객체를 생성할 때에는 대칭키 알고리즘/피드백모드/패딩방식 순으로 입력을 해야하는데
그중 대칭키 알고리즘은 필수 입력사항이고 나머지는 설정하지 않을 경우 기본값이 지정됩니다.

피드백모드와 패딩방식의 기본값은 각각 EBC, PKCS5Padding 입니다.

final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
final Cipher cipher = Cipher.getInstance("AES"); // AES/EBC/PKCSPadding

대칭키 알고리즘

대칭키 알고리즘 중 현재 가장 많이 이용되고 있는 알고리즘은 AES(Advanced Encryption Standard) 알고리즘입니다.
AES는 대칭키 블록 암호 알고리즘 중 하나로, DES보다 암호화 키 길이가 길어서 더 안전합니다.

패딩방식

고정된 크기로 암호화하기 때문에 마지막 블록에서 고정된 크기를 채우지 못할 경우 dummy데이터를 패딩해야 합니다.
암호화 할때 dummy데이터를 패딩했던 것은 복호화할때 unpad 해야합니다.

Cipher객체 생성에서 만약 패딩 방식을 설정하지 않는다면(NoPadding) 암호화할 원문데이터가 블록을 채우지 못할 경우 아래와 같은 에러 메시지가 출력되기 때문에, 패딩종류를 설정하는 것을 권장합니다.

javax.crypto.IllegalBlockSizeException: Input length not multiple of 16 bytes
	at java.base/com.sun.crypto.provider.CipherCore.finalNoPadding(CipherCore.java:980)
	at java.base/com.sun.crypto.provider.CipherCore.fillOutputBuffer(CipherCore.java:936)
	at java.base/com.sun.crypto.provider.CipherCore.doFinal(CipherCore.java:735)
	at java.base/com.sun.crypto.provider.AESCipher.engineDoFinal(AESCipher.java:436)
	at java.base/javax.crypto.Cipher.doFinal(Cipher.java:2205)
	at java.base/javax.crypto.SealedObject.<init>(SealedObject.java:174)
	at xyz.applebox.java.cipher.BlockCipher.encrypt(BlockCipher.java:35)
	at xyz.applebox.java.cipher.BlockCipher.main(BlockCipher.java:24)
java.lang.NullPointerException: Cannot invoke "javax.crypto.SealedObject.getObject(java.security.Key)" because "data" is null
	at xyz.applebox.java.cipher.BlockCipher.decrypt(BlockCipher.java:44)
	at xyz.applebox.java.cipher.BlockCipher.main(BlockCipher.java:25)

Cipher 객체의 transformation 예시

- AES/CBC/NoPadding
- AES/CBC/PKCS5Padding
- AES/ECB/NoPadding
- AES/ECB/PKCS5Padding
- AES/GCM/NoPadding
- DESede/CBC/NoPadding
- DESede/CBC/PKCS5Padding
- DESede/ECB/NoPadding
- DESede/ECB/PKCS5Padding
- RSA/ECB/PKCS1Padding
- RSA/ECB/OAEPWithSHA-1AndMGF1Padding
- RSA/ECB/OAEPWithSHA-256AndMGF1Padding

참고: https://docs.oracle.com/en/java/javase/18/docs/specs/security/standard-names.html#cipher-algorithm-names


2. 피드백 모드

2.1. EBC

블록단위로 데이터를 암호화할 때 암호화키만을 이용하여 암호화하는 모드입니다.
암호화키만 이용하기 때문에 모든 블록에 동일한 암호화키가 적용됩니다.

각 블록마다 동일한 암호화키를 이용하기 때문에 블록 내의 내용이 동일할 경우 동일한 암호화 결과를 갖는다는 취약점이 있습니다.

다만, 모두 동일한 암호화 키를 사용하기 때문에 병렬처리가 가능하며 암복호화가 빠르다는 장점이 있습니다.

final Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"));

아래는 블록단위로 평문을 그대로 암호화하는 EBC 모드의 암호화/복호화 그림입니다.


references: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation


2.2. CBC

Cipher Block Chaining mode

EBC의 보안 취약점을 막기 위해 데이터 블록만을 이용하여 암호화하는 것이 아닌 이전블록의 암호화 결과를 활용하여 암호화하는 체이닝 모드가 등장했습니다.

CBC는 이러한 체이닝 모드의 대표적인 예입니다.


references: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation

최초의 블록을 암호화할 때에 iv(Initialization Vector) 값을 이용하고, 그 이후 블록부터는 이전 블록에서의 암호화 결과를 활용합니다.
암호화에 이용되는 iv값은 복호화 때에도 활용되기 때문에 잘 저장해 둬야합니다.


final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));

CBC 모드는 iv를 반드시 설정해야 합니다.


3. 코드 예제

3.1. SealedObject 객체를 이용한 AES 암복호화

만일, 암호화된 문자열을 어딘가에 저장할일이 없다면 SealedObject 객체를 활용하는 것도 좋습니다.
암호화할때에는 SecretKeySpec과 IvParameterSpec을 cipher 초기화시 설정해줘야하지만 복호화시엔 SecretKeySpec만 있어도 복호화가 가능하며 코드도 매우 깔끔합니다.

private static final SecureRandom random = new SecureRandom();
private static final SecretKeySpec keySpec = new SecretKeySpec("RRkZlu+gbLxrwYEyzB+yYQ==".getBytes(), "AES");

static AlgorithmParameterSpec createIvParameterSpec() {
    byte[] iv = new byte[16];
    random.nextBytes(iv);
    return new IvParameterSpec(iv);
}

static SealedObject encrypt(String data) {
    try {
        final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, createIvParameterSpec());
        return new SealedObject(data, cipher);
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}

static String decrypt(SealedObject data) {
    try {
        return data.getObject(keySpec).toString();
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}
public static void main(String[] args) {
    String data = "hello jiniworld!";
    SealedObject encData = encrypt(data);
    String decData = decrypt(encData);
    System.out.println(encData);
    System.out.println(decData);
}

출력 결과

javax.crypto.SealedObject@52d455b8
hello jiniworld!

3.2. Cipher의 doFinal을 이용한 암복호화

만일 암호화한 값을 문자열형식으로 확인하고 싶거나 DB에 저장하고 싶다면 Cipher 클래스의 doFinal() 메서드를 이용해 암호화하고, 암호화된 byte array값은 Base64 인코더나 Hex 인코더를 이용하여 문자열로 변환하면 됩니다.

ivSpec을 암호화할때마다 생성을 할 경우, 동일한 원문이어도 iv값이 바뀌어 암호문이 매번 바뀌게 됩니다.
아래의 코드는 암호화할때마다 iv를 새로 생성합니다. 이 경우 복호화를 하기 위해서는 iv값도 함께 저장해둬야합니다.

private static final SecureRandom random = new SecureRandom();
private static final SecretKeySpec secretKeySpec = new SecretKeySpec("RRkZlu+gbLxrwYEyzB+yYQ==".getBytes(), "AES");

static byte[] createIv() {
    byte[] iv = new byte[16];
    random.nextBytes(iv);
    return iv;
}

static String encrypt(String data, AlgorithmParameterSpec parameterSpec) {
    try {
        final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, parameterSpec);
        return Base64.getEncoder().encodeToString(cipher.doFinal(data.getBytes()));
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}

static String decrypt(String data, AlgorithmParameterSpec parameterSpec) {
    try {
        final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, parameterSpec);
        return new String(cipher.doFinal(Base64.getDecoder().decode(data)));
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}
public static void main(String[] args) {
    String data = "hello jiniworld!";
    byte[] iv = createIv();

    AlgorithmParameterSpec parameterSpec = new IvParameterSpec(iv);
    String encData = encrypt(data, parameterSpec);
    String decData = decrypt(encData, parameterSpec);
    System.out.println(encData);
    System.out.println(decData);
}

출력 결과

A7iZHQLWnqZ9xOaqroUpl2cqDTF837WqcmxdVOqHuIM=
hello jiniworld!

Hex 방식으로 encode, decode를 하고 싶다면 아래와 같이 설정하면 됩니다.

static String encrypt(String data, AlgorithmParameterSpec parameterSpec) {
    try {
        final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, parameterSpec);
        return String.valueOf(Hex.encode(cipher.doFinal(data.getBytes())));
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}

static String decrypt(String data, AlgorithmParameterSpec parameterSpec) {
    try {
        final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, parameterSpec);
        return new String(cipher.doFinal(Hex.decode(data)));
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}
a61cb8cd642e09173c0a2872666c549134047ac5df5b496cadd04a5c91a8f251
hello jiniworld!

Hex 방식으로 encode할 경우 Base64방식보다 암호화된 문자열이 깁니다.

728x90
반응형