Java를 사용해 HTTPS를 만들어보자

김태훈·2023년 2월 15일
0

HTTP가 어떻게 동작하는지 알아야 합니다
암호화(대칭키, 비대칭키, 양방향 암호화, 단방향 암호화, 비밀키, 공개키...)에 대한 개념이 필요합니다.

들어가기에 앞서

이 글의 목적은 java를 사용해 https의 동작 과정을 약식으로 구현해보는 것입니다. 해도 해도 잊어버리는 HTTPS의 동작 원리를 머리에 때려박기 위해 다음과 같은 글을 작성합니다.

HTTPS란?

HTTP + Secure, HTTP 프로토콜의 보안 버전

HTTP 통신에서 보완을 개선한 프로토콜이라고만 이해하고 넘어가겠습니다.

HTTPS가 나온 배경

HTTPS가 나온 이유는 "HTTP는 보안에 취약하다" 라는 근본적 이유를 해결하기 위해서입니다.

인터넷 상에서 정보를 전달하려면 HTTP를 사용해야 합니다.
HTTP를 통해 클라이언트가 서버에게 데이터를 패킷에 감싸 보내면,
패킷은 라우터를 타고타고 여기저기 들르면서 목적지를 찾아갑니다.

이 부분에서 문제가 발생합니다.
들르는 과정에서 데이터가 탈취될 수 있다는 문제가 생깁니다.
HTTP는 데이터를 평문으로 보내기 때문에, 해커가 패킷을 탈취하면 데이터를 확인할 수 있게 됩니다.
만약 데이터 안에 비밀번호와 같은 다른 사람에게 보여주면 안되는 정보가 들어있다면 큰일나겠죠?

두 번째 문제는 HTML이 동일한 피싱사이트가 만들어지는 걸 막지 못합니다.
사용자 입장에서는 화면만 보고서는 이 사이트가 진짜 naver인지, 가짜 naver인지 알 수 없다는 이야기가 됩니다.
즉, 어떤 신뢰할 수 있는 무언가가 필요하다는 거겠죠?

"전달되는 데이터가 중간에 탈취당해도, 해커가 내부 내용을 알 수 없게 만들어야겠다",
"서버에게 받은 응답이 올바른지 신뢰할 수 있어야 한다"
이러한 목표를 가지고 HTTPS가 탄생합니다.
자세한 동작원리는 바로 아래에서 설명드리겠습니다.

HTTPS의 동작 원리

HTTPS는 HTTP 메세지를 암호화하는 방식을 사용해 해당 문제를 해결합니다.
메세지가 암호화되어있으면, 탈취당해도 해커는 해당 내용을 해석할 수 없으니까요.

메세지를 암호화하는 방법

그러면 메세지를 어떻게 암호화할 수 있을까요?
바로 Client와 Server는 key를 사용해 메세지를 암호화시킵니다.
주고받는 메세지를 key를 사용해 암호화시키고 열어봅니다.

그러려면 Client, Server 둘 다 key를 사전에 들고 있어야 한다는 전제가 생깁니다. 그게 가능할까요?
Server 입장에서 생각해봅시다. 네이버가 하루에 몇 명의 Client의 요청을 받을지를 상상해봅시다. 누군지도 모르고 엄청 많은 Client들의 key를 들고 있는건 불가능합니다.

key를 교환하는 방법

일단 간단하게만 말하면 Client에서 key를 만들고 Server에게 HTTP 방식으로 전달합니다.
HTTP를 사용하기 때문에 전달되는 과정에서 탈취당하거나, 변조될 수 있습니다.

첫 번째, 탈취가 가능하다는 문제는 "공개키-비밀키" 방식을 사용해 해결합니다.
Server는 자기만 아는 비밀키를 들고 있습니다.
비밀키와 짝이 되어 메세지를 열고 닫을 수 있는 공개키를 Client에게 전달합니다.
만약 이 과정에서 공개키를 탈취당한다면?

아무 문제 없습니다.
공개키는 다음 두 가지를 할 수 있습니다.

1. Server로 보내는 요청을 암호화하기
2. Server가 보내는 응답을 열어보기(복호화)

즉, Server의 공개키만 가지고는

1. Client->Server에게 보내는 정보를 열어볼 수 없고,
2. Server가 보낸 응답의 내용을 바꿔치기한 뒤 다시 암호화를 시킬 수 없다는 의미입니다.

두 번째 문제로 넘어가겠습니다. 만약 Key가 변조당하면 어떤 문제가 일어날까요?
다음 상황을 생각해봅시다.
해커는 Server의 공개키 대신 해커 자신의 공개키를 넣어서 Client에게 전달합니다.
아무것도 모르는 Client는 자신의 password 등 개인정보를 해커의 공개키로 암호화한 뒤 요청을 보냅니다.
해커가 해당 요청을 자신의 비밀키로 열 수 있습니다. 이렇게 개인정보가 유출이 됩니다.

key가 변조될 수 있다는 문제를 해결하기 위해 CA라는 등장인물이 나타납니다.
CA는 "인증서를 만들어내는 역할"을 하는데, 오직 CA만 인증서를 만들 수 있습니다.
인증서는 아무나 열어볼 수 있는데, 열어보면 Server의 공개키와 Server의 정보를 얻을 수 있습니다.
(인증서를 만드는 건 이따가 설명드리겠습니다)

Client가 Server에게 요청을 보내면 ->
Server는 Client에게 자신의 인증서를 보낸다 ->
Client는 인증서를 열어 Server 정보와 공개키를 획득한다->
Server 정보가 올바르다면 ->
해당 공개키를 저장한다

이 방식으로 Client와 Server는 Key 교환에 성공했습니다.
이제 HTTP 메세지를 암호화시켜 주고받을 수 있게 되었습니다.

CA가 인증서를 만드는 방법

그런데 인증서는 어떻게 만들어지길래 CA만이 만들 수 있는걸까요?
인증서란 Server의 정보와 공개키를 암호화시킨 값입니다.
CA 자신의 비밀키를 사용해서요.

엥? 아까 Server랑 분명히 동일한 상황같습니다.
대상이 Server에서 CA로 바뀌었다 뿐이지, CA의 공개키를 Client에게 전달해야 합니다.
그 과정에서 탈취당하거나 변조당할 수 있지 않나요?

아닙니다. 바로 CA의 공개키는 HTTP를 통해서 전달이 일어나지 않기 때문입니다.
Client가 사용하는 브라우저 내부에 CA의 공개키가 저장되어 있습니다.
애초에 브라우저 내부에 값이 저장되어 있으니 공개키를 바꿔치기할 수가 없습니다.

즉, 인증서를 열어서 나온 값들은 무조건 CA에서 암호화한 값이기 때문에 신뢰할 수 있습니다.

Server의 공개키들을 Client에 저장하면 이렇게 복잡하게 통신할 필요가 없지 않나? 하는 의문이 들 수 있습니다.
하지만 Server가 한두개일까요? Server는 아무나 만들 수 있는데, 그 많은 공개키를 브라우저에 저장할 수는 없습니다.
Server 하나 생길 때마다 브라우저가 업데이트 되는 것 역시 말도 안되는 상황이구요.
그런 이유로 CA라는 소수의 인증받은 업체들을 사용하는 겁니다.

성능 Issue

다 끝난줄 알았지만 마지막 한 개 문제가 남아있습니다.
보안 문제는 다 끝났지만, 속도의 문제가 남아있습니다.
비밀키-공개키를 사용해 암/복호화 하는건 시간이 꽤나 오래 걸리는 작업입니다.
대칭키를 사용하는 방법보다 1000배 이상 느리다고 합니다 🥲

그래서 약간의 꼼수를 사용합니다.
Client는 인증서를 열어본 뒤 Server 정보에 문제가 없으면, 대칭키를 하나 만듭니다.
이 대칭키를 Server 공개키로 암호화합니다. 그 상태로 Server에게 전달합니다.

해당 요청은 Server만 열 수 있습니다.
Server는 해당 요청을 열어서 대칭키를 획득합니다.

이제 이 대칭키는 오직 Client와 Server만이 가지고 있습니다.
이걸 사용해서 HTTP 메세지를 암/복호화하면 빠른 속도로 통신이 가능합니다.

요약

이렇게 HTTPS 작동 원리에 대한 설명이 끝이 났습니다.

정리를 하면, HTTPS는 HTTP를 암호화시켜주는 프로토콜입니다.
대칭키를 사용해 암호화를 하는데, 키 배송 문제가 발생합니다.

그래서 CA의 인증서를 사용해 Client에게 Server공개키를 안전하게 전달합니다.
Client는 암호화에 사용할 대칭키를 만들고, Server공개키로 암호화를 시켜 서버에게 보냅니다.

서버는 자신의 비밀키로 대칭키를 획득합니다.
이후 모든 메세지를 암호화시켜 보내주면서 HTTPS 통신이 이뤄집니다.

아래 그림은 위 설명을 잘 요약하고 있으니, 그림을 따라가며 흐름을 정리하시길 추천합니다.

SSL Handshake

위의 그림에서 5번~10번 과정을 SSL Handshake라고 부릅니다. 클라이언트와 서버가 서로를 믿기 위해 거치는 여러 과정을 악수한다고 표현합니다.

SSL Handshake를 하게 되면 사실 그림에 있는 것 외에도 몇 가지 작업을 더 하긴 합니다.

1. 대칭키 교환 알고리즘 선정
2. 인증서 서명 방식 선정
3. 대칭키 알고리즘 선정
4. HMAC 알고리즘 선정

왜 위와 같은 작업이 필요하냐면, 서버와 클라이언트는 각자 사용 가능한 알고리즘들이 다릅니다. 그래서 둘 다 사용가능한 알고리즘을 선정해야 합니다.

위 그림의 5번에서 10번에 추가 설명을 넣겠습니다.

5번 작업

  • 접속요청을 한다
  • 클라이언트는 자신이 사용할 수 있는 알고리즘들을 서버에 보낸다

6번 작업

  • 클라이언트가 보내준 알고리즘들 중 자신이 사용가능한 알고리즘을 선택한다
  • 클라이언트에게 인증서를 보낸다

7, 8번 작업

  • 클라이언트는 인증서를 사용해 서버 공개키를 얻는다
  • 대칭키를 만든다
  • 대칭키를 서버 공개키로 암호화한다

9번 작업

  • 클라이언트는 서버에게 암호화된 대칭키를 전송한다

10번 작업

  • 서버는 서버 비밀키로 대칭키를 얻는다

구현

  • HTTPS에서 알고리즘들을 선정하는 과정을 제외했습니다. 추후에 추가할 예정입니다.
    이 구현을 통해 다음과 같은 결과를 만들어낼 생각입니다.

구현에 들어가기 전 HTTPS 통신의 목적을 상기하겠습니다.

  1. 클라이언트와 서버가 통신하는 과정에서 데이터가 탈취되어도 안전해야 한다.
  2. 서버에게 받은 응답을 신뢰할 수 있어야 한다.

위 두가지 목적을 달성하는 HTTPS 통신을 만들어 볼 계획입니다.
다음 HTTPS 통신에서 등장인물은 클라, 서버, CA 이렇게 셋 존재합니다.

utils 디렉토리

utils 디렉토리에 있는 건 그냥 도구입니다. 어떻게 구현되어 있는지 설명은 생략하겠습니다. 굳이 여기서 내부 로직을 이해하실 필요 없습니다. 그냥 메서드를 가져다만 씁시다(이후 암호화 관련해서 글을 작성할 예정입니다). 메서드 위에 간략하게 설명을 적어뒀습니다.

// AES256Crypto.java

package utils;

import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import static utils.RandomCharsetGenerator.getRandomString;

/**
 * AES : 대칭키 알고리즘
 * 출처 : https://bamdule.tistory.com/234
 */
public class AES256Crypto {
    private static final String alg = "AES/CBC/PKCS5Padding";
    private static final int KEY_SIZE = 32;

    /**
     * createSymmetricKey 메서드를 사용해 대칭키를 만든다.
     * @return String 타입의 대칭키. 32비트의 랜덤한 문자열이다.
     */
    public static String createSymmetricKey() {
        return getRandomString(KEY_SIZE);
    }

    /**
     *
     * @param text : 암호화할 문자열
     * @param key : createSymmetricKey 방식으로 만든 대칭키
     * @return : text를 암호화한 결과
     */
    public static String encrypt(String text, String key) throws Exception {
        String iv = key.substring(0, 16);
        Cipher cipher = Cipher.getInstance(alg);
        SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
        IvParameterSpec ivParamSpec = new IvParameterSpec(iv.getBytes());
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivParamSpec);

        byte[] encrypted = cipher.doFinal(text.getBytes("UTF-8"));
        return Base64.getEncoder().encodeToString(encrypted);
    }

    /**
     *
     * @param cipherText : 암호화된 문자열
     * @param key : createSymmetricKey 방식으로 만든 대칭키
     * @return : cipherText를 복호화한 결과
     */
    public static String decrypt(String cipherText, String key) throws Exception {
        String iv = key.substring(0, 16);

        Cipher cipher = Cipher.getInstance(alg);
        SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(), "AES");
        IvParameterSpec ivParamSpec = new IvParameterSpec(iv.getBytes());
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivParamSpec);

        byte[] decodedBytes = Base64.getDecoder().decode(cipherText);
        byte[] decrypted = cipher.doFinal(decodedBytes);
        return new String(decrypted, "UTF-8");
    }
}
// RSACrypto.java

package utils;

import enums.UserType;

import javax.crypto.Cipher;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;

/**
 * RSA : 비대칭키 알고리즘
 * 출처 : https://this-programmer.tistory.com/259
 */
public class RSACrypto {

    /**
     * publicKey, privateKey 키페어를 만든다
     * @param userType : enums/UserType에 따라 어떤 RSA를 사용할지 결정한다.
     *                 userType이 CA라면 RSA2048, SERVER라면 RSA1024를 사용한다.
     *                 SERVER의 비밀키 길이보다 CA의 비밀키 길이가 더 길어야 하기 때문이다.
     *                 그렇지 않으면 Server의 비밀키를 암호화할 수 없다.
     *                 나 역시 비대칭키 암호화에 대해 이해가 부족해서 이후에 추가적인 글을 작성할 예정이다.
     * @return
     */
    public static HashMap<String, String> createKeypairAsString(UserType userType) {
        HashMap<String, String> stringKeypair = new HashMap<>();
        try {
            Integer keySize = userType.getKeySize();
            SecureRandom secureRandom = new SecureRandom();
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(keySize, secureRandom);
            KeyPair keyPair = keyPairGenerator.genKeyPair();

            PublicKey publicKey = keyPair.getPublic();
            PrivateKey privateKey = keyPair.getPrivate();

            String stringPublicKey = Base64.getEncoder().encodeToString(publicKey.getEncoded());
            String stringPrivateKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());

            stringKeypair.put("publicKey", stringPublicKey);
            stringKeypair.put("privateKey", stringPrivateKey);

        } catch (Exception e) {
            e.printStackTrace();
        }
        return stringKeypair;
    }

    /**
     * plainData를 publicKey로 부호화한다
     * plainData : encoding할 데이터
     * stringPublicKey : PublicKey
     */
    public static String encodeUsingPublic(String plainData, String stringPublicKey) {
        String encryptedData = null;
        try {
            //평문으로 전달받은 공개키를 공개키객체로 만드는 과정
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            byte[] bytePublicKey = Base64.getDecoder().decode(stringPublicKey.getBytes());
            X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(bytePublicKey);
            PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);

            //만들어진 공개키객체를 기반으로 암호화모드로 설정하는 과정
            Cipher cipher = Cipher.getInstance("RSA");
            cipher.init(Cipher.ENCRYPT_MODE, publicKey);

            //평문을 암호화하는 과정
            byte[] byteEncryptedData = cipher.doFinal(plainData.getBytes());
            encryptedData = Base64.getEncoder().encodeToString(byteEncryptedData);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return encryptedData;
    }

    /**
     * plainData를 privateKey로 부호화한다
     * plainData : encoding할 데이터
     * stringPrivateKey : PrivateKey
     */
    public static String encodeUsingPrivate(String plainData, String stringPrivateKey) {
        String encryptedData = null;
        try {
            //평문으로 전달받은 개인키를 개인키객체로 만드는 과정
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            byte[] bytePrivateKey = Base64.getDecoder().decode(stringPrivateKey.getBytes());
            PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(bytePrivateKey);
            PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);

            //만들어진 비밀키 객체를 기반으로 암호화모드로 설정하는 과정
            Cipher cipher = Cipher.getInstance("RSA");
            cipher.init(Cipher.ENCRYPT_MODE, privateKey);

            //평문을 암호화하는 과정
            byte[] byteEncryptedData = cipher.doFinal(plainData.getBytes());
            encryptedData = Base64.getEncoder().encodeToString(byteEncryptedData);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return encryptedData;
    }

    /**
     * publicKey로 암호화된 내용을 privateKey로 복호화한다
     * encryptedData : decoding 해야하는 암호화된데이터
     * stringPrivateKey : PrivateKey
     */
    public static String decodeUsingPrivate(String encryptedData, String stringPrivateKey) {
        String decryptedData = null;
        try {
            //평문으로 전달받은 개인키를 개인키객체로 만드는 과정
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            byte[] bytePrivateKey = Base64.getDecoder().decode(stringPrivateKey.getBytes());
            PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(bytePrivateKey);
            PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);

            //만들어진 개인키객체를 기반으로 암호화모드로 설정하는 과정
            Cipher cipher = Cipher.getInstance("RSA");
            cipher.init(Cipher.DECRYPT_MODE, privateKey);

            //암호문을 평문화하는 과정
            byte[] byteEncryptedData = Base64.getDecoder().decode(encryptedData.getBytes());
            byte[] byteDecryptedData = cipher.doFinal(byteEncryptedData);
            decryptedData = new String(byteDecryptedData);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return decryptedData;
    }

    /**
     * privateKey로 암호화된 내용을 publicKey로 복호화한다
     * encryptedData : decoding 해야하는 암호화된데이터
     * stringPublicKey : PublicKey
     */
    public static String decodeUsingPublic(String encryptedData, String stringPublicKey) {
        String decryptedData = null;
        try {
            //평문으로 전달받은 공개키를 공개키객체로 만드는 과정
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            byte[] bytePublicKey = Base64.getDecoder().decode(stringPublicKey.getBytes());
            X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(bytePublicKey);
            PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);

            //만들어진 개인키객체를 기반으로 암호화모드로 설정하는 과정
            Cipher cipher = Cipher.getInstance("RSA");
            cipher.init(Cipher.DECRYPT_MODE, publicKey);

            //암호문을 평문화하는 과정
            byte[] byteEncryptedData = Base64.getDecoder().decode(encryptedData.getBytes());
            byte[] byteDecryptedData = cipher.doFinal(byteEncryptedData);
            decryptedData = new String(byteDecryptedData);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return decryptedData;
    }

}
package utils;

import java.util.HashMap;

//  출처: https://toyuq.tistory.com/242 [Goni:티스토리]
public class ObjectMapper {

	// Map으로 만들어 변환한다
    public static HashMap<String, String> convertMap(String str) {
        HashMap<String, String> hashMap = new HashMap<>();
        str = str.substring(1, str.length()-1);
        String[] params = str.split(", ");
        for(String param : params) {
            String[] keyAndValue = param.split("=");
            hashMap.put(keyAndValue[0], keyAndValue[1]);
        }
        return hashMap;
    }
}
// RandomCharsetGenerator.java

package utils;

import java.nio.charset.*;
import java.util.*;

/**
 * 출처 : https://www.delftstack.com/ko/howto/java/random-alphanumeric-string-in-java/
 */
class RandomCharsetGenerator {

    /**
     * length 길이의 Random한 문자열을 만든다.
     * 문자열은 영대문자, 영소문자, 0-9까지 숫자로 이루어진다.
     * @param length
     * @return
     */
    static String getRandomString(int length)
    {
        // bind the length
        byte[] bytearray;
        bytearray = new byte[256];

        String mystring;
        StringBuffer thebuffer;
        String theAlphaNumericS;

        new Random().nextBytes(bytearray);

        mystring = new String(bytearray, Charset.forName("UTF-8"));

        thebuffer = new StringBuffer();

        //remove all spacial char
        theAlphaNumericS = mystring.replaceAll("[^A-z0-9]", "");

        //random selection
        for (int m = 0; m < theAlphaNumericS.length(); m++) {

            if (Character.isLetter(theAlphaNumericS.charAt(m))
                    && (length > 0)
                    || Character.isDigit(theAlphaNumericS.charAt(m))
                    && (length > 0)) {

                thebuffer.append(theAlphaNumericS.charAt(m));
                length--;
            }
        }

        // the resulting string
        return thebuffer.toString();
    }
}

enums 디렉토리

이해하실 필요 없습니다.

그냥 이번 구현에서 "userType이 CA라면 RSA2048, SERVER라면 RSA1024를 사용하겠다" 정도만 이해해주셨으면 좋겠습니다. CA의 key가 Server의 key와 크기가 같거나 작으면 오류가 발생하는 문제가 발생했습니다.

저도 암호화 내부 로직을 잘 몰라 자세한 이유를 모르겠습니다.
암호화에 대해 학습한 뒤 수정하겠습니다. 혹시 아시는 분은 댓글 달아주시면 감사하겠습니다 :)

// UserType.java

package enums;

public enum UserType {
    CA(2048), SERVER(1024);

    private Integer keySize;

    UserType(Integer keySize) {
        this.keySize = keySize;
    }

    public Integer getKeySize() {
        return keySize;
    }
}

main(entry point 위치함)

main 메서드에 위치한 해당 구현의 흐름은 다음과 같습니다.

  1. CA 와 Server를 하나씩 생성한다.
  2. 서버는 CA에게 인증을 받는다. 위 그림에서 1~3번 과정이 여기에 해당한다.
  3. Client를 하나 생성한다.

    생성자로 CA의 publicKey를 인자로 넣는데, 위 그림에서 4번 과정이 여기에 해당한다.
    브라우저 내부에 CA의 publicKey가 있다는 걸 다음과 같이 구현해보았다.
  4. Client는 Server에게 요청을 보내고 응답을 받는다.

    scanner를 통해 값을 입력하면 ->
    clientA.request 메서드를 실행하고 ->
    HTTPS 통신이 일어나면서 ->
    서버에서 응답을 받아온 뒤, 해당 응답을 화면에 출력한다.

테스트 가능한 상황은 다음과 같습니다.

1. 피싱사이트로 요청을 보낸 경우
2. 서버에 존재하지 않는 username으로 요청을 보내는 경우
3. password가 틀린 경우
4. 성공적으로 로그인한 경우
5. java 또는 html을 검색한 경우
6. 검색 결과가 없는 경우
// main.java

import domain.CA;
import domain.Client;
import domain.Server;

import java.util.HashMap;
import java.util.Scanner;

public class Application {
    /**
     * 로직은 다음과 같다
     * 1. CA와 naver 서버가 생성된다
     * 2. 서버는 CA에게 인증을 받는다
     * 3. 클라이언트가 생성될 때, ca의 공개키를 저장한다(ca의 공개키는 브라우저에 내장되어 있다. 따라서, 클라이언트는 무조건 ca의 공개키를 들고 있어야 한다. 따라서, 다음과 같이 생성자에서 받아오는 식으로 구현을 했다)
     * 4. client는 요청을 보내고 응답 메세지를 받아온다
     */
    public static void main(String[] args) throws Exception{

        CA ca = new CA();
        Server naver = new Server("www.naver.com");
        naver.requestAuth(ca);

        Client clientA = new Client(ca.getPublicKey());

        Scanner scanner = new Scanner(System.in);

        System.out.print("HTTPS가 정상적으로 작동하는지 테스트하고 싶으면 0, \n" +
                "피싱서버에 요청을 보낸 상황을 테스트하고 싶으면 1을 입력하세요 : ");
        String testType = scanner.nextLine();
        if (testType.equals("0")) {
            Server fhishingServer = new Server("www.maver.com");
            fhishingServer.requestAuth(ca);
            clientA.request(fhishingServer, "www.naver.com", 0, new HashMap<String, String>());
        }

        // 피싱서버를 만들고, CA에게 인증을 받은 상태.
        Server fhishingServer = new Server("www.maver.com");
        fhishingServer.requestAuth(ca);
        while(true) {
            System.out.print("로그인을 원하면 0, 검색을 원하면 1을 눌러주세요. : ");
            int requestType = Integer.parseInt(scanner.nextLine());
            HashMap<String, String> params = new HashMap();
            if (requestType == 0) {
                params = showLoginForm(scanner);
            } else if(requestType == 1) {
                params = showSearchForm(scanner);
            }
            String result = clientA.request(naver, "www.naver.com", requestType, params);
            System.out.println("응답 메세지 : " + result);
        }
    }

    private static HashMap<String, String> showSearchForm(Scanner scanner) {
        HashMap<String, String> params = new HashMap();
        System.out.println("검색어를 입력하세요");
        String keyword = scanner.nextLine();

        params.put("keyword", keyword);
        return params;
    }

    private static HashMap<String, String> showLoginForm(Scanner scanner) {
        HashMap<String, String> params = new HashMap();
        System.out.println("아이디를 입력하세요");
        String username = scanner.nextLine();
        System.out.println("비밀번호를 입력하세요");
        String password = scanner.nextLine();

        params.put("username", username);
        params.put("password", password);
        return params;
    }

}

CA

필드 : publicKey, privatekey

책임

  • 서버를 검사한 뒤, 해당 서버의 인증서를 만든다 : makeCertificate
  • 자신의 publicKey를 클라이언트에게 제공한다 : getPublicKey
// CA.java

package domain;

import enums.UserType;
import utils.RSACrypto;

import java.util.HashMap;

public class CA {
    private static final String DELIMETER = "\n@\n@\n";
    private String publicKey;
    private String privateKey;

    // CA는 자신의 퍼블릭키, 프라이빗키를 가지고 있다
    public CA() {
        HashMap<String, String> rsaKeyPair = RSACrypto.createKeypairAsString(UserType.CA);
        this.publicKey = rsaKeyPair.get("publicKey");
        this.privateKey = rsaKeyPair.get("privateKey");
    }

    public String getPublicKey() {
        return publicKey;
    }

    /**
     * CA의 개인키로 사이트 정보, 사이트 public Key를 암호화를 시켜 인증서를 만든다
     * @param siteInfo : 현 예제에서는 siteURL만 들어갔다.
     * @param serverPubKey
     * @return 인증서 : String
     */
    public String makeCertificate(String siteInfo, String serverPubKey) {
        validate();
        return RSACrypto.encodeUsingPrivate(siteInfo + DELIMETER + serverPubKey, privateKey);
    }

    private void validate() {
        // CA는 서버에게 인증서를 주기 전, 서버가 제대로 된 사이트인지 검사하는 과정이 있다.
        // 해당 코드는 검사하는 과정을 표현했다
        // 예제에서는 그 과정이 필요 없다고 생각해 아무 내용도 넣지 않았다
    }
}

Server

필드 : serverUrl, publicKey, privatekey, certificate, symmetricKey

생성될 때 serverUrl, publicKey, privateKey를 저장한다. 이후 CA에게 인증서를 달라는 요청을 보내 certificate를 저장하고, Client에게 SymmetricKey를 전달받아 저장한다.

책임

  • 클라이언트에게 요청을 받아, 적절한 처리를 통해 결과를 만들어 응답을 보낸다 : send
  • 클라이언트에게 인증서를 보낸다 : giveCertificate
// Server.java

package domain;

import utils.AES256Crypto;
import utils.ObjectMapper;
import utils.RSACrypto;
import enums.UserType;

import java.util.HashMap;

public class Server {
    private final String serverUrl;
    private final String publicKey;
    private final String privateKey;
    private String certificate;
    private String symmetricKey;

    private HashMap<String, String> userStore;
    private HashMap<String, String> db;

    // Server는 자신의 퍼블릭키, 프라이빗키, 자신 서버의 정보를 가지고 있다
    // 현 예제에서는 서버의 정보로 url만을 가지고 있다
    public Server(String serverUrl) {
        initUserStore();
        initDataStore();

        HashMap<String, String> rsaKeyPair = RSACrypto.createKeypairAsString(UserType.SERVER);
        this.publicKey = rsaKeyPair.get("publicKey");
        this.privateKey = rsaKeyPair.get("privateKey");
        this.serverUrl = serverUrl;
    }

    private void initUserStore() {
        this.userStore = new HashMap<>();
        userStore.put("admin", "qwer1234");
    }

    private void initDataStore() {
        this.db = new HashMap<>();
        db.put("java", "자바는 프로그래밍 언어입니다.");
        db.put("html", "html은 프로그래밍 언어가 아닙니다");
    }

    public void requestAuth(CA ca) {
        this.certificate = ca.makeCertificate(serverUrl, publicKey);
    }

    public String giveCertificate() throws Exception {
        if (certificate == null) {
            throw new Exception("서버가 CA에게 인증받지 않아 HTTPS 통신이 불가능합니다");
        }
        return certificate;
    }

    public void saveSymmetricKey(String encodingSymKey) {
        // todo key-value로 바꿔서 여러 클라이언트 가능하도록
        this.symmetricKey = RSACrypto.decodeUsingPrivate(encodingSymKey, privateKey);
    }

    /**
     * 요청에 따라 적절한 기능을 수행한다
     * @param requestType : 0: 로그인, 1: 검색
     * @param encryptInput 대칭키로 암호화된 input
     * @return : 로직을 처리한 뒤 대칭키로 암호화한다
     * @throws Exception
     */
    public String send(int requestType, String encryptInput) throws Exception {
        if (symmetricKey == null) {
            throw new Exception("클라와 서버는 HTTPS 통신을 위한 대칭키가 존재하지 않습니다");
        }
        HashMap<String, String> params = ObjectMapper.convertMap(AES256Crypto.decrypt(encryptInput, symmetricKey));

        // 요청에 따라, 해당되는 요청을 수행
        if (requestType == 0) {
           return login(params);
        } else if (requestType == 1) {
            return search(params);
        }
        return AES256Crypto.encrypt("CANNOT_FOUND", symmetricKey);
    }

    private String login(HashMap<String, String> params) throws Exception {
        //로그인 해보기
        String username = params.get("username");
        String password = params.get("password");
        if (userStore.get(username) != null && userStore.get(username).equals(password)) {
            return AES256Crypto.encrypt("SUCCESS", symmetricKey);
        }
        return AES256Crypto.encrypt("CANNOT FOUND", symmetricKey);
    }

    private String search(HashMap<String, String> params) throws Exception {
        String keyword = params.get("keyword");
        if (db.get(keyword) == null) {
            return AES256Crypto.encrypt("", symmetricKey);
        }
        String result = db.get(keyword);
        return AES256Crypto.encrypt(result, symmetricKey);
    }
}

Client

필드 : caPublicKey, symmetricKey

생성될 때 CA의 publicKey를 저장한다.

책임

  • 서버에게 요청을 보낸다 : request
  • 서버의 인증서를 열어 server의 publicKey를 얻는다 : getServerPubKey
  • HTTPS 통신에서 사용할 대칭키를 만든다 : makeSymmetricKey
// Client.java

package domain;

import utils.AES256Crypto;
import utils.RSACrypto;

import java.util.HashMap;

public class Client {
    private static final String DELIMETER = "\n@\n@\n";

    private String caPublicKey;
    private String symmetricKey;

    public Client(String caPublicKey) {
        this.caPublicKey = caPublicKey;
    }

    /**
     * 서버에 요청을 보내 응답을 받는다
     * 응답을 decoding한 뒤 return한다
     * @param server
     * @param serverUrl : 인증서를 열면, 서버의 정보가 나온다. 해당 서버가 내가 요청을 보낸 서버가 맞는지 확인하기 위해 사용한다
     * @param requestType : 0: login, 1:search 를 보낸다. API를 약식으로 구현했다
     * @param params
     */
    public String request(Server server, String serverUrl, int requestType, HashMap<String, String> params) throws Exception {
        if(isNotConnected()) {
            String serverPubKey = getServerPubKey(server, serverUrl);
            this.symmetricKey = makeSymKey();

            // 서버에 해당 키를 암호화해 보내준다.
            String encodingSymKey = RSACrypto.encodeUsingPublic(symmetricKey, serverPubKey);
            server.saveSymmetricKey(encodingSymKey);
        }
        String encodingResponse = server.send(requestType,AES256Crypto.encrypt(params.toString(), this.symmetricKey));
        return AES256Crypto.decrypt(encodingResponse, this.symmetricKey);
    }

    private boolean isNotConnected() {
        return symmetricKey == null;
    }

    /**
     * HTTP 메세지 암호화에 사용활 대칭키를 만든다
     */
    private String makeSymKey() {
        return AES256Crypto.createSymmetricKey();
    }

    private String getServerPubKey(Server server, String serverUrl) throws Exception {
        String certificate = server.giveCertificate();
        String parseCertificate = RSACrypto.decodeUsingPublic(certificate, caPublicKey);
        String[] urlAndServerPublicKey = parseCertificate.split(DELIMETER);
        if (!(serverUrl.equals(urlAndServerPublicKey[0]))) {
            throw new Exception("서버가 다릅니다.");
        }
        return urlAndServerPublicKey[1];
    }
}

TLS 1.3부터 사용하는 키 교환 방식

앞서 우리는 비대칭키를 사용해서 키를 교환했습니다. 그런데 아무래도 복잡하고 그렇죠.
그래서 TLS 1.3부터는 디피-헬만 알고리즘 방식으로 키 교환, SSL Handshake가 일어납니다.
이 부분에 대해서는 이후에 암호화에 대한 글을 작성하며 자세하게 적어보겠습니다. 해당 내용에 대해 더 알고 싶으신 분들은 이 글을 읽어주세요.

더 하면 좋을 작업

  1. DELEMETER라는 상수로 Server Public Key와 서버정보를 분리하는데, 적절한 자료형을 만들어보기

  2. json <-> string 변환하는 라이브러리 만들어보기(ObjectMapper)

  3. HTTP 요청, 응답 메세지 형태를 맞춰서 만들어보기

  4. RSA, AES 암호화를 만들어보기

  5. 여러 암호화 클래스를 만들어 클라이언트, 서버끼리 알고리즘을 선택하는 과정을 만들기

여담

언제부터 HTTPS가 이렇게 많이 쓰였을까

본격적으로 HTTPS 사용이 시작된 건 2015년부터입니다. 바로 구글이 HTTPS를 적용한 사이트에 seo(검색우선순위)를 제공한 게 그 이유입니다. 검색 우선순위에서 떨어지지 않기 위해 대부분의 사이트들이 HTTPS로 빠르게 전환했습니다.

또 HTTPS로 암호화를 한다 하더라도 요청을 뚫을 수 있는 것 같습니다. 암호화 방식의 약점을 공략해 평문으로 내용을 바꾼다고 하는데, 보안에 관해 깊은 이해가 필요한 것 같습니다. 저는 그냥 이런 공격방식이 있구나, 정도로만 넘어갈 생각인데 더 궁금하신 분들은 HEIST라는 키워드로 찾아보시면 좋을 것 같습니다.

TLS 1.3이 생기며...

TLS 1.2까지는 클라이언트와 서버에서 사용할 대칭키교환 알고리즘, 인증서 서명 등을 묶어서 선택했습니다. Cipher Suite라고 합니다.
TLS 1.3부터는 각각의 알고리즘을 병렬적으로 선택할 수 있게 변경합니다. 알고리즘이 점점 많아지면서 모든 case를 묶어서 관리하기 어려워졌기 때문입니다.
TLS 1.3부터는 보안이 취약한 알고리즘 등이 빠졌으며, 타원 곡선 알고리즘을 기본으로 사용한다고 합니다. 추후에 이 부분에 대해 학습한 뒤 내용을 추가해보겠습니다.

마무리지으며

HTTPS는 해킹에 안전한 기술입니다. 그렇다고 사용자들에게 안전하다는 의미는 아닙니다. CA들이 서버를 검증한다고 하지만, 피싱사이트를 다 걸러내지는 못하나봅니다.


위 표를 보면 Let's Encrypt(특징 : 무료)라는 사이트는 6300개의 피싱 사이트에 인증서를 만들어줬네요. 즉, 피싱사이트도 HTTPS를 가질 수 있다는 의미입니다.

아래 그림에서 왼쪽이 진짜 Paypal, 오른쪽이 피싱사이트입니다. 피싱사이트 역시 CA에게 인증을 받으면 다음과 같이 HTTPS 사이트가 만들어집니다.

아니, 그러면 도대체 어떻게 구별하냐구요?

위 사진을 다시 보면, 왼쪽에는 HTTPS 요청에 추가로 회사 이름이 달려있습니다. 오른쪽은 그냥 Secure만 붙어있구요. EV인증서라는 걸 사용하면, 저렇게 회사 이름이 붙어서 보여지는데 보통 우리가 사용하는 naver, daum 이런 대형 서비스 회사들은 EV 인증서를 사용합니다.

Chrome 77부터 이름을 안보여주는 방향으로 정책이 변경되었네요..

그러면 피싱 사이트를 어떻게 구별해야 할까요? 바로 주소창에서 도메인명을 잘 봐야 합니다.
pay.naver.com이라는 도메인이 있다면

  • com 최상위 도메인,
  • naver.com 2단계 도메인,
  • pay.naver.com 3단계 도메인 입니다.

com이라는 도메인은, com이라는 단체에서 관리하는 웹사이트라는 의미가 됩니다.
naver.com이라는 도메인은 naver라는 사이트에서 관리하는 사이트입니다. 즉, 앞에가 어떤 모양을 갖던, 도메인이 naver.com으로 끝난다면 신뢰할 수 있다는 의미가 됩니다.

이제 몇 가지 예시를 통해 직접 사이트를 판단하면서 잘 이해했는지 확인해봅시다.

www.maver.com
pay.naver.com
pay.naver.jp
badsite.naver.com
pay.naver.cafe-809.com


#### 정답 ####
www.maver.com(안전하지 않음)
pay.naver.com(안전)
pay.naver.jp(안전하지 않음 -> naver.jp에서 관리하는 사이트. naver.jp는 우리가 아는 naver가 아니죠?)
badsite.naver.com(안전 -> naver.com에서 관리함)
pay.naver.cafe-809.com(안전하지 않음 -> cafe-809.com에 관리)

이상으로 Java를 사용해 HTTPS를 구현하는 걸 마무리짓겠습니다. 혹시 제가 잘못된 지식을 적어뒀다던가, 질문이 있으시다면 댓글 부탁드리겠습니다.
읽어주셔서 감사합니다 :)

참고

https://www.youtube.com/watch?v=H6lpFRpyl14

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=ucert&logNo=221469782367

https://rachel-kwak.github.io/2021/03/08/HTTPS.html

https://m.blog.naver.com/ucert/221637506294

https://luavis.me/server/tls-1.3

https://www.crocus.co.kr/1233

https://aws-hyoh.tistory.com/entry/HTTPS-%ED%86%B5%EC%8B%A0%EA%B3%BC%EC%A0%95-%EC%89%BD%EA%B2%8C-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0-3SSL-Handshake

profile
작은 지식 모아모아

0개의 댓글