클라이언트와 서버간의 안전한 통신을 위하여 간단하게 암호화를 만들어보았다. 작업 환경은 아래와 같다.
◼︎ 환경
- 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 키: 대칭키 암호화에서 데이터를 암호화하고 복호화하는데 사용되는 비밀 키. AES 키는 128, 192, 또는 256비트로 설정될 수 있다.
- IV (Initialization Vector): AES-CBC 모드에서 사용되는 추가 보안 요소. IV는 암호화할 때마다 무작위로 생성되고, 암호화된 데이터와 함께 전송됩다.
- Base64 인코딩: AES 키와 IV는 바이너리 데이터이므로, 이를 텍스트 형식으로 저장하고 전송하기 위해 Base64로 인코딩한다.
대칭키와 비대칭키 암호화의 장점과 단점은 아래와 같다.
① 특징:
- 암호화와 복호화에 같은 키를 사용.
- AES(Advanced Encryption Standard)와 같은 알고리즘이 대칭키 암호화에 해당.
- 빠른 속도: 대칭키 암호화는 비대칭키보다 훨씬 빠름. 특히 대량의 데이터를 암호화할 때 성능이 우수함.
- 간단한 구현: 동일한 키로 암호화와 복호화를 수행하기 때문에 상대적으로 간단하게 구현할 수 있음.
- 키 관리 문제: 대칭키를 안전하게 공유하는 것이 문제. 만약 네트워크를 통해 키를 전송해야 한다면, 키가 중간에서 가로채기당할 위험이 있음.
- 보안성 문제: 키가 노출되면 데이터를 보호할 방법이 없음. 키 교환이 안전하지 않다면, 대칭키 암호화는 취약할 수 있음.
비대칭키 암호화 (Asymmetric Encryption)
① 특징:
- 암호화에는 공개키를, 복호화에는 개인키를 사용.
- RSA(Rivest-Shamir-Adleman)와 같은 알고리즘이 비대칭키 암호화에 해당.
- 보안성: 비대칭키 암호화는 공개키를 사용해 암호화하므로 키 교환이 안전. 공개키는 누구나 사용할 수 있지만, 개인키는 소유자만이 알고 있기 때문에 보안성이 뛰어남.
- 키 관리 용이: 공개키는 네트워크를 통해 쉽게 공유할 수 있고, 개인키는 비밀로 유지되므로 키 교환이 안전하게 구현됨.
- 속도 문제: 비대칭키 암호화는 대칭키 암호화에 비해 훨씬 느림. 특히 대량의 데이터를 암호화하는 데 비효율적임.
- 데이터 크기 제한: RSA와 같은 비대칭키 알고리즘은 암호화할 수 있는 데이터의 크기가 키 길이에 의해 제한됨.
참고로 아래는 TLS/SSL 프로토콜(HTTPS) 을 쉽게 설명하는 이미지 이다. (출처: https://has3ong.github.io/computer%20science/ssl-tls/ )
클라이언트/서버간의 안전한 데이터 암호화를 위한 하이브리드 방식은 아래와 같은 절차를 따른다.
- 서버는 클라이언트(웹) 에게 RSA 공개키를 배포
- 클라이언트는 랜덤 생성된 AES 키를 사용하여 데이터를 암호화
- RSA 공개키로 AES 키를 암호화 하고 암호화된 데이터와 IV 값을 함께 서버에 전달
- 서버는 RSA 개인키로 암호화된 AES 키를 복호화하고 함께 전달된 IV 값을 사용하여 데이터를 복호화
하이브리드 방식의 암호화 방식을 적용하면 적은 노력으로 손쉽게 클라이언트와 서버간의 암호화 통신을 구현할 수 있다.
Can I Use - Web Cryptography API 사이트를 통하여 호환성을 확인해 볼 수 있다. 이 사이트에 따르면 대부분의 웹 브라우저에서 지원된다.
하이브리드 방식 암호화 구현하기
서버는 AES 암호화를 위한 대칭키를 암호화기 위하여 먼저 RSA 비대칭키 생성한다.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 공개키로 암호화를 한다.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
Encrypt Data 버튼을 클릭하여 데이터를 암호화 한다.
이제 공개키로 암호화된 AES 키 값, AES 로 암호화된 데이터와 IV 값을 서버에 전달한다.
- 공개키로 암호화된 AES 키 : BASE64로 인코딩
- IV : BASE64로 인코딩
- AES 로 암호화된 데이터 : BASE64로 인코딩
서버는 비밀키로 암호화된 대칭키 값을 복호화하고 키를 사용하여 암호화된 데이터를 복호화 한다.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 을 사용하여 작성.
댓글 없음:
댓글 쓰기