2024년 10월 26일

코딩 - 자바스크립트 CryptoJS 와 JAVA 간의 하이브리드 암호화 (RSA+AES)

클라이언트와 서버간의 안전한 통신을 위하여 간단하게 암호화를 만들어보았다. 작업 환경은 아래와 같다. 

◼︎ 환경

  • HW : MacBook Pro (14-inch, 2021)
  • CPU : Apple M1 Pro
  • MENORY : 16GB
  • DISK : 512 GB SSD
  • OS : macOS 15.0.1 (24A348)
  • TOOLS : Visual Studio Code, Java 11 
  • Programming Language : Java, HTML, JavaScript 


개인적으로 데이터 암호화는 AES (Advanced Encryption Standard) 알고리즘을 사용한다. AES는 대칭키 알고리즘 중 하나로, 데이터 암호화와 복호화에 동일한 키를 사용한다. 
  • AES 키: 대칭키 암호화에서 데이터를 암호화하고 복호화하는데 사용되는 비밀 키. AES 키는 128, 192, 또는 256비트로 설정될 수 있다.
  • IV (Initialization Vector): AES-CBC 모드에서 사용되는 추가 보안 요소. IV는 암호화할 때마다 무작위로 생성되고, 암호화된 데이터와 함께 전송됩다.
  • Base64 인코딩: AES 키와 IV는 바이너리 데이터이므로, 이를 텍스트 형식으로 저장하고 전송하기 위해 Base64로 인코딩한다.
서버/클라이언트 환경을 고려하면 대칭키 방식의 암호화/복호화는 서버와 클라이언트 모두가 AES 키 와 IV 값을 미리 알고 있어야 한다.  키 관리의 위험성을 고려하면 암호화 키를 서버와 클라이언트가 공유 하는 대칭키 방식 보다는 비대칭키를 사용하는 것이 더 키 관리에 안전하다고 할 수 있다. 

대칭키와 비대칭키 암호화의 장점과 단점은 아래와 같다.

대칭키 암호화 (Symmetric Encryption)
① 특징:
  • 암호화와 복호화에 같은 키를 사용.
  • AES(Advanced Encryption Standard)와 같은 알고리즘이 대칭키 암호화에 해당.
② 장점:
  • 빠른 속도: 대칭키 암호화는 비대칭키보다 훨씬 빠름. 특히 대량의 데이터를 암호화할 때 성능이 우수함.
  • 간단한 구현: 동일한 키로 암호화와 복호화를 수행하기 때문에 상대적으로 간단하게 구현할 수 있음.
③ 단점:
  • 키 관리 문제: 대칭키를 안전하게 공유하는 것이 문제. 만약 네트워크를 통해 키를 전송해야 한다면, 키가 중간에서 가로채기당할 위험이 있음.
  • 보안성 문제: 키가 노출되면 데이터를 보호할 방법이 없음. 키 교환이 안전하지 않다면, 대칭키 암호화는 취약할 수 있음.

비대칭키 암호화 (Asymmetric Encryption)
① 특징:
  • 암호화에는 공개키를, 복호화에는 개인키를 사용.
  • RSA(Rivest-Shamir-Adleman)와 같은 알고리즘이 비대칭키 암호화에 해당.
② 장점:
  • 보안성: 비대칭키 암호화는 공개키를 사용해 암호화하므로 키 교환이 안전. 공개키는 누구나 사용할 수 있지만, 개인키는 소유자만이 알고 있기 때문에 보안성이 뛰어남.
  • 키 관리 용이: 공개키는 네트워크를 통해 쉽게 공유할 수 있고, 개인키는 비밀로 유지되므로 키 교환이 안전하게 구현됨.
③ 단점:
  • 속도 문제: 비대칭키 암호화는 대칭키 암호화에 비해 훨씬 느림. 특히 대량의 데이터를 암호화하는 데 비효율적임.
  • 데이터 크기 제한: RSA와 같은 비대칭키 알고리즘은 암호화할 수 있는 데이터의 크기가 키 길이에 의해 제한됨.
이들 암호화 방식의 특성을 고려하면 서버/클라이언트 환경에서는  TLS/SSL 프로토콜(HTTPS) 등에서 널리 사용되는 비대칭키와 대칭키를 결합한 하이브리드 암호화 방식이 가장 안전하고 효율적이라고 할 수 있다. 

참고로 아래는 TLS/SSL 프로토콜(HTTPS) 을 쉽게 설명하는 이미지 이다. (출처https://has3ong.github.io/computer%20science/ssl-tls/ )


클라이언트/서버간의 안전한 데이터 암호화를 위한 하이브리드 방식은 아래와 같은 절차를 따른다.
  1. 서버는 클라이언트(웹) 에게 RSA 공개키를 배포
  2. 클라이언트는 랜덤 생성된 AES 키를 사용하여 데이터를 암호화
  3. RSA 공개키로 AES 키를 암호화 하고 암호화된 데이터와 IV 값을 함께 서버에 전달
  4. 서버는 RSA 개인키로 암호화된 AES 키를 복호화하고 함께 전달된 IV 값을 사용하여 데이터를 복호화



하이브리드 방식의 암호화 방식을 적용하면 적은 노력으로 손쉽게 클라이언트와 서버간의 암호화 통신을 구현할 수 있다.  

하이브리드 방식 암호화 구현에 있어 클라이언트에서 ❶ AES 암호화는 CryptoJS (JavaScript library of crypto standards) 을 사용하였고 ❷ RSA 암호화는 웹 암호화 API 의 일부로 제공되는 crypto.subtle 을 사용하였다. 주의 할 점은 crypto.subtle API는모든 브라우저에서 지원하지 않으며, 특히 일부 구형 브라우저나 특정 모바일 브라우저에서는 지원되지 않을 수 있다. 서버의 경우 기본 자바의 암호화 기능을 사용하였다.

Can I Use - Web Cryptography API 사이트를 통하여 호환성을 확인해 볼 수 있다. 이 사이트에 따르면 대부분의 웹 브라우저에서 지원된다. 

하이브리드 방식 암호화 구현하기

서버는 AES 암호화를 위한 대칭키를 암호화기 위하여 먼저 RSA 비대칭키  생성한다

import java.io.File;
import java.io.IOException;
import java.security.Key;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Base64;
import org.apache.commons.io.FileUtils;
public class TestRSAKeyPair {
// RSA 키 쌍 생성 (키 크기: 2048비트)
public static KeyPair generateRSAKeyPair(int keySize) throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(keySize);
return keyPairGenerator.generateKeyPair();
}
public static String convertKeyToPEM(Key key) {
String base64Key = Base64.getEncoder().encodeToString(key.getEncoded());
String pemFormattedKey = base64Key.replaceAll("(.{64})", "$1\n"); // 64자마다 줄바꿈 추가
if (key instanceof PrivateKey) {
return "-----BEGIN PRIVATE KEY-----\n" + pemFormattedKey + "\n-----END PRIVATE KEY-----";
} else {
return "-----BEGIN PUBLIC KEY-----\n" + pemFormattedKey + "\n-----END PUBLIC KEY-----";
}
}
public static void savePEM(String keyPEM , File file) throws IOException {
FileUtils.write(file, keyPEM, "UTF-8");
}
public static void log(Object obj) {
System.out.println(obj);
}
public static void main(String[] args) {
try {
//1. RSA 키 쌍 생성
KeyPair keyPair = generateRSAKeyPair(2048);
// 2. 공개키와 개인키 가져오기
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
// 3.1 공개키를 PEM 형식으로 변환하여 출력
String publicKeyPEM = convertKeyToPEM( publicKey );
log("Public Key (PEM):");
log(publicKeyPEM);
File dir = new File("키를 저장할 경로");
// 3.2 pem 파일 저장
File file1 = new File(dir, "public.pem");
savePEM(publicKeyPEM, file1);
// 4.1 개인키를 PEM 형식으로 변환하여 출력
String privateKeyPEM = convertKeyToPEM( privateKey );
log("\nPrivate Key (PEM):");
log(privateKeyPEM);
// 4.2 pem 파일 저장
File file2 = new File( dir, "private.pem");
savePEM(privateKeyPEM, file2);
} catch (Exception e) {
e.printStackTrace();
}
}
}

비밀키는 안전하게 보관하고 공개키는 클라이언트에 전달한다. 클라이언트는 AES 키를 생성하여 데이터를 암호화하고 AES 키는 서버가 공개한 RAS 공개키로 암호화를 한다.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AES Encryption Example</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
// AES 키와 IV 값을 생성하고 AES로 데이터를 암호화
function generateAESKey() {
const key = CryptoJS.lib.WordArray.random(32); // 256비트 AES 키 생성
const iv = CryptoJS.lib.WordArray.random(16); // 128비트 IV 생성
return { key, iv };
}
// AES 암호화 함수
function encryptDataAES(plainText, aesKey, iv) {
const encrypted = CryptoJS.AES.encrypt(plainText, aesKey, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString(); // Base64 형식으로 반환
}
// AES 키를 Uint8Array로 변환
function aesKeyToUint8Array(aesKey) {
const words = aesKey.words;
const keyBytes = [];
for (let i = 0; i < words.length; i++) {
const word = words[i];
keyBytes.push((word >> 24) & 0xff);
keyBytes.push((word >> 16) & 0xff);
keyBytes.push((word >> 8) & 0xff);
keyBytes.push(word & 0xff);
}
return new Uint8Array(keyBytes);
}
// AES 키를 공개키로 암호화
async function encryptAESKeyWithRSA(aesKey, publicKeyPem) {
const aesKeyArrayBuffer = aesKeyToUint8Array(aesKey).buffer;
const publicKeyArrayBuffer = pemToArrayBuffer(publicKeyPem);
const publicKey = await crypto.subtle.importKey(
"spki",
publicKeyArrayBuffer,
{
name: "RSA-OAEP",
hash: { name: "SHA-256" }
},
true,
["encrypt"]
);
const encryptedAESKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
publicKey,
aesKeyArrayBuffer
);
return btoa(String.fromCharCode(...new Uint8Array(encryptedAESKey))); // Base64로 인코딩된 AES 키
}
// PEM 형식의 공개키를 ArrayBuffer로 변환
function pemToArrayBuffer(pem) {
const pemHeader = "-----BEGIN PUBLIC KEY-----";
const pemFooter = "-----END PUBLIC KEY-----";
const pemContents = pem.substring(pemHeader.length, pem.length - pemFooter.length).replace(/\s/g, '');
const binaryString = atob(pemContents);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
// IV 값을 Base64로 변환
function encodeIvToBase64(iv) {
const ivArray = iv.words.map(word => [
(word >> 24) & 0xff,
(word >> 16) & 0xff,
(word >> 8) & 0xff,
word & 0xff,
]).flat();
return btoa(String.fromCharCode(...ivArray));
}
async function runEncryption() {
const plainText = document.getElementById("plainText").value;
const publicKeyPem = document.getElementById("publicKeyPem").value;
// 1. AES 키와 IV 생성 및 데이터 암호화
const { key: aesKey, iv } = generateAESKey();
const encryptedData = encryptDataAES(plainText, aesKey, iv);
// 2. AES 키를 공개키로 암호화
const encryptedAESKey = await encryptAESKeyWithRSA(aesKey, publicKeyPem);
// 3. 결과 출력
document.getElementById("iv").value = encodeIvToBase64(iv); // IV 값 Base64로 인코딩하여 출력
document.getElementById("encryptedData").value = encryptedData; // AES로 암호화된 데이터
document.getElementById("encryptedAESKey").value = encryptedAESKey; // RSA로 암호화된 AES 키
}
</script>
</head>
<body>
<h1>AES Encryption Example with RSA Encrypted Key</h1>
<label for="plainText">Plain Text:</label><br>
<textarea id="plainText" rows="4" cols="50">This is a secret message</textarea><br><br>
<label for="publicKeyPem">RSA Public Key (PEM):</label><br>
<textarea id="publicKeyPem" rows="10" cols="70">-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlGtFGvtuFL1ux6oDTU8j
FQ6ecm8pIP1ea7HT49nC0AN6al7g+YfEmetmNU/RnkOl9oARsGTMYZRagjTwPKXj
EZhT2cmygzs3CcN8WjvSRw1V178+rOMDDgKANin/L/3o3exIk0nJm1+ux6qzEVnD
npMWdsgODAeZCX9s23ZLLlr9Quy+HjnzxrOUU7Y4oznADkfqHcf5jbZ1fZWIPCbE
ZgIkWK5mx+1iqu1CQ/gPr5dNPfA71B0ocvbg4JwzW9cuhWtDOj8Zg9wz8otrZi8O
L6Y8/AuOe6kj5SJ5ugDsiUDYRuZwQWO3rAecG9ngryEWvNYfUVfs1YgMsjFbw5US
bwIDAQAB
-----END PUBLIC KEY-----</textarea><br><br>
<button onclick="runEncryption()">Encrypt Data</button><br><br>
<label for="iv">IV (Base64):</label><br>
<textarea id="iv" rows="2" cols="70" readonly></textarea><br><br>
<label for="encryptedData">Encrypted Data (Base64):</label><br>
<textarea id="encryptedData" rows="4" cols="70" readonly></textarea><br><br>
<label for="encryptedAESKey">Encrypted AES Key (Base64):</label><br>
<textarea id="encryptedAESKey" rows="4" cols="70" readonly></textarea><br><br>
</body>
</html>
view raw test.html hosted with ❤ by GitHub
Encrypt Data 버튼을 클릭하여 데이터를 암호화 한다.

이제 공개키로 암호화된 AES 키 값, AES 로 암호화된 데이터와 IV 값을 서버에 전달한다.
  • 공개키로 암호화된 AES 키 : BASE64로 인코딩 
  • IV : BASE64로 인코딩 
  • AES 로 암호화된 데이터 : BASE64로 인코딩

서버는 비밀키로 암호화된 대칭키 값을 복호화하고 키를 사용하여 암호화된 데이터를 복호화 한다.

import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
public class TestDecrypt {
// RSA 개인키로 AES 키 복호화
public static String decryptAESKeyWithRSA(String encryptedAESKey, String privateKeyStr) throws Exception {
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decryptedAESKeyBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedAESKey));
return new String(decryptedAESKeyBytes);
}
// AES 키로 데이터를 복호화
public static String decryptDataWithAES(String encryptedData, String aesKey) throws Exception {
byte[] keyBytes = aesKey.getBytes("UTF-8");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
byte[] decodedBytes = Base64.getDecoder().decode(encryptedData);
byte[] decryptedBytes = cipher.doFinal(decodedBytes);
return new String(decryptedBytes);
}
public static void main(String[] args) {
try {
// 클라이언트로부터 전송받은 암호화된 AES 키 및 데이터
String encryptedAESKey = "암호화된 AES 키";
String encryptedData = "암호화된 데이터";
// RSA 개인키 (Base64로 인코딩된 문자열)
String privateKeyStr = "Base64로 인코딩된 개인키";
// RSA 개인키로 AES 키 복호화
String aesKey = decryptAESKeyWithRSA(encryptedAESKey, privateKeyStr);
System.out.println("Decrypted AES Key: " + aesKey);
// 복호화된 AES 키로 데이터 복호화
String decryptedData = decryptDataWithAES(encryptedData, aesKey);
System.out.println("Decrypted Data: " + decryptedData);
} catch (Exception e) {
e.printStackTrace();
}
}
}

참고자료 

  • ChatGPT 4o : 코드는 ChatGPT 4o 을 사용하여 작성.

댓글 없음:

댓글 쓰기