Developer's Development
[JAVA] javax.crypto 파일 암/복호화 본문
Case)
파일 업로드 시, 암호화(내용 암호화/파일명 난수 생성, 확장자 enc로 변경)해서 서버 업로드
파일 다운로드 시, 서버에 업로드된 파일 경로와 원래 파일명(확장자 포함)을 전달받아 복호화(내용 복호화/원래 파일명, 원래 확장자로 변경)해서 다운로드
Reference
https://hongik-prsn.tistory.com/75
삽질 기록 (바쁘신 분은 바로 맨 아래로 이동 권장)
Reference 참고하고, GPT의 도움을 받아 처음 작성했던 코드
import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class FileEncryption {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES";
public static void encryptFile(String key, File inputFile, File outputFile)
throws CryptoException {
doCrypto(Cipher.ENCRYPT_MODE, key, inputFile, outputFile);
}
public static void decryptFile(String key, File inputFile, File outputFile)
throws CryptoException {
doCrypto(Cipher.DECRYPT_MODE, key, inputFile, outputFile);
}
private static void doCrypto(int cipherMode, String key, File inputFile, File outputFile)
throws CryptoException {
try {
Key secretKey = new SecretKeySpec(key.getBytes(), ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(cipherMode, secretKey);
FileInputStream inputStream = new FileInputStream(inputFile);
byte[] inputBytes = new byte[(int) inputFile.length()];
inputStream.read(inputBytes);
byte[] outputBytes = cipher.doFinal(inputBytes);
FileOutputStream outputStream = new FileOutputStream(outputFile);
outputStream.write(outputBytes);
inputStream.close();
outputStream.close();
} catch (NoSuchPaddingException | NoSuchAlgorithmException
| InvalidKeyException | BadPaddingException
| IllegalBlockSizeException | IOException ex) {
throw new CryptoException("Error encrypting/decrypting file", ex);
}
}
public static class CryptoException extends Exception {
public CryptoException(String message, Throwable throwable) {
super(message, throwable);
}
}
public static void main(String[] args) {
String key = "YourSecretKey"; // 반드시 안전한 키를 사용하세요.
File inputFile = new File("input.txt");
File encryptedFile = new File("encryptedFile.enc");
File decryptedFile = new File("decryptedFile.txt");
try {
encryptFile(key, inputFile, encryptedFile);
decryptFile(key, encryptedFile, decryptedFile);
System.out.println("File encrypted and decrypted successfully.");
} catch (CryptoException e) {
System.err.println(e.getMessage());
}
}
}
가다듬는 과정
기존에 사용하고 있는 개인정보(File이 아닌 String) 암호화 코드를 참고하였다.
- TRANSFORMATION 변경 : "AES" → "AES/ECB/PCKCS5Padding"
- 사용 중인 암/복호화 키 설정
- **AES**: 대칭 알고리즘으로, 암호화 및 복호화에 동일한 키를 사용.
- **ECB (Electronic Codebook)**: 각 블록을 독립적으로 암호화하는 모드. 이는 동일한 평문 블록이 동일한 암호문 블록으로 변환되기 때문에 보안적으로 취약할 수 있음.
- **PKCS5Padding**: 블록 크기에 맞추어 부족한 부분을 패딩 하는 방법 중 하나.
- 차이점
1) **모드 (Mode)**: ECB 대신에 "AES/ECB/PKCS5Padding"을 사용하면 암호화 시 각 블록이 서로 다른 키로 암호화되기 때문에 같은 평문 블록이라도 다른 암호문 블록으로 변환. 이는 일반적으로 높은 보안 수준을 제공함.
2) **패딩 (Padding)**: 현재 코드는 PKCS7 패딩을 사용하고 있지만, "AES/ECB/PKCS5Padding"을 사용하면 PKCS5Padding이 적용. PKCS7와 PKCS5는 블록 크기에 따른 패딩 방식의 차이가 없어서 대부분의 상황에서 호환됨.
암호화 과정은 생각보다 잘 해결되었다.
문제는 복호화 과정이었는데, 경로가 없는 복호화 파일 자체를 클라이언트에게 제공해야 하는 것.
처음 도전했던 방법은 클라이언트의 다운로드 경로를 back단에서 지정하기..
이 방식의 가장 큰 문제점은 "CORS 정책 위반"과, 사용자에게 파일 다운로드 관련 이벤트를 제공하지 않는다는 것이었다.
얼떨결에 CORS 관련 공부까지 함.
https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F
.. 뭔가 단단히 잘못되어가고 있음을 느꼈다.
정신 차리고 [java 파일 다운로드]로 구글링 하여 좀 알아보니, 두 가지 방식으로 고려해 볼 수가 있었다.
1) front단에서 Blob으로 변환시켜 a 태그의 download 속성을 사용해 구현하기.
2) back단에서 byte로 파일을 읽어 응답하면 front단에서 바로 다운로드하게 하기.
당연히 1번부터 진행했다.
암/복호화에 관련된 기타 코드들은 생략한 삽질 코드
//back단 (java)
//FileUtil
// 파일 객체로 읽어오기
File decryptedFile = new File( fileVo.getDecryptedFile() );
FileUtils.writeByteArrayToFile( decryptedFile, outputBytes );
byte[] fileByte = FileUtils.readFileToByteArray( decryptedFile );
// byte[] 객체로 읽어오기
FileUtils.writeByteArrayToFile( decryptedFile, outputBytes );
return outputBytes;
//Service
/**
* (암호화)첨부파일 다운로드
*/
public ResponseEntity<byte[]> downloadFile( FileVo fileVo, HttpServletResponse response ) throws Exception {
//파일 복호화
fileVo.setEncryptedFile( new File( fileVo.getEncryptedFilePath() ) );
//byte[] 객체로 읽어오기 (원래 파일명으로 다운은 되는데 내용이 빈 값)
byte[] decryptedContent = fileUtil.decryptFile( fileVo );
HttpHeaders headers = new HttpHeaders();
headers.setContentType( MediaType.APPLICATION_OCTET_STREAM );
headers.setContentDispositionFormData("attachment", fileVo.getDecryptedFile() );
return ResponseEntity.ok().headers( headers ).body( decryptedContent );
}
//front단 (jQuery)
// 바이트 배열을 Blob으로 변환하여 파일 다운로드
const blob = new Blob([response], { type: 'application/octet-stream' });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = orgFileNm;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
.. 진짜 별별 방법을 다 써봤는데 0byte짜리 빈 껍데기가 다운로드되고, 인코딩 오류 파일 다운로드되고 대환장 파티
그러다 문득 사수 분께서 이 대사를 하신 거지. "너 근데 엑셀 파일 생성해서 다운로드는 어떻게 했는데?"
?
................??
!
이게 그 짧은 시간 동안 머릿속에 스친 생각 전부임.
그게 넘어가게 된 2번 방식이고, 고치면서 또 수많은 디버깅이 있었지만 이제 진짜 해결한 최최최종 코드 시작
HTML
<button th:onclick="goDownload([[${row.filePath}]], [[${row.orgFileNm}]])" type="button">다운로드</button>
<form id="fileDecryptForm">
<input type="hidden" name="encryptedFilePath"/>
<input type="hidden" name="decryptedFile"/>
</form>
jQuery
var $fileDecryptForm = $("#fileDecryptForm");
goDownload = function ( filePath, orgFileNm ) {
$fileDecryptForm.find("[name=encryptedFilePath]").val(filePath);
$fileDecryptForm.find("[name=decryptedFile]").val(orgFileNm);
$fileDecryptForm.attr({"action":"/FileDownload","target":"_self","method":"post"}).submit();
}
Controller
@ResponseBody
@RequestMapping("/FileDownload")
public void FileDownload( FileVo fileVo, HttpServletRequest request, HttpServletResponse response ) throws Exception {
//파일 복호화
fileVo.setEncryptedFile( new File( fileVo.getEncryptedFilePath() ) );
FileUtil.decryptFile( fileVo, request, response );
}
FileUtil (java)
// 암호화 파일 확장자
private String encryptedFileExt = "enc";
private static final String TRANSFORM = "AES/ECB/PKCS5Padding";
private static final String ALGORITHM = "AES";
private static final String KEY = "YourSecretKey"; //변경하세요
/**
* 파일 업로드
*/
public void execute(FileVo fileVo) throws Exception{
// ..기타 업로드 관련 코드 생략
// 신규파일 이름 생성
String randomFileName = UUID.randomUUID().toString();
String fileName = randomFileName + "." + encryptedFileExt;
// 첨부파일 저장
String serverFileName = destFileDir + fileName;
try {
fileVo.setEncryptedFilePath( serverFileName );
encryptFile( fileVo );
} catch (IllegalStateException e) {
log.error(e.getMessage());
}
}
/**
* 파일 암복호화
*/
public void encryptFile( FileVo fileVo ) throws CryptoException {
try {
Key secretKey = new SecretKeySpec(KEY.getBytes(), ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORM);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] inputBytes = fileVo.getFile().getBytes();
byte[] outputBytes = cipher.doFinal(inputBytes);
FileUtils.writeByteArrayToFile( new File( fileVo.getEncryptedFilePath() ), outputBytes );
} catch ( NoSuchPaddingException | NoSuchAlgorithmException
| InvalidKeyException | BadPaddingException
| IllegalBlockSizeException | IOException ex) {
throw new CryptoException("Error encrypting file", ex);
}
}
public static void decryptFile( FileVo fileVo, HttpServletRequest request, HttpServletResponse response ) {
try {
Key secretKey = new SecretKeySpec(KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM);
Cipher cipher = Cipher.getInstance(TRANSFORM);
cipher.init(Cipher.DECRYPT_MODE, secretKey);
FileInputStream inputStream = new FileInputStream(fileVo.getEncryptedFile());
// 한글깨짐 처리
String userAgent = request.getHeader("User-Agent");
String fileName;
if(userAgent.contains("MSIE") || userAgent.contains("Trident") || userAgent.contains("Chrome")){
fileName = URLEncoder.encode(fileVo.getDecryptedFile(),"UTF-8").replaceAll("\\+", "%20");
} else {
fileName = new String(fileVo.getDecryptedFile().getBytes("UTF-8"), "ISO-8859-1");
}
// ContetnType, Header 정보 설정
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition","attachment; filename="+fileName);
OutputStream os = response.getOutputStream();
CipherOutputStream cipherOutputStream = new CipherOutputStream(os, cipher);
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
cipherOutputStream.write(buffer, 0, bytesRead);
}
//inputStream.close();
//cipherOutputStream.close();
} catch ( Exception e ) {
log.error( "Error decrypting file : {}", e.getMessage() );
} finally {
//2023-11-17 - stream 자원 해제 시, finally 블록 사용 및 null check 로직 추가
//TODO try-with-resources
try {
if( inputStream != null ) inputStream.close();
if( cipherOutputStream != null ) cipherOutputStream.close();
} catch ( IOException e ) {
log.error( "Error closing stream : {}", e.getMessage() );
}
}
}
public class CryptoException extends Exception {
public CryptoException(String message, Throwable throwable) {
super(message, throwable);
}
}
try-with-resources는 Java 7에서 도입된 기능으로, 자원을 효과적으로 관리하기 위해 사용됨.
해당 블록이 끝날 때 자동으로 AutoClosable 또는 Closable 인터페이스를 구현한 자원들이 자동으로 닫힘.
따라서 별도로 finally 블록을 작성하지 않아도 자원을 안전하게 해제할 수 있음.
//예제 코드
try (리소스 생성 및 할당) {
//자원 사용
} catch (예외 타입 변수) {
//예외 처리
}
정리하는 지금도 어질어질하다 굉장히..
계속 삽질하다가 이번에도 빈 껍데기겠지.. 하고 다운로드된 파일 눌렀는데 제대로 떴을 때의 그 짜릿함은 잊을 수가 없다.
이 맛에 개발자 하나
'개발 > JAVA' 카테고리의 다른 글
[JAVA] Session Timer (서버 연동 실시간 갱신 세션 타이머) (0) | 2024.01.05 |
---|---|
[JAVA] 파일 미리보기 및 다운로드 (0) | 2023.12.29 |
[JAVA] Apache POI Excel Download '제한된 보기' 경고 미설정 (0) | 2023.10.30 |
[JAVA] Pattern, Stream 클래스를 통한 List 원소 상수 처리 (0) | 2023.03.23 |