[Springboot] HTTP 바디 데이터 암호화 전송하기

Bobby·2021년 9월 3일
6

즐거운 개발일지

목록 보기
5/22
post-thumbnail

HTTP 바디에 암호화 된 데이터를 주고 받아보자!

1. Preview

방식

  • AES/CBC 암호화 알고리즘 사용

  • Key 값과 iv 값이 동일할 경우에 같은 평문을 암호화하면 같은 암호문이 나온다는 문제점이 있다. 따라서 iv 값은 매번 랜덤으로 생성(16바이트) 하도록 했다.

  • 매번 iv 값이 달라지므로 iv 값과 암호문을 합쳐서 리턴

  • key는 32바이트 이므로 SHA-256 해시 값 사용

사용 라이브러리

  • SpringBoot Initialize 사용
  • org.springframework.boot' version '2.5.4'
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation group: 'commons-io', name: 'commons-io', version: '2.11.0'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

2. AES 암호화, 복호화

  • AESUtil 클래스를 만들자.
public class AESUtil {

    private final String ALGORITHM = "AES/CBC/PKCS5PADDING";
    private final String KEY = "example";
    private String iv;
    
    ...
    
}

KEY 값 생성

  • 키 값은 임의의 문자열의 SHA-256 해시값을 사용했다.
    private Key createKeySpec() {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hashBytes = digest.digest(KEY.getBytes(StandardCharsets.UTF_8));
            return new SecretKeySpec(hashBytes, "AES");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("createKeySpec fail : " + e.getMessage());
        }
    }

IV 값 생성

  • 매 요청마다 숫자와 영문을 조합한 랜덤 16바이트 값을 생성하여 멤버 변수에 저장
    private IvParameterSpec createIvSpec() {
        try {
            String iv = StringUtil.randomStr(16);
            this.iv = iv;
            return new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            throw new RuntimeException("createIvSpec fail : " +  e.getMessage());
        }

    }
  • 랜덤 값 만들기
public class StringUtil {

    public static String randomStr(int length) {
        Random random = new Random();
        StringBuilder str = new StringBuilder();
        for (int i = 0; i < length; i++) {
            int choice = random.nextInt(3);
            switch(choice) {
                case 0:
                    str.append((char)(random.nextInt(25)+97));
                    break;
                case 1:
                    str.append((char)(random.nextInt(25) +65));
                    break;
                case 2:
                    str.append((char)(random.nextInt(10) +48));
                    break;
                default:
                    break;
            }
        }
        return str.toString();
    }
}

암호화

  • 생성한 iv 값과 Base64로 인코딩한 값을 합친 문자열을 리턴한다.
public String encrypt(String data) {

        try {
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            cipher.init(Cipher.ENCRYPT_MODE, createKeySpec(), createIvSpec());
            byte[] encryptData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return iv + Base64Utils.encodeToString(encryptData);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
            throw new RuntimeException("encrypt fail : " + e.getMessage());
        }
    }

복호화

  • 데이터의 첫 16자리는 iv값, 이후의 데이터는 암호문이다.
  • 암호문은 Base64로 인코딩 되어 있으므로 디코딩 한다.
  • 복호화된 평문을 리턴한다.
public String decrypt(String data) {
        String ivStr = data.substring(0,16);
        String content = data.substring(16);
        byte[] dataBytes = Base64Utils.decodeFromString(content);

        try {
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            cipher.init(Cipher.DECRYPT_MODE, createKeySpec(), new IvParameterSpec(ivStr.getBytes(StandardCharsets.UTF_8)));
            byte[] original = cipher.doFinal(dataBytes);
            return new String(original, StandardCharsets.UTF_8);
        } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
            throw new RuntimeException("decrypt fail : " + e.getMessage());
        }
    }

동작 테스트

  • 테스트용 Dto클래스
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TestDto {

    private String username;
    private int age;
}
  • 테스트
class AESUtilTest {

    AESUtil util = new AESUtil();

    @Test
    void encryptTest() throws JsonProcessingException, NoSuchAlgorithmException {
        ObjectMapper objectMapper = new ObjectMapper();
        TestDto testDto = new TestDto("김", 20);

        String data = objectMapper.writeValueAsString(testDto);
        System.out.println("data = " + data);
        String encrypt = util.encrypt(data);
        System.out.println("encrypt = " + encrypt);

        String decrypt = util.decrypt(encrypt);
        System.out.println("decrypt = " + decrypt);
        TestDto origin = objectMapper.readValue(decrypt, TestDto.class);

        assertEquals(testDto.getUsername(), origin.getUsername());
        assertEquals(testDto.getAge(), origin.getAge());
    }
}


3. 필터

Request, Response Wrapper 생성

  • 클라이언트의 요청 정보가 담긴 HttpServletRequest 객체에서 getInputStream() 메소드를 사용하여 바디 데이터를 꺼낼 수 있다.
  • 하지만 한 번 호출 후 재 호출이 불가능하고 복호화 후 다시 데이터를 request input stream에 쓰기가 불가능 하다.
  • 따라서 getInputStream()을 재사용 할 수 있도록 Wrapper클래스를 생성해야 한다.

RequestWrapper

  • HttpServletRequest를 생성자로 주입받아서 바디 데이터를 꺼내 복호화 한 후 멤버변수에 저장한다.
  • getInputStream()를 오버라이딩하여 복호화된 decodingBody를 가지고 input stream을 생성하여 리턴 하도록 한다.
public class RequestDecryptWrapper extends HttpServletRequestWrapper {
    private final Charset encoding;
    private String decodingBody;
    private byte[] rawData;

    public RequestDecryptWrapper(HttpServletRequest request) {
        super(request);
        String charEncoding = request.getCharacterEncoding();

        this.encoding = ObjectUtils.isEmpty(charEncoding) ? StandardCharsets.UTF_8 : Charset.forName(charEncoding);

        try {
            InputStream inputStream = request.getInputStream();
            rawData = IOUtils.toByteArray(inputStream);

            if (ObjectUtils.isEmpty(rawData)) {
                return;
            }
            AESUtil aesUtil = new AESUtil();

            this.decodingBody = aesUtil.decrypt(new String(rawData, StandardCharsets.UTF_8));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    public ServletInputStream getInputStream() {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(decodingBody == null ? "".getBytes(encoding) : decodingBody.getBytes(encoding));
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener listener) {
            }

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

Response Wrapper

  • 요청 수행을 마치고 그 결과 값을 클라이언트로 전달하기 위한 reponse 객체의 output stream에 평문데이터를 쓴다.
  • getOutputStream()를 오버라이딩 하여 해당 메소드가 호출되어 write() 가 실행 될때 ByteArrayOutputStream에 쓰도록 한다.
  • encryptResponse() : Wrapper에 담긴 데이터를 꺼내서 암호화 하는 메소드
public class ResponseEncryptWrapper extends HttpServletResponseWrapper {
    private final ByteArrayOutputStream output;

    public ResponseEncryptWrapper(HttpServletResponse response) {
        super(response);
        output = new ByteArrayOutputStream();
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        return new ServletOutputStream() {
            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setWriteListener(WriteListener listener) {

            }

            @Override
            public void write(int b) throws IOException {
                output.write(b);
            }
        };
    }

    public byte[] encryptResponse() {
        String responseMessage = new String(output.toByteArray(), StandardCharsets.UTF_8);
        AESUtil aesUtil = new AESUtil();
        return aesUtil.encrypt(responseMessage).getBytes(StandardCharsets.UTF_8);
    }
}

필터 생성

  • 앞서 만든 Wrapper 들을 생성하여 필터 체인에 넘긴다.
  • 모든 로직을 수행하고 나서 httpServletResponse의 output stream에 암호화 된 데이터를 쓴다.
@WebFilter("/*")
public class HttpEncryptionFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        RequestDecryptWrapper requestDecryptWrapper = new RequestDecryptWrapper(httpServletRequest);
        ResponseEncryptWrapper responseEncryptWrapper = new ResponseEncryptWrapper(httpServletResponse);

        chain.doFilter(requestDecryptWrapper, responseEncryptWrapper);

        httpServletResponse.getOutputStream().write(responseEncryptWrapper.encryptResponse());
    }
}
  • 필터를 동작하게 하기 위해서 @ServletComponentScan에 추가한다.

4. 컨트롤러

  • 간단하게 TestDto 객체로 받아 다시 리턴하는 메소드
  • inputStream도 한 번 꺼내봤다..
@RestController
public class TestController {

    @CrossOrigin
    @PostMapping("/")
    public TestDto hello(HttpServletRequest request, @RequestBody TestDto dto) throws IOException {

        ServletInputStream inputStream = request.getInputStream();
        byte[] bytes = IOUtils.toByteArray(inputStream);
        String s = new String(bytes, StandardCharsets.UTF_8);
        System.out.println("s = " + s);

        return dto;
    }
}

테스트

  • 포스트맨 이용
  • 암호화 된 데이터는 json형태가 아니지만 복호화 한 후 평문 데이터가 json형태 이므로 Content-Type을 application/json 으로 해야한다.


5. 자바스크립트로 요청하기

  • Crypto-js 라이브러리 사용
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
  • form 데이터를 전송한 다고 가정

...
    <form id="form">
        <div>
            <span>이름</span> <input name="username" type="text"/>
        </div>
        <div>
            <span>나이</span> <input name="age" type="text"/>
        </div>
        <div>
            <button type="button" onclick="btn()">등록</button>
        </div>
    </form>
...
  • 버튼 클릭시
    const btn = () => {
        const key = "example";
        const sha256 = CryptoJS.SHA256(key);
        const url = "http://localhost:8080"
        fetch(url, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: encrypt(sha256)
        })
            .then(resp => resp.text())
            .then(data => {
                console.log(data);
                decrypt(data, sha256);
            });

    }

AES 암호화, 복호화

  • 자바에서와 같은 로직을 수행
    const encrypt = (sha256) => {
        let form = document.querySelector('#form');
        let formData = new FormData(form);
        let obj = {};
        for (let key of formData.keys()) {
            obj[key] = formData.get(key);
        }
        let iv = Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 10);
        let string = CryptoJS.AES.encrypt(
            JSON.stringify(obj),
            sha256,
            {
                iv: CryptoJS.enc.Utf8.parse(iv),
                mode: CryptoJS.mode.CBC,
                padding: CryptoJS.pad.Pkcs7,
            },
        ).toString();
        return iv + string;
    }

    const decrypt = (data, sha256) => {
        const iv = data.substring(0, 16);
        const content = data.substring(16);

        let result = CryptoJS.AES.decrypt(
            content,
            sha256,
            {
                iv: CryptoJS.enc.Utf8.parse(iv),
                padding: CryptoJS.pad.Pkcs7,
            }
        ).toString(CryptoJS.enc.Utf8);

        console.log(JSON.parse(result));
    }

테스트

  • 암호화 데이터와 복호화 한 후 데이터를 객체에 담아 출력 확인

코드


6. 문제점

  • 만들다보니 문제점이 있다. 웹에서 키 값이 노출 된다는 점이다.
  • 다음은 RSA로 키를 암호화하여 전송하는 방법을 추가 해봐야 겠다.
    • 서버에서 개인키, 공개키 쌍 생성
    • 클라이언트에서 서버로 공개키 요청
    • 임의의 키 값을 공개키로 암호화하여 전송
    • 전송할 데이터는 aes 암호화 후 전송
    • 서버에서 개인키로 복호화 해서 aes 키 값 추출
    • 해당 키로 데이터 복호화 후 사용
    • 이 방법을 쓴다고 해도 의미가 있을까...?

7. 참고

profile
물흐르듯 개발하다 대박나기

0개의 댓글