본문 바로가기
[JAVA]

자바에서 RSA 키 생성, SHA-256을 활용한 전자서명 구현을 간단하게 따라하고 깊이있게 알아보자.

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

📌 RSA(Rivest-Shamir-Adleman)란?

  • 암호화와 보안 분야에서 널리 사용되는 공개키 암호화 알고리즘이다. 

 

📌 안전성과 활용

  • RSA의 안전성은 큰 정수 인수분해 문제에 기반한다.
  • 데이터 보안뿐만 아니라 인증과 디지털 서명 등 다양한 보안 응용 분야에서 활용된다.
  • 현재까지도 널리 사용되며 실생활에서 많이 접하는 보안 프로세스 중 하나이다.

 

📌 공개키와 개인키

  • RSA는 공개키와 개인키라는 두 가지 키를 사용한다.
  • 두 키를 이용해 전자서명과 암호화를 구현할 수 있다.
  • 두 키는 서로 페어(짝)를 이룬다.
  • 공개키는 누구에게나 오픈하는 키이다.
    • 공개키로 암호화된 데이터는 개인키로만 해독할 수 있다.
  • 개인키는 키를 생성한 개인만이 가지고 있는 키이며 외부에 절대 노출되어서는 안 된다.
    • 개인키로 서명된 데이터는 공개키로만 해독할 수 있다.

 

📌 키 생성

  • RSA 키 페어(공개키-개인키 쌍)를 생성하기 위해서는 소수(p, q) 선택과 관련된 연산이 필요하다.
  • p와 q를 선택하고, 이들을 바탕으로 공개키(e, n)와 개인키(d, n)를 계산하는 방식으로 만들어진다.
  • 자세한 건 구글링을 통해 알아보자 😉

 

📌 RSA 전자서명을 예시로 이해하기

  • 전자서명에서 서명이란 암호화한다는 말과 같다.
  • 철수는 RSA 방식의 공개키와 개인키를 만들었다.
  • 철수는 데이터를 자신의 개인키로 서명(암호화)했다.
  • 철수는 영희에게 <자신의 공개키>와, <자신의 개인키로 암호화된 데이터>를 주었다.
  • 영희는 <철수의 공개키>와, <철수의 개인키로 암호화 된 데이터>를 받았다.
  • 영희는 <철수의 공개키>로 <암호화된 데이터>를 복호화함으로써 철수가 해당 데이터를 만들었음을 신뢰할 수 있다.
    • 철수의 공개키로 복호화되었다는 이야기는 해당 데이터가 철수의 개인키로 암호화되었다는 이야기이다.
    • 철수만이 가지고 있는 개인키로 암호화되었으므로 철수의 데이터임을 신뢰할 수 있는 것이다.

 

💡 전자서명의 이점

  • 무결성: 서명된 데이터가 변경되지 않았음을 검증할 수 있다.
  • 인증: 서명이 유효한 경우, 해당 데이터를 생성한 사람(철수)임을 확인할 수 있다.
  • 부인 방지: 철수만이 자신의 개인키로 서명할 수 있으므로, 나중에 부정하거나 부인하는 것이 어려워진다.

 

📌 RSA 암호화를 예시로 이해하기

  • 영희는 대칭키를 생성했다.
  • 영희는 <철수의 공개키>로 <대칭키>를 암호화하여 철수에게 보냈다.
  • 해당 데이터는 철수의 개인키로만 복호화가 가능하므로 철수만이 자신의 개인키로 열 수 있다.
  • 철수는 자신의 개인키로 영희에게 받은 데이터를 복호화했다.
  • 복호화한 데이터는 영희가 만든 대칭키이므로 이후 철수와 영희는 해당 대칭키로 안전하게 데이터를 암복호화하여 주고받을 수 있게 되었다. 

 

📌 자바에서 RSA 키 생성하기

// RSA 생성
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;

public class RsaGenerator {

    public static KeyPair generateKeyPair() throws NoSuchAlgorithmException {
        // 1. 현재 시간을 이용하여 seed 값을 생성합니다.
        String key = String.valueOf(System.currentTimeMillis());

        // 2. SecureRandom 객체를 생성하고 seed 값을 적용합니다.
        SecureRandom random = new SecureRandom(key.getBytes());

        // 3. KeyPairGenerator 객체를 생성하고 RSA 알고리즘을 사용하여 초기화합니다.
        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
        
        // 4. 키쌍의 초기화 설정을 (keySize : 2048, 위에서 설정한 랜덤 seed 값)으로 합니다.
        keyGen.initialize(2048, random);

        // 5. KeyPairGenerator를 사용하여 공개키와 개인키의 쌍인 KeyPair를 생성합니다.
        return keyGen.generateKeyPair();
    }
}

// 사용 예시(Base64로 인코딩)
public class test {

	public void testMethod() {
    	String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
    	String secretKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
    	
        ...
    }
}
  • Java에서는 Rsa 키 쌍을 만들 수 있는 클래스를 지원한다.
  • 1 , 2번은 키쌍을 만들기 위해 사용하는 난수를 생성하는 과정이다.
  • 3번은 KeyPairGenerator라는 객체를 생성하고 알고리즘을 RSA로 사용하여 초기화한다.
  • 4번에서 initialize 메서드의 파라미터 값은 (int keysize, SecureRandom random)이다.
    • 자세한 내용을 아래의 KeyPairGenerator 클래스에서 설명한다.
  • 사용 예시에서 생성된 키 페어를 Base64를 이용해 인코딩하여 문자열 타입으로 키를 생성하고 있음을 확인할 수 있다.
  • 물론 Hex 등으로 변환하여 사용하는 것도 가능하다.

 

📖 KeyPairGenerator 클래스

    /**
     * Initializes the key pair generator for a certain keysize using
     * a default parameter set and the {@code SecureRandom}
     * implementation of the highest-priority installed provider as the source
     * of randomness.
     * (If none of the installed providers supply an implementation of
     * {@code SecureRandom}, a system-provided source of randomness is
     * used.)
     *
     * @param keysize the keysize. This is an
     * algorithm-specific metric, such as modulus length, specified in
     * number of bits.
     *
     * @throws    InvalidParameterException if the {@code keysize} is not
     * supported by this KeyPairGenerator object.
     */
    public void initialize(int keysize) {
        initialize(keysize, JCAUtil.getDefSecureRandom());
    }

    /**
     * Initializes the key pair generator for a certain keysize with
     * the given source of randomness (and a default parameter set).
     *
     * @param keysize the keysize. This is an
     * algorithm-specific metric, such as modulus length, specified in
     * number of bits.
     * @param random the source of randomness.
     *
     * @throws    InvalidParameterException if the {@code keysize} is not
     * supported by this KeyPairGenerator object.
     *
     * @since 1.2
     */
    public void initialize(int keysize, SecureRandom random) {
        // This does nothing, because either
        // 1. the implementation object returned by getInstance() is an
        //    instance of KeyPairGenerator which has its own
        //    initialize(keysize, random) method, so the application would
        //    be calling that method directly, or
        // 2. the implementation returned by getInstance() is an instance
        //    of Delegate, in which case initialize(keysize, random) is
        //    overridden to call the corresponding SPI method.
        // (This is a special case, because the API and SPI method have the
        // same name.)
    }
  • initialize 메서드는 오버로딩된 여러 메서드가 존재한다.
    • 난수를 설정하지 않는 경우 JCAUtil.getDefSecureRandom() 자체적으로 난수를 생성함을 확인할 수 있다.
  • 🤖 RSA의 키 사이즈는 어느 정도가 적당 한 지에 대한 뤼튼의 답변
    • RSA 키 크기는 암호화에서 사용되는 모듈러스(modulus)의 길이를 나타냅니다. 일반적으로 RSA 키 크기는 비트 단위로 표현됩니다. 더 큰 키 크기는 더 강력한 보안을 제공하지만 계산 비용이 증가하므로 상황에 맞게 적절한 키 크기를 선택해야 합니다. 
  • 1024비트 이하
    • 과거에 널리 사용되었으나, 현재는 권장되지 않습니다.
    • 암호 분석 기술의 발전으로 인해 안전성이 저하되었습니다.
    • 현재 보안 요구사항을 충족하지 못할 수 있으므로 권장하지 않습니다.
  • 2048비트
    • 현재 가장 일반적으로 사용되는 RSA 키 크기입니다.
    • 충분히 안전하면서도 계산 비용이 적당합니다.
    • 대부분의 보안 응용 프로그램에서 권장하는 기본 값입니다.
  • 3072비트 이상
    • 더 강력한 보안을 요구하는 경우 선택할 수 있는 옵션입니다.
    • 예를 들어, 정부 기관, 금융 기관 등에서 민감한 정보를 처리하는 경우에 사용될 수 있습니다.
    • 하지만 계산 비용이 증가하므로 성능과 보안 사이의 균형을 고려해야 합니다.
  • 4096비트 이상
    • 매우 강력한 보안을 제공하지만, 계산 비용이 많이 듭니다.
    • 주로 최고 수준의 보안 요구사항을 충족해야 하는 환경에서 선택됩니다.

 

📌 RSA 전자서명 암복호화 테스트

📖 암호화 클래스

public class RsaGenerator {
    public static String encrypt(String data, String privateKeyStr) {
        try {
            // [1] Base64로 인코딩된 개인 키를 PrivateKey 객체로 변환합니다.
            byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

            // [2] RSA 암호화 방식을 설정한다.
            Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
            cipher.init(Cipher.ENCRYPT_MODE, privateKey);

            // [3]
            byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encryptedData);
        } catch (Exception e) {
            throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
        }
    }
}
  • 위 암호화 클래스에서는 문자열 데이터와, 개인키를 파라미터로 받아 암호화한다.
    • 개인키로 암호화(서명)하고 공개키로 복호화하는 전자서명 테스트이다.
  • [1]에서는 Base64로 인코딩 된 개인키를 PrivateKey 객체로 변환한다.
    • 위의 키 생성 예제에서 개인키를 Base64로 인코딩하여 String 타입으로 변환했기 때문에 역으로 Base64로 디코딩하여 PrivateKey 타입으로 변환하는 것이다.
    • 만약 Hex로 인코딩한 String 타입의 키를 가지고 있다면 Hex로 디코딩하여야 한다.
    • 두 차이를 모르겠다면 이 글을 참고하자.
    • String 타입의 개인키를 PrivaveKey 타입으로 변환하는 경우 PKCS8EncodedKeySpec 객체를 사용해야 한다.
    • KeyFactory 객체를 통해 RSA 방식임을 선언하고 privateKey로 최종 변환한다.
  • [2]에서는 RSA 암호화 방식을 초기화하는데 Cipher.getInstance()의 파라미터를 통해 커스텀한 방식을 설정할 수 있다.
    • 기본적으로 "RSA"만 입력하면 위의 코드와 같이 "RSA/ECB/PKCS1Padding"로 설정된다.
      • 아래에 설명하겠지만 전자서명의 경우 PKCS1Padding을 사용해야 한다.
    • 왼쪽부터 "암호화 방식/블록 암호 운용 모드/패딩 스키마"를 설정할 수 있다.
    • 암호화 방식은 RSA이다.
    • 블록 암호 운용 모드는 대칭키에서 사용하는 시스템이기 때문에 RSA에서는 필요로 하지 않지만 관례상 적는다. 실제로 Cipher 클래스의 상단 주석에도 "RSA/ECB/PKCS1Padding"라고 적혀있다. 
      • AES와 같은 대칭키를 사용하는 경우 ECB (Electronic Codebook) 모드 외에도 CBC (Cipher Block Chaining), CTR (Counter), CFB (Cipher Feedback), OFB (Output Feedback) 및 GCM (Galois/Counter Mode)과 같은 다양한 모드를 사용할 수 있다.
    • 패딩 스키마의 경우 PKCS1Padding은 RSA 암호화에서 사용되는 패딩 스키마의 오래된 표준이며, 데이터의 안전성을 보장하기 위해 추가된다. 
      • 여기서 패딩이란 원본 데이터에 난수와 같은 별도의 추가적인 데이터를 더해 원본 데이터를 암호화하는 데에 규칙성을 저하시켜 안전성을 강화하는 역할을 한다. 
      • PKCS1Padding은 RSA 암호화에서 블록 크기에 맞춰 데이터를 패딩 하는 방식이다. 이 패딩은 공개키 암호화 시 발생할 수 있는 일부 취약점을 보완하고 데이터의 무결성과 보안성을 강화한다. 그러나 PKCS1Padding에도 일부 제한사항과 취약점이 있을 수 있다. 예를 들어, 패딩 오라클(oracle) 공격과 같은 공격 벡터가 존재할 수 있으며, 이를 방지하기 위해 추가적인 조치가 필요할 수도 있다.
      • 이를 해결하기 위해 더 강력한 보안을 제공하는 OAEP (Optimal Asymmetric Encryption Padding) 패딩 스키마가 개발되었다. OAEP는 PKCS1Padding보다 안전하며, SHA-1 또는 SHA-256과 MGF1(Mask Generation Function 1)을 사용하여 데이터를 패딩 한다. 따라서 OAEP 패딩 (RSA/ECB/OAEPWithSHA-1AndMGF1Padding 또는 RSA/ECB/OAEPWithSHA-256AndMGF1Padding)을 사용하는 것이 좋다.
      • 아래는 Cipher 클래스의 주석 중 일부이다.
  • [3]에서는 암호화할 데이터를 바이트배열로 변환하는데 이때 UTF-8 인코딩 방식임을 명시한다.
  • 이후 최종적으로 Base64 인코딩을 통해 String 타입으로 변환하고 리턴한다.
    • 이 부분 역시 Hex(16진수)로 변환하여 리턴하는 방법도 있다. 

 

🖋️ Cipher 클래스 상단의 주석

* Every implementation of the Java platform is required to support
* the following standard {@code Cipher} transformations with the keysizes
* in parentheses:
* <ul>
* <li>{@code AES/CBC/NoPadding} (128)</li>
* <li>{@code AES/CBC/PKCS5Padding} (128)</li>
* <li>{@code AES/ECB/NoPadding} (128)</li>
* <li>{@code AES/ECB/PKCS5Padding} (128)</li>
* <li>{@code AES/GCM/NoPadding} (128)</li>
* <li>{@code DESede/CBC/NoPadding} (168)</li>
* <li>{@code DESede/CBC/PKCS5Padding} (168)</li>
* <li>{@code DESede/ECB/NoPadding} (168)</li>
* <li>{@code DESede/ECB/PKCS5Padding} (168)</li>
* <li>{@code RSA/ECB/PKCS1Padding} (1024, 2048)</li>
* <li>{@code RSA/ECB/OAEPWithSHA-1AndMGF1Padding} (1024, 2048)</li>
* <li>{@code RSA/ECB/OAEPWithSHA-256AndMGF1Padding} (1024, 2048)</li>
  • AES, RSA를 사용할 때의 기본적으로 지원하는 블록암호 운용 모드(AES만 해당)와 패딩 스키마가 명시되어 있다.
  • 이를 응용하면 AES도 쉽게 구현할 수 있을 것으로 보인다.

 

🧹 RSA 패딩 스키마 정리 

RSA/ECB/PKCS1Padding

  • 이 패딩은 오래된 표준이지만 여전히 널리 사용된다.
  • 주로 데이터의 블록 단위가 아닌 짧은 메시지를 암호화하는 경우에 적합하다.
  • 예를 들어, 전자 서명 및 인증서에 사용될 수 있다.

RSA/ECB/OAEPWithSHA-1AndMGF1Padding

  • PKCS#1 v1.5보다 더 최신이고 안전한 옵션이다.
  • SHA-1 해시 함수와 MGF1을 사용하여 데이터를 패딩 처리를 한다.
  • 기밀성과 안정성이 중요한 상황에서 사용할 수 있다.

RSA/ECB/OAEPWithSHA-256AndMGF1Padding

  • 보다 강력한 보안이 필요한 경우에 선택되는 옵션이다.
  • SHA-256 해시 함수와 MGF1을 사용하여 데이터를 패딩 처리를 한다.
  • 보안 요구사항이 엄격하거나 고도의 보안 강화가 필요한 상황에서 사용할 수 있다.

 

🚨 주의

  • 위와 같이 개인키로 서명(암호화)하는 전자서명 방식의 경우에 RSA/ECB/OAEPWithSHA-1AndMGF1Padding 또는 RSA/ECB/OAEPWithSHA-256AndMGF1Padding)을 사용하게 되면 Caused by: java.security.InvalidKeyException: OAEP cannot be used to sign or verify signatures 같은 에러를 만나게 된다.
  • 따라서 전자서명의 경우 위처럼 "RSA/ECB/PKCS1PADDING"를 사용하면 된다.
  • 반대로 RSA로 데이터를 암호화하는 경우에는 OAEP 패딩 스키마를 사용하는 것이 보다 안전한 데이터 암호화가 가능하다.
    • 데이터 암호화에서는 PKCS1Padding를 권장하지 않는다.
  • 이유는 잘 모르겠으나, 전자서명의 경우 데이터의 안전한 암호화 목적보다는 인증, 증명에 대한 목적이 크기 때문에 강력한 암호화가 불필요하기 때문이 아닐까 싶다.

 

📖 복호화 클래스

public class RsaVerifier {

    // String 객체로 변환 후 리턴
    public static String decryptReturnNewString(String encryptedValue, String publicKeyStr) {
        try {

            // [1] Base64로 인코딩된 공개 키를 PublicKey 객체로 변환합니다.
            byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyStr);
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(keySpec);

            // [2] RSA 복호화 방식을 설정한다.
            Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
            cipher.init(Cipher.DECRYPT_MODE, publicKey);

            // [3]
            byte[] encryptedValueBytes = Base64.getDecoder().decode(encryptedValue);
            byte[] decryptedValueBytes = cipher.doFinal(encryptedValueBytes);

            return new String(decryptedValueBytes);
        } catch (Exception e) {
            throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
        }
    }
}
  • 위의 메서드는 개인키로 암호화된 데이터를 공개키로 복호화한다.
  • [1]에서는 Base64로 인코딩 된 공개키를 PublicKey 객체로 변환한다.
  • 위의 키 생성 로직에서 Base64로 인코딩하여 String 타입으로 변환했기 때문에 역으로 Base64로 디코딩하여 PublicKey 타입으로 변환해 주는 것이다. 
    • 여기서도 마찬가지이다. 만약 Hex로 인코딩한 String 타입의 키를 가지고 있다면 Hex로 디코딩하여야 한다.
  • String 타입의 공개키를 PublicKey 타입으로 변환하는 경우 X509EncodedKeySpec 객체를 사용해야 한다.
  • KeyFactory 객체를 통해 RSA 방식임을 선언하고 publicKey 키로 최종 변환한다.
  • [2]에서는 암호화 방식과 똑같이 복호화 방식을 설정한다.
  • 암호화 방식에서 설정한 세팅값을 그대로 사용하고
  • DECRYPT_MODE임을 명시하고, 공개키로 초기화한다.
  • [3]에서는 복호화 데이터를 Base64로 디코딩하여 바이트배열로 변환하고
  • 복호화를 진행하여 원본 데이터의 바이트 배열값을 얻는다.
  • 이후 최종적으로 new String 객체를 생성하여 String 타입으로 변환하고 리턴한다.
    • new String은 바이트 배열을 문자열로 변환하는 것이고, toString()은 바이트 배열 자체의 문자열을 값을 의미하므로 바이트 배열이 의미하는 문자를 얻기 위해서 new String 객체를 사용해야 한다.
  • 이때 주의해야 할 점이 만약 Base64.getEncoder().encodeToString()을 사용하여 리턴하면 Base64로 인코딩한 값을 String 타입으로 변환한 값이 리턴되기 때문에 원본 데이터의 값과 전혀 다른 값이 나올 수 있다.

 

📖 테스트 메서드 및 결과

public static void rsaEncryptDecryptTest() {
	
    String text = "안녕하세요.";
    
    String publicKeyByJava   = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlK5ioVvjb1CHE+wl0Od9tekm7tMLB06ApYKuwEVEFQNRRdcVToNnHMf80Hy17LHAv4R83UTf6ZOy3IeLWbjFMp43s3K4SCS1e94T9bojztWLuwrSDIrGMXfKOsnaEdJyAEAqocL+HSZTOGpM20UGKIHzKK+VDwFYsP1d2qoRBhy6bLE/hbFYodc5iIFeB+U8vpHwcSROYXdZ+hceo9oBxMZM0Kv+SqOFIcguVEoIteMH7VjSXR1vtfN5yCDRrLKwj6FOPTO9EXHsgbc1+PwOntSLGxm7AbgS90BvEmHDSXlUt7hA2WHeP7PCsN1cYYpuca+nRAP3jwfVMLSxsziq8wIDAQAB";
    String privateKeyByJava  = "미리 생성한 privateKey";
	
    // 개인키로 암호화
    String encrypted = RsaGenerator.encrypt(text, privateKeyByJava);

    // 공개키로 복호화
    String decryptReturnBase64 = RsaVerifier.decryptReturnBase64(encrypted, publicKeyByJava);
    String decryptNewString = RsaVerifier.decryptReturnNewString(encrypted, publicKeyByJava);

    System.out.println("### decryptReturnBase64 : " + decryptReturnBase64); // Base64 return
    // ### decryptReturnBase64 : 7JWI64WV7ZWY7IS47JqULg==

    System.out.println("### decryptNewString : " + decryptNewString); // string return
    // ### decryptNewString : 안녕하세요.
}
  • 위의 결과처럼 만약 Base64로 인코딩한 후에 리턴하면 원본 데이터의 "안녕하세요"가 아닌 Base64로 인코딩 값의 String 변환 값이 나오니 주의해야 한다.
    • 복호화 메서드는 원본값을 얻어야 하므로 Hex나 Base64로 인코딩하여 리턴하지 말고 new String 객체로 리턴해야 한다.
  • 공개키로 암호화하고 개인키로 복호화하는 방식은 위의 방식을 보고 응용하면 쉽게 만들 수 있을 것 같다.

 

📌 SHA-256을 활용한 전자서명 구현

  • 전자서명은 타인에게 나를 혹은 내가 만든 것임을 입증하는 용도로 사용한다.
  • 위의 개인키로 암호화 공개키로 복호화하는 방식에서 SHA-256 해시함수를 활용하여 전자서명을 구현할 수 있다.
  • 여기에서는 대표적인 인코딩 방식인 16진수 변환과 Base64 인코딩 변환 두 가지 모두 보여주겠다.
  • SHA-256과 자바에서의 구현에 대한 자세한 내용은 이 글을 참고하도록 하자.

 

💡 SHA-256을 활용한 전자서명의 예시

  • 철수는 RSA 방식의 공개키와 개인키를 만들었다.
  • 철수는 영희에게 줄 데이터를 SHA-256으로 해시하고, 그 해시값을 자신의 개인키로 서명(암호화)했다.
  • 철수는 영희에게 <자신의 공개키>와, <자신의 개인키로 암호화된 해시 데이터>, <원본 데이터>를 주었다.
  • 영희는 <철수의 공개키>와, <철수의 개인키로 암호화 된 해시 데이터>, <원본 데이터>를 받았다.
  • 영희는 <철수의 공개키>로 <암호화된 해시 데이터>를 복호화하여 나온 해시값과, 원본 데이터를 직접 해시하여 나온 해시값을 비교하여 철수가 해당 데이터를 만들었으며, 원본 데이터가 변조되지 않았음을 신뢰할 수 있다.

 

📖 SHA256 클래스(Return Hex)

public class Sha256ToHex {

    /**
     * 입력 받은 문자열을 SHA-256으로 해싱하여 해시 값을 반환함
     * @param text
     * @return 해시값
     * @throws NoSuchAlgorithmException
     */
    public String encrypt(String text) throws NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        md.update(text.getBytes());

        return bytesToHex(md.digest());
    }

    /**
     * 바이트 배열을 16진수 문자열로 반환함
     * @param bytes
     * @return 16진수 문자열
     */
    private String bytesToHex(byte[] bytes) {
        StringBuilder builder = new StringBuilder();
        for (byte b : bytes) {
            builder.append(String.format("%02x", b));
        }
        return builder.toString();
    }
}
  • 리턴을 16진수로 한다.

 

📖 SHA256 클래스(Return Base64)

public class Sha256ToBase64 {

    /**
     * 입력 받은 문자열을 SHA-256으로 해싱하여 해시 값을 반환함
     * @param text
     * @return 해시값
     * @throws NoSuchAlgorithmException
     */
    public String encrypt(String text) throws NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        md.update(text.getBytes());

        return Base64.getEncoder().encodeToString(md.digest());
    }
}
  • 리턴을 Base64로 인코딩 문자열로 한다.

 

📖 RsaGenerator(전자서명 생성) 클래스

public class RsaGenerator {
    public static String encryptWithHexHash(String data, String privateKeyStr) {
        try {
            // Base64로 인코딩된 개인 키를 PrivateKey 객체로 변환합니다.
            byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

            // 데이터로부터 SHA-256 해시 값을 생성하고 16진수로 변환합니다.
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hashedData = md.digest(data.getBytes(StandardCharsets.UTF_8));
            String sha256hex = bytesToHex(hashedData);

            // RSA 개인 키를 사용하여 해시 값을 암호화합니다.
            Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
            cipher.init(Cipher.ENCRYPT_MODE, privateKey);

            byte[] encryptedHashValue = cipher.doFinal(sha256hex.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encryptedHashValue);
        } catch (Exception e) {
            throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
        }
    }
    
    public static String encryptWithBase64(String data, String privateKeyStr) {
        try {
            // Base64로 인코딩된 개인 키를 PrivateKey 객체로 변환합니다.
            byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

            // 데이터로부터 SHA-256 해시 값을 생성하고 Base64로 인코딩합니다.
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hashedData = md.digest(data.getBytes(StandardCharsets.UTF_8));
            String sha256Base64 = Base64.getEncoder().encodeToString(hashedData);

            // RSA 개인 키를 사용하여 해시 값을 암호화합니다.
            Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
            cipher.init(Cipher.ENCRYPT_MODE, privateKey);

            byte[] encryptedHashValue = cipher.doFinal(sha256Base64.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encryptedHashValue);
        } catch (Exception e) {
            throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
        }
    }

    public static String encryptNoHash(String data, String privateKeyStr) {
        try {
            // Base64로 인코딩된 개인 키를 PrivateKey 객체로 변환합니다.
            byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PrivateKey privateKey = keyFactory.generatePrivate(keySpec);

            // RSA 개인 키를 사용하여 해시 값을 암호화합니다.
            Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
            cipher.init(Cipher.ENCRYPT_MODE, privateKey);

            byte[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encryptedData);
        } catch (Exception e) {
            throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
        }
    }

    private static String bytesToHex(byte[] hash) {
        StringBuffer hexString = new StringBuffer();
        for (int i = 0; i < hash.length; i++) {
            String hex = Integer.toHexString(0xff & hash[i]);
            if(hex.length() == 1) hexString.append('0');
            hexString.append(hex);
        }
        return hexString.toString();
    }
}
  • 세 개의 메서드 중 마지막 encryptNoHash는 해시를 사용하지 않은 메서드이다.
  • 나머지 두 개의 메서드의 차이점은 바이트 배열의 해시값을 16진수로 변환하고 암호화하냐, Bas64로 변환하고 암호화하냐이다.

 

📖 RsaVerifier 클래스(전자서명 복호화) 클래스

public class RsaVerifier {

    public static String decryptReturnNewString(String encryptedValue, String publicKeyStr) {
        try {

            // Base64로 인코딩된 공개 키를 PublicKey 객체로 변환합니다.
            byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyStr);
            X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(keySpec);

            // 암호화된 해시 값을 복호화합니다.
            Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1PADDING");
            cipher.init(Cipher.DECRYPT_MODE, publicKey);

            byte[] encryptedValueBytes = Base64.getDecoder().decode(encryptedValue);
            byte[] decryptedValueBytes = cipher.doFinal(encryptedValueBytes);

            return new String(decryptedValueBytes);
        } catch (Exception e) {
            throw new RuntimeException(e);  // 필요에 따라 예외 처리를 합니다
        }
    }
}
  • 위의 복호화 메서드 그대로이다.
  • 다시 한 번 강조하지만 복호화 메서드의 리턴에 인코딩을 넣으면 리턴 값이 원본값과 다르게 나온다. 주의하자.

 

📖 테스트 메서드 및 결과

public static void rsaTest(String text) throws NoSuchAlgorithmException {

    Sha256ToBase64 sha256ToBase64 = new Sha256ToBase64();
    Sha256ToHex sha256ToHex = new Sha256ToHex();

    // text를 SHA-256으로 해시하여 두가지 방식(Base64, Hex)으로 리턴
    System.out.println("SHA-256 ENCRYPT");
    System.out.println("### 원본 text : " + text);
    System.out.println("### sha256ToHex : " + sha256ToHex.encrypt(text));
    System.out.println("### sha256ToBase64 : " + sha256ToBase64.encrypt(text));

    // 자바에서 만든 공개키와 개인키
    String publicKeyByJava   = "미리 생성한 공개키";
    String privateKeyByJava  = "미리 생성한 개인키":

    String encryptNoHash = RsaGenerator.encryptNoHash(text, privateKeyByJava);
    String encryptedTextWithHex = RsaGenerator.encryptWithHexHash(text, privateKeyByJava);
    String encryptedTextWithBase64 = RsaGenerator.encryptWithBase64(text, privateKeyByJava);

    String decryptedNoHash = RsaVerifier.decryptReturnNewString(encryptNoHash, publicKeyByJava);
    String decryptedTextWithHex = RsaVerifier.decryptReturnNewString(encryptedTextWithHex, publicKeyByJava);
    String decryptedTextWithBase64 = RsaVerifier.decryptReturnNewString(encryptedTextWithBase64, publicKeyByJava);
    System.out.println("RSA DECRYPT");
    System.out.println("### decryptedNoHash : " + decryptedNoHash);
    System.out.println("### decryptedTextWithHex : " + decryptedTextWithHex);
    System.out.println("### decryptedTextWithBase64 : " + decryptedTextWithBase64);
}

/**
SHA-256 ENCRYPT
### (1) 원본 text : 안녕하세요.
### (2) sha256ToHex : 8b118d6741f7cfa1a7ee246d0dda39f2f00bf9fd207b4e6c7fad87a15434a513
### (3) sha256ToBase64 : ixGNZ0H3z6Gn7iRtDdo58vAL+f0ge05sf62HoVQ0pRM=
RSA DECRYPT
### (1) decryptedNoHash : 안녕하세요.
### (2) decryptedTextWithHex : 8b118d6741f7cfa1a7ee246d0dda39f2f00bf9fd207b4e6c7fad87a15434a513
### (3) decryptedTextWithBase64 : ixGNZ0H3z6Gn7iRtDdo58vAL+f0ge05sf62HoVQ0pRM=
*/
  • 결과를 차례대로 살펴보자.
  1. 해시를 하지 않은 상태에서 개인키로 암호화하고 공개키로 복호화하면 원본 그대로의 값이 나온다.
  2. 데이터를 Sha256으로 해시하여 나온 바이트 배열을 16진수로 변환하고 바이트 배열로 바꿔 암호화한 경우(해시값은 Sha256ToHex), 이를 복호화할 때 리턴으로 나온 해시값(바이트 배열)을 new String 객체로 변환하면 16진수로 인코딩된 String 해시값을 얻을 수 있다.
  3. 데이터를 Sha256으로 해시하여 나온 바이트 배열을 Base64로 변환하고 바이트 배열로 바꿔 암호화한 경우(해시값은 Sha256ToBase64), 이를 복호화할 때 리턴으로 나온 해시값(바이트 배열)을 new String 객체로 변환하면 Base64로 인코딩된 String 해시값을 얻을 수 있다.

📜 정리

 설명이 매우 어렵기 때문에 간단히 정리하면 SHA-256이든, RSA 암호화든 결국 바이트 배열로 encrypt되기 때문에 암호화 메서드에서는 개발자가 읽고 데이터베이스에 원활히 저장하기 위해 리턴할 때 반드시 String 타입으로 변환해줘야 한다. 이때 Hex로 변환할 것인지, Base64로 변환할 것인지를 선택하여 한 가지 방법으로 통일해야만이 복호화했을 때 올바른 해시값을 얻을 수 있다.

 

 복호화(decrypt) 메서드의 경우 원본인 문자열 타입을 그대로 리턴해야 하는데 리턴 값을 Hex나 Base64로 인코딩하게 되면 불필요한 인코딩 과정 때문에 원본 데이터가 인코딩 되어 전혀 다른 값이 나온다. 따라서 복호화 메서드의 경우는 반드시 new String 객체로 리턴하자.

 

 추가적으로 SHA-256 해시가 들어간 경우 복호화 메서드는 new String으로 리턴하기 때문에 암호화 메서드 내부에서 SHA-256으로 해시값을 구한 후 Hex나 Base64 등으로 한 번 인코딩을 하고 그 인코딩 된 String 값을 바이트 배열로 바꿔 암호화 처리를 해야 한다. 이렇게 하지 않고 바이트 배열의 SHA-256 해시값을 그대로 암호화하게 되면 복호화 메서드에서 new String으로 리턴할 경우 엉뚱한 값이 나오게 된다. 인코딩하지 않은 해시값의 바이트 배열 그대로 문자열 타입으로 바뀌어 리턴되기 때문이다.

 

혹여나 이 글을 보고 구현하는 사람이 있다면 언제든지 댓글을 남기면 보는 즉시 답변하겠다. 끗.

 

 

 

참고

뤼튼

 

728x90