Developer's Development

[JAVA] javax.crypto 파일 암/복호화 본문

개발/JAVA

[JAVA] javax.crypto 파일 암/복호화

mylee99 2023. 11. 15. 16:06

Case)
파일 업로드 시, 암호화(내용 암호화/파일명 난수 생성, 확장자 enc로 변경)해서 서버 업로드
파일 다운로드 시, 서버에 업로드된 파일 경로와 원래 파일명(확장자 포함)을 전달받아 복호화(내용 복호화/원래 파일명, 원래 확장자로 변경)해서 다운로드
 

Reference

 https://hongik-prsn.tistory.com/75

 

JAVA 암호화와 복호화 Cipher

먼저 암호화라는 개념은 너무나 간단합니다 내가 가진 원문의 메세지를 상대방이 해석할 수 없게 하는 것이 바로 암호화의 목적 javax.crypto.Cipher 클래스는 암호화 알고리즘을 나타낸다. 암호를

hongik-prsn.tistory.com

 

삽질 기록 (바쁘신 분은 바로 맨 아래로 이동 권장)

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

 

🌐 악명 높은 CORS 개념 & 해결법 - 정리 끝판왕 👏

악명 높은 CORS 에러 메세지 웹 개발을 하다보면 반드시 마주치는 멍멍 같은 에러가 바로 CORS 이다. 웹 개발의 신입 신고식이라고 할 정도로, CORS는 누구나 한 번 정도는 겪게 된다고 해도 과언이

inpa.tistory.com

 
.. 뭔가 단단히 잘못되어가고 있음을 느꼈다.
 
정신 차리고 [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 (예외 타입 변수) {
    //예외 처리
}


정리하는 지금도 어질어질하다 굉장히..
계속 삽질하다가 이번에도 빈 껍데기겠지.. 하고 다운로드된 파일 눌렀는데 제대로 떴을 때의 그 짜릿함은 잊을 수가 없다.
이 맛에 개발자 하나

Comments