소개
AES와 RSA 암복호화에 대한 설명을 적었다.
AES
하나의 키로 암복호화를 하는 대칭키 블록 암복호화 방식
CBC (Cipher Block Chaining)
- 대표 규칙 : AES/CBC/PKCS5Padding
- 이전 블록의 암호문이 다음 블록의 평문과 XOR 연산되는 방식.
- 보안을 위해 IV 값 사용 권장하며, IV는 16바이트 필요
- 데이터 조작 시 복호화는 되지만 쓰레기 값이 나옴 (무결성 보장 미흡).
- Padding이 필수
GCM (Galois/Counter Mode)
- 대표 규칙: AES/GCM/NoPadding
- 암호화와 동시에 인증 태그를 생성하여 데이터 위변조를 즉각 감지
- CTR (카운터 방식)에 인증 기능을 추가한 암호화 방식
- Padding 없음
예시 코드
- Java
javaimport javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; /** * AES 암복호화 유틸리티 */ public class AesUtils { private static final String AES_ALGO = "AES"; private static final String CBC_TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final String GCM_TRANSFORMATION = "AES/GCM/NoPadding"; private static final int GCM_TAG_LENGTH = 128; // bit private static final int IV_SIZE = 16; // CBC IV size (16 bytes) private static final int GCM_IV_SIZE = 12; // GCM nonce size (12 bytes recommended) // CBC IV 고정값 방식 (동일 평문 -> 동일 암호문: 검색 인덱스용으로 제한적 사용) public static String encryptCbcFixedIv(String plainText, String key, String fixedIv) throws Exception { Cipher cipher = Cipher.getInstance(CBC_TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGO); IvParameterSpec ivSpec = new IvParameterSpec(fixedIv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public static String decryptCbcFixedIv(String cipherText, String key, String fixedIv) throws Exception { Cipher cipher = Cipher.getInstance(CBC_TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGO); IvParameterSpec ivSpec = new IvParameterSpec(fixedIv.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decoded = Base64.getDecoder().decode(cipherText); return new String(cipher.doFinal(decoded), StandardCharsets.UTF_8); } // CBC IV 랜덤값 방식 (보안성 강화, IV를 결과물 앞에 결합) public static String encryptCbcRandomIv(String plainText, String key) throws Exception { byte[] iv = new byte[IV_SIZE]; new SecureRandom().nextBytes(iv); // SecureRandom 사용 Cipher cipher = Cipher.getInstance(CBC_TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGO); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // IV(16) + CipherText 결합 byte[] combined = ByteBuffer.allocate(iv.length + encrypted.length) .put(iv) .put(encrypted) .array(); return Base64.getEncoder().encodeToString(combined); } public static String decryptCbcRandomIv(String combinedText, String key) throws Exception { byte[] combined = Base64.getDecoder().decode(combinedText); ByteBuffer buffer = ByteBuffer.wrap(combined); byte[] iv = new byte[IV_SIZE]; buffer.get(iv); byte[] encrypted = new byte[buffer.remaining()]; buffer.get(encrypted); Cipher cipher = Cipher.getInstance(CBC_TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGO); IvParameterSpec ivSpec = new IvParameterSpec(iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); } // GCM 방식 (Authenticated Encryption, 현대적인 추천 방식) public static String encryptGcm(String plainText, String key) throws Exception { byte[] iv = new byte[GCM_IV_SIZE]; new SecureRandom().nextBytes(iv); Cipher cipher = Cipher.getInstance(GCM_TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGO); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); cipher.init(Cipher.ENCRYPT_MODE, keySpec, spec); // 이때 encrypted 맨 뒤 16바이트는 GCM인증 태그가 들어간다 byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); byte[] combined = ByteBuffer.allocate(iv.length + encrypted.length) .put(iv) .put(encrypted) .array(); return Base64.getEncoder().encodeToString(combined); } public static String decryptGcm(String combinedText, String key) throws Exception { byte[] combined = Base64.getDecoder().decode(combinedText); ByteBuffer buffer = ByteBuffer.wrap(combined); byte[] iv = new byte[GCM_IV_SIZE]; buffer.get(iv); byte[] encrypted = new byte[buffer.remaining()]; buffer.get(encrypted); Cipher cipher = Cipher.getInstance(GCM_TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES_ALGO); GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); cipher.init(Cipher.DECRYPT_MODE, keySpec, spec); return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); } }
- Typescript (Server Side)
tsximport crypto from "crypto"; /** * Server-Side AES Encryption Utility */ const AES_ALGO = "aes-256-cbc"; const GCM_ALGO = "aes-256-gcm"; const IV_SIZE = 16; const GCM_IV_SIZE = 12; const GCM_TAG_SIZE = 16; // 128 bits export const AesUtils = { // CBC IV 고정값 방식 encryptCbcFixedIv(plainText: string, key: string, fixedIv: string): string { const cipher = crypto.createCipheriv(AES_ALGO, Buffer.from(key), Buffer.from(fixedIv)); let encrypted = cipher.update(plainText, "utf8", "base64"); encrypted += cipher.final("base64"); return encrypted; }, decryptCbcFixedIv(cipherText: string, key: string, fixedIv: string): string { const decipher = crypto.createDecipheriv(AES_ALGO, Buffer.from(key), Buffer.from(fixedIv)); let decrypted = decipher.update(cipherText, "base64", "utf8"); decrypted += decipher.final("utf8"); return decrypted; }, // CBC IV 랜덤값 방식 encryptCbcRandomIv(plainText: string, key: string): string { const iv = crypto.randomBytes(IV_SIZE); const cipher = crypto.createCipheriv(AES_ALGO, Buffer.from(key), iv); const encrypted = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]); // IV(16) + CipherText 결합 후 Base64 인코딩 return Buffer.concat([iv, encrypted]).toString("base64"); }, decryptCbcRandomIv(combinedText: string, key: string): string { const combined = Buffer.from(combinedText, "base64"); const iv = combined.subarray(0, IV_SIZE); const encrypted = combined.subarray(IV_SIZE); const decipher = crypto.createDecipheriv(AES_ALGO, Buffer.from(key), iv); let decrypted = decipher.update(encrypted).toString("utf8"); decrypted += decipher.final("utf8"); return decrypted; }, // GCM 방식 (Auth Tag를 포함하여 무결성 보장) encryptGcm(plainText: string, key: string): string { const iv = crypto.randomBytes(GCM_IV_SIZE); const cipher = crypto.createCipheriv(GCM_ALGO, Buffer.from(key), iv); const encrypted = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); // GCM은 인증 태그가 필수. 16바이트로 생성됨 // Node.js는 Tag가 별도로 나오므로 [IV + Encrypted + Tag] 순서로 구성 return Buffer.concat([iv, encrypted, tag]).toString("base64"); }, decryptGcm(combinedText: string, key: string): string { const combined = Buffer.from(combinedText, "base64"); const iv = combined.subarray(0, GCM_IV_SIZE); // Java의 Cipher.doFinal(GCM)은 데이터 끝에 16바이트 Tag를 붙임 const tag = combined.subarray(combined.length - GCM_TAG_SIZE); const encrypted = combined.subarray(GCM_IV_SIZE, combined.length - GCM_TAG_SIZE); const decipher = crypto.createDecipheriv(GCM_ALGO, Buffer.from(key), iv); decipher.setAuthTag(tag); let decrypted = decipher.update(encrypted).toString("utf8"); decrypted += decipher.final("utf8"); return decrypted; }, };
- Typescript (Client Side)
tsx/** * Browser-side AES Utility (Web Crypto API) */ type IV_MODE = "random" | "fixed"; // "random" or "fixed" const AES_CBC = "AES-CBC"; const AES_GCM = "AES-GCM"; // Key String을 CryptoKey 객체로 변환 const deriveCbcKey = async (keyStr: string): Promise<CryptoKey> => { const encoder = new TextEncoder(); const keyData = encoder.encode(keyStr); return crypto.subtle.importKey("raw", keyData, AES_CBC, false, ["encrypt", "decrypt"]); }; export const AesCbcUtils = { async encrypt(text: string, keyStr: string, ivMode: IV_MODE, ivStr?: string) { const encoder = new TextEncoder(); const key = await deriveCbcKey(keyStr); let iv: Uint8Array; if (ivMode === "fixed") { iv = new Uint8Array(16); iv.set(ivStr ? encoder.encode(ivStr) : encoder.encode(keyStr.substring(0, 16))); } else { iv = crypto.getRandomValues(new Uint8Array(16)); } const ciphertext = await crypto.subtle.encrypt( { name: AES_CBC, iv: iv as BufferSource }, key, encoder.encode(text), ); // 랜덤 모드일 때는 IV를 앞에 붙여서 반환, 고정 모드일 때는 Ciphertext만 반환 (또는 약속된 프로토콜에 따라) if (ivMode === "random") { const combined = new Uint8Array(iv.length + ciphertext.byteLength); combined.set(iv); combined.set(new Uint8Array(ciphertext), iv.length); return btoa(String.fromCharCode(...combined)); } else { return btoa(String.fromCharCode(...new Uint8Array(ciphertext))); } }, async decrypt(base64: string, keyStr: string, ivMode: IV_MODE, ivStr?: string) { try { const key = await deriveCbcKey(keyStr); const data = new Uint8Array( atob(base64) .split("") .map((c) => c.charCodeAt(0)), ); let iv: Uint8Array; let ciphertext: Uint8Array; if (ivMode === "fixed") { const encoder = new TextEncoder(); iv = new Uint8Array(16); iv.set(ivStr ? encoder.encode(ivStr) : encoder.encode(keyStr.substring(0, 16))); iv.set(encoder.encode(keyStr).slice(0, 16)); ciphertext = data; } else { iv = data.slice(0, 16); ciphertext = data.slice(16); } const decryptedBuffer = await crypto.subtle.decrypt( { name: AES_CBC, iv: iv as BufferSource }, key, ciphertext as BufferSource, ); return new TextDecoder().decode(decryptedBuffer); } catch (e) { throw new Error("복호화 실패: IV 방식이나 키가 일치하지 않습니다."); } }, }; // Key String을 CryptoKey 객체로 변환 const deriveGcmKey = async (keyStr: string): Promise<CryptoKey> => { const encoder = new TextEncoder(); const keyData = encoder.encode(keyStr); const hashBuffer = await crypto.subtle.digest("SHA-256", keyData); return crypto.subtle.importKey("raw", hashBuffer, AES_GCM, false, ["encrypt", "decrypt"]); }; export const AesGcmUtils = { async encrypt(text: string, keyStr: string) { const key = await deriveGcmKey(keyStr); const iv = crypto.getRandomValues(new Uint8Array(12)); // GCM 권장 IV 길이는 12바이트 const encoder = new TextEncoder(); const encodedText = encoder.encode(text); const ciphertext = await crypto.subtle.encrypt({ name: AES_GCM, iv }, key, encodedText); // IV(12byte) + Ciphertext를 합쳐서 Base64로 반환 const combined = new Uint8Array(iv.length + ciphertext.byteLength); combined.set(iv); combined.set(new Uint8Array(ciphertext), iv.length); return btoa(String.fromCharCode(...combined)); }, async decrypt(combinedBase64: string, keyStr: string) { try { const key = await deriveGcmKey(keyStr); const combined = new Uint8Array( atob(combinedBase64) .split("") .map((c) => c.charCodeAt(0)), ); const iv = combined.slice(0, 12); const ciphertext = combined.slice(12); const decryptedBuffer = await crypto.subtle.decrypt({ name: AES_GCM, iv }, key, ciphertext); return new TextDecoder().decode(decryptedBuffer); } catch (e) { throw new Error("복호화 실패: IV 방식이나 키가 일치하지 않습니다."); } }, };
RSA
소인수분해를 이용하여 생성한 공용키와 개인키로 암복호하를 하는 비대칭 암복호화 방식
공용키로 암호화. 개인키로 복호화.
예시 코드
- 키 생성 (openssl 이용)
bash# 개인키 생성 (PEM 포맷) openssl genrsa -out private_key.pem 2048 # 키 PKCS#8로 변환 openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private_key.pem -out private_key_pkcs8.pem # 공개키 추출 openssl rsa -in private_key.pem -pubout -out public_key.pem
- Java
javaimport javax.crypto.Cipher; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; import java.util.HashMap; import java.util.Map; /** * AI SaaS 보안을 위한 RSA 비대칭키 암복호화 유틸리티 */ public class RsaUtils { private static final String RSA_ALGO = "RSA"; private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding"; private static final int KEY_SIZE = 2048; // 최소 2048비트 이상 권장 // RSA 키 쌍(Public Key, Private Key) 생성 public static Map<String, String> generateKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGO); keyPairGenerator.initialize(KEY_SIZE); KeyPair keyPair = keyPairGenerator.genKeyPair(); String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); String privateKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); Map<String, String> keyMap = new HashMap<>(); keyMap.put("publicKey", publicKey); keyMap.put("privateKey", privateKey); return keyMap; } // 공개키(Public Key)로 암호화 public static String encrypt(String plainText, String publicKeyStr) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr); X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGO); PublicKey publicKey = keyFactory.generatePublic(spec); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encrypted = cipher.doFinal(plainText.getBytes()); return Base64.getEncoder().encodeToString(encrypted); } // 개인키(Private Key)로 복호화 public static String decrypt(String cipherText, String privateKeyStr) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance(RSA_ALGO); PrivateKey privateKey = keyFactory.generatePrivate(spec); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] decoded = Base64.getDecoder().decode(cipherText); return new String(cipher.doFinal(decoded)); } }
- Typescript (Server Side)
tsximport crypto from "crypto"; /** * Server-Side RSA Utility */ const PADDING = crypto.constants.RSA_PKCS1_PADDING; const KEY_SIZE = 2048; export const RsaUtils = { // RSA 키 쌍 생성 generateKeyPair(): { publicKey: string; privateKey: string } { return crypto.generateKeyPairSync("rsa", { modulusLength: KEY_SIZE, publicKeyEncoding: { type: "spki", // Java의 X509EncodedKeySpec과 호환 format: "pem", }, privateKeyEncoding: { type: "pkcs8", // Java의 PKCS8EncodedKeySpec과 호환 format: "pem", }, }); }, // 공개키로 암호화 encrypt(plainText: string, publicKey: string): string { const buffer = Buffer.from(plainText, "utf8"); const encrypted = crypto.publicEncrypt( { key: publicKey, padding: PADDING, }, buffer, ); return encrypted.toString("base64"); }, // 개인키로 복호화 decrypt(cipherText: string, privateKey: string): string { const buffer = Buffer.from(cipherText, "base64"); const decrypted = crypto.privateDecrypt( { key: privateKey, padding: PADDING, }, buffer, ); return decrypted.toString("utf8"); }, };
- Typescript (Client Side)
tsx/** * Browser-side RSA Utility (Web Crypto API) */ const exportPEM = (label: string, buffer: ArrayBuffer) => { const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); const lines = base64.match(/.{1,64}/g) || []; return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----`; }; const ALGO_NAME = "RSA-OAEP"; const HASH_ALGO = "SHA-256"; export const BrowserRsaUtils = { // RSA 키 쌍 생성 async generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> { const keyPair = await crypto.subtle.generateKey( { name: ALGO_NAME, modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), // 65537 hash: HASH_ALGO, }, true, // 키 추출 가능 여부 ["encrypt", "decrypt"], ); const pubKey = await crypto.subtle.exportKey("spki", keyPair.publicKey); const privKey = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); return { publicKey: exportPEM("PUBLIC KEY", pubKey), privateKey: exportPEM("PRIVATE KEY", privKey), }; }, // 공개키로 암호화 async encrypt(plainText: string, publicKey: string): Promise<string> { const binaryKey = atob(publicKey.replace(/-----BEGIN.*?-----|-----END.*?-----|\n/g, "")); const keyBuffer = new Uint8Array(binaryKey.length).map((_, i) => binaryKey.charCodeAt(i)); const key = await crypto.subtle.importKey( "spki", keyBuffer, { name: ALGO_NAME, hash: HASH_ALGO }, false, ["encrypt"], ); const encrypted = await crypto.subtle.encrypt( { name: ALGO_NAME }, key, new TextEncoder().encode(plainText), ); return btoa(String.fromCharCode(...new Uint8Array(encrypted))); }, // 개인키로 복호화 async decrypt(cipherText: string, privateKey: string): Promise<string> { const binaryKey = atob(privateKey.replace(/-----BEGIN.*?-----|-----END.*?-----|\n/g, "")); const keyBuffer = new Uint8Array(binaryKey.length).map((_, i) => binaryKey.charCodeAt(i)); const key = await crypto.subtle.importKey( "pkcs8", keyBuffer, { name: ALGO_NAME, hash: HASH_ALGO }, false, ["decrypt"], ); const data = new Uint8Array( atob(cipherText) .split("") .map((c) => c.charCodeAt(0)), ); const decrypted = await crypto.subtle.decrypt({ name: ALGO_NAME }, key, data); return new TextDecoder().decode(decrypted); }, };