HTTP 바디에 암호화 된 데이터를 주고 받아보자!
AES/CBC 암호화 알고리즘 사용
Key 값과 iv 값이 동일할 경우에 같은 평문을 암호화하면 같은 암호문이 나온다는 문제점이 있다. 따라서 iv 값은 매번 랜덤으로 생성(16바이트) 하도록 했다.
매번 iv 값이 달라지므로 iv 값과 암호문을 합쳐서 리턴
key는 32바이트 이므로 SHA-256 해시 값 사용
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'
}
public class AESUtil {
private final String ALGORITHM = "AES/CBC/PKCS5PADDING";
private final String KEY = "example";
private String iv;
...
}
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());
}
}
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();
}
}
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());
}
}
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());
}
}
@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());
}
}
클라이언트에서 암호화된 데이터가 http body에 담겨 넘어온다. (json 데이터)
언제 복호화 하는 것이 좋을까?
필터에서 복호화를 하여 DispatchServlet은 복호화 된 평문 데이터를 가지고 작업을 수행 하도록 하는 것이 좋을 것 같다.
출처 : https://justforchangesake.wordpress.com/2014/05/07/spring-mvc-request-life-cycle/
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()));
}
}
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);
}
}
@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());
}
}
@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;
}
}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
...
<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);
});
}
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));
}