ECIES 암호화 적용 기록
| 작성일 | 2025년 08월 20일 |
|---|---|
| 수정일 | 2025년 12월 30일 |
| 카테고리 | 기술 |
| 태그 | |
| 원본 | https://croot.notion.site/2556063e6590801093d2d0a0d66cebf2 |
Prologue
그리스 내 교통시스템 프로젝트를 진행하면서 AMKA, Passport 등 민감한 개인정보를 입력받아 전송해야 하는 기능이 필요
초기에는 RSA와 ECC 중 고민했으나, 보다 가벼운 ECC를 사용하기로 결정함.
보안 요구사항 (하이브리드 암호화)
- 서버에서 비대칭 키쌍(공개키/개인키) 생성
- 공개키를 API로 클라이언트에 전달
- 클라이언트는 대칭키(AES 등)를 생성해 폼 데이터 암호화
- 대칭키를 공개키로 암호화
- 암호화된 데이터 + 암호화된 대칭키를 서버로 전송
- 서버는 개인키로 대칭키 복호화, 이후 데이터 복호화 및 처리
👉 ECC 기반 암호화 방식 중 ECIES(Integrated Encryption Scheme) 를 이용하기로 결정.
처음에는 ECIES 자체가 하이브리드 암호화 방식인지 몰랐음… 의사소통을 원활히 하기 위해 기본적인 정의를 우선 학습함.
ECC 기반 암호화 유형
- ECDSA (Elliptic Curve Digital Signature Algorithm)
- 설명: 디지털 서명 생성 및 검증에 사용 → 데이터 무결성과 인증 보장
- 특징: RSA보다 짧은 키 길이로 동일 보안 수준 제공
- 용도: 블록체인(비트코인, 이더리움), 소프트웨어 서명, 인증서
- ECDH (Elliptic Curve Diffie-Hellman)
- 설명: 두 당사자 간 안전한 키 교환 프로토콜
- 특징: 비밀키를 직접 교환하지 않고 안전하게 공유 가능
- 용도: TLS/SSL, VPN, 암호화 통신
- ECIES (Elliptic Curve Integrated Encryption Scheme)
- 설명: 공개키 암호화 + 대칭키 암호화를 결합한 하이브리드 방식
- 특징: 공개키로 대칭키 암호화 + 데이터는 대칭키로 암호화
- 용도: 데이터 암호화, 안전한 메시지 전송
구현 과정
라이브러리 적용
- Server (Java) → BouncyCastle 라이브러리 사용
- Client (Vue) → eciesjs 라이브러리 사용
라이브러리를 이용하여 빠르게 해결하도록 시도.
하지만… 라이브러리마다 계산 방식, KDF, MAC 처리가 달라 호환되지 않는 문제 발생!
상세 옵션을 이용해볼까 싶었지만 라이브러리 BouncyCastle 의 암호화 방식이 너무 복잡하여 직접 암복호화 로직을 작성하기로 함.
암호화 알고리즘 분석
- Server (Java) → Cipher 이용
- Client (Vue) → Web Crypto API 이요
ECIES 구성
- 키 합의 (ECDH)
- 송신자와 수신자의 키를 이용해 공유 비밀키(Shared Secret) 생성
- 키 도출 함수 (KDF)
- 공유 비밀키를 기반으로
EncKey(대칭키 암호화용)-
MacKey(무결성 검증용)을 생성
- 공유 비밀키를 기반으로
- 대칭키 암호화 (AES 등)
EncKey로 실제 평문 데이터를 암호화 → Ciphertext
- 무결성 검증 (MAC)
MacKey를 이용해 Ciphertext에 대한 HMAC 생성- 데이터 위·변조 여부 확인
ECIES 흐름
image.png
- 송신자
- 수신자의 공개키로 ECDH 수행 → Shared Secret
- Shared Secret을 KDF로 처리 →
EncKey,MacKey EncKey로 메시지 대칭 암호화 (AES)MacKey로 HMAC 생성- 최종적으로:
Ciphertext + HMAC + 송신자 공개키전송
- 수신자
- 송신자의 공개키 + 자신의 개인키로 ECDH 수행 → 동일한 Shared Secret
- 동일한 KDF 수행 →
EncKey,MacKey - HMAC 검증 → 메시지 무결성 확인
EncKey로 암호문 복호화
구현
nuxt + typescript 컴포저블 생성
1. base64 형태의 server public key string, form object, 암호화 대상 field명 배열을 입력받음
1. form field는 AES/CBC/PKCS5Padding 암호화 알고리즘 이용하여 암호화
1. AES Key와 iv는 EC/secp256r1, ECIES 암호화 알고리즘 이용하여 만들어진 server public key를 이용하여 암호화
1. java BouncyCastleProvider 과 호환 되어야할 것
1. 출력은 암호화된 form object, 암호화 된 aes key base64, 암호화 된 iv base64가 나와야 함
1. salt, info, wrap 등 필수적이지 않은 로직은 모두 제거
최종 결정 암호화 방식
ECIES
- Curve :
secp256r1 - Point Encoding:
? (Uncompressed | Compressed | Hybrid) - KDF:
KDF2 with SHA-256 - 대칭암호화:
AES-256-CBC
AES
- Mode:
AES-256-CBC - IV:
Random - Padding:
PKCS5Padding(PKCS7Padding 호환) - MAC:
HMAC-SHA256
Design
image.png
소스코드
Server
// Empty
Client
// Empty
리팩토링
기존에는 Form data 암호화와 ECIES 내부 암호화 방식이 상이하게 개발되었음.
Form data 암호화에 사용되는 대칭 암호화를 ECIES 내부에서도 동일하게 사용하게끔 개선
Server
@RequiredArgsConstructor
@Component
@Slf4j
public class EciesUtil {
private static final String KEY_ALGORITHM = "ECDH"; // KeyAgreement algorithm
private static final String KEY_FACTORY_ALG = "EC"; // KeyFactory algorithm (important!)
private static final String CURVE_NAME = "secp256r1";
private static final String HKDF_HASH = "HmacSHA256";
private static final int AES_KEY_LEN_BYTES = 32; // 256-bit
private static final SecureRandom secureRandom = new SecureRandom();
private final AesUtil aesUtil; // AES 암복호화 유틸 (추정)
public KeyPair generateECKeyPair() throws GeneralSecurityException {
KeyPairGenerator kpg = KeyPairGenerator.getInstance(KEY_FACTORY_ALG);
ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec(CURVE_NAME);
kpg.initialize(ecGenParameterSpec, secureRandom);
return kpg.generateKeyPair();
}
/**
* Sender: recipientPublicKey로부터 ephemeral 키 생성 후 shared secret -> HKDF -> aesKey 생성
* 반환값에 ephemeralPublicKey, aesKey, iv 포함 (iv는 랜덤 생성)
*/
public EciesSecret generateSecret(PublicKey recipientPublicKey) throws GeneralSecurityException {
// 1) ephemeral keypair 생성
KeyPair ephemeral = generateECKeyPair();
// 2) shared secret (ephemeralPriv, recipientPub)
byte[] sharedSecret = deriveSharedSecret(ephemeral.getPrivate(), recipientPublicKey);
// 3) HKDF로 AES 키 파생 (AES-256)
byte[] aesKeyBytes = hkdfExpand(sharedSecret, null, "ECIES-AES-256".getBytes(), AES_KEY_LEN_BYTES);
SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES");
// 4) IV 생성 (전송 필요)
byte[] ivBytes = new byte[16];
secureRandom.nextBytes(ivBytes);
IvParameterSpec iv = new IvParameterSpec(ivBytes);
return EciesSecret.builder()
.ephemeralPublicKey(ephemeral.getPublic())
.aesKey(aesKey)
.iv(iv)
.build();
}
/**
* Receiver: base64로 넘어온 ephemeralPublicKey와 자신의 privateKey로 shared secret 생성 후 동일한 HKDF로 aesKey 도출
*/
public EciesSecret generateSecret(PrivateKey recipientPrivateKey, String base64EphemeralPublicKey, String base64Iv)
throws GeneralSecurityException {
byte[] ephemeralBytes = Base64.getDecoder().decode(base64EphemeralPublicKey);
KeyFactory kf = KeyFactory.getInstance(KEY_FACTORY_ALG);
PublicKey ephemeralPub = kf.generatePublic(new X509EncodedKeySpec(ephemeralBytes));
byte[] ivBytes = Base64.getDecoder().decode(base64Iv);
IvParameterSpec iv = new IvParameterSpec(ivBytes);
byte[] sharedSecret = deriveSharedSecret(recipientPrivateKey, ephemeralPub);
byte[] aesKeyBytes = hkdfExpand(sharedSecret, null, "ECIES-AES-256".getBytes(), AES_KEY_LEN_BYTES);
SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES");
return EciesSecret.builder()
.ephemeralPublicKey(ephemeralPub)
.aesKey(aesKey)
.iv(iv)
.build();
}
private byte[] deriveSharedSecret(PrivateKey priv, PublicKey pub) throws GeneralSecurityException {
KeyAgreement keyAgreement = KeyAgreement.getInstance(KEY_ALGORITHM);
keyAgreement.init(priv);
keyAgreement.doPhase(pub, true);
return keyAgreement.generateSecret();
}
// HKDF (RFC 5869) - simple extract/expand using HmacSHA256
private byte[] hkdfExpand(byte[] prkOrIkm, byte[] salt, byte[] info, int len) throws GeneralSecurityException {
// For simplicity: do HKDF-Extract then HKDF-Expand
byte[] prk = hkdfExtract(salt, prkOrIkm);
return hkdfExpandFromPrk(prk, info, len);
}
private byte[] hkdfExtract(byte[] salt, byte[] ikm) throws GeneralSecurityException {
Mac mac = Mac.getInstance(HKDF_HASH);
if (salt == null) {
salt = new byte[mac.getMacLength()];
}
SecretKeySpec keySpec = new SecretKeySpec(salt, HKDF_HASH);
mac.init(keySpec);
return mac.doFinal(ikm);
}
private byte[] hkdfExpandFromPrk(byte[] prk, byte[] info, int length) throws GeneralSecurityException {
Mac mac = Mac.getInstance(HKDF_HASH);
SecretKeySpec keySpec = new SecretKeySpec(prk, HKDF_HASH);
mac.init(keySpec);
int hashLen = mac.getMacLength();
int n = (int) Math.ceil((double) length / hashLen);
if (n > 255) throw new GeneralSecurityException("Cannot expand to more than 255 * HashLen bytes with HKDF");
byte[] result = new byte[length];
byte[] t = new byte[0];
int copied = 0;
for (int i = 1; i <= n; i++) {
mac.update(t);
if (info != null) mac.update(info);
mac.update((byte) i);
t = mac.doFinal();
int toCopy = Math.min(t.length, length - copied);
System.arraycopy(t, 0, result, copied, toCopy);
copied += toCopy;
}
return result;
}
// AES 암복호 호출은 기존의 aesUtil 사용
public String encrypt(String data, EciesSecret secret) throws Exception {
return aesUtil.encrypt(data, secret.getAesKey(), secret.getIv());
}
public String decrypt(String encryptedData, EciesSecret secret) throws Exception {
return aesUtil.decrypt(encryptedData, secret.getAesKey(), secret.getIv());
}
// PostConstruct는 데모용으로만 사용; 운영 환경에선 제거 권장
@PostConstruct
void demo() {
try {
KeyPair kp = generateECKeyPair();
log.info("publicKey (base64) = {}", Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()));
} catch (Exception e) {
log.error("EciesUtil demo error", e);
}
}
}
Client
/** ECIES 암호화 비밀 키 자료구조 */
export interface EciesSecret {
/** 임시 공개키 */
ephemeralPublicKey: CryptoKey;
/** 파생된 AES 대칭키 */
aesKey: CryptoKey;
/** AES-CBC 암호화를 위한 초기화 벡터 (IV) */
iv: Uint8Array;
}
export type Base64String = string;
/**
* 서버 공개키와 Form 데이터를 받아서 선택 필드를 ECIES 암호화
*
* @template Form Form 객체 타입
* @param serverPublicKeyBase64 Base64 인코딩된 서버 공개키 (SPKI)
* @param formData 암호화할 Form 데이터
* @param fieldsToEncrypt 암호화할 필드 배열
* @returns 암호화 결과 객체
*/
export async function encryptFormData<Form>(
serverPublicKeyBase64: string,
formData: Form,
fieldsToEncrypt: string[],
): Promise<{
iv: Base64String;
ephemeralPubKey: Base64String;
encryptedFormData: Form;
}> {
// 서버 공개키 import
const serverPubKey = await crypto.subtle.importKey(
"spki",
Uint8Array.from(atob(serverPublicKeyBase64), c => c.charCodeAt(0)).buffer,
{ name: "ECDH", namedCurve: "P-256" },
false,
[],
);
// ECIES 비밀 키 생성
const secret = await generateSecret(serverPubKey);
const ephemeralPubKey = await crypto.subtle.exportKey("spki", secret.ephemeralPublicKey);
// 필드별 암호화
const encryptedFormData = { ...formData };
await Promise.all(
Object.entries(formData).map(async ([key, value]) => {
if (fieldsToEncrypt.includes(key) && typeof value === "string") {
encryptedFormData[key] = await encryptField(value, secret);
}
}),
);
return {
iv: arrayBufferToBase64(secret.iv.buffer as ArrayBuffer),
ephemeralPubKey: arrayBufferToBase64(ephemeralPubKey),
encryptedFormData,
};
}
/** ArrayBuffer → Base64 변환 */
function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = "";
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/** PKCS#7 패딩 적용 */
function pkcs7Pad(data: Uint8Array, blockSize: number = 16): Uint8Array {
const padLength = blockSize - (data.length % blockSize);
const padded = new Uint8Array(data.length + padLength);
padded.set(data);
for (let i = data.length; i < padded.length; i++) {
padded[i] = padLength;
}
return padded;
}
/** 서버 공개키로 ECIES 비밀 키 생성 */
async function generateSecret(pubKey: CryptoKey): Promise<EciesSecret> {
const ephemeralKeyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]);
const sharedSecret = await crypto.subtle.deriveKey(
{ name: "ECDH", public: pubKey },
ephemeralKeyPair.privateKey,
{ name: "AES-CBC", length: 256 },
true,
["encrypt"],
);
const iv = crypto.getRandomValues(new Uint8Array(16));
return {
ephemeralPublicKey: ephemeralKeyPair.publicKey,
aesKey: sharedSecret,
iv,
};
}
/** 문자열 → AES-CBC 암호화 (PKCS#7 포함) */
async function eciesEncrypt(source: string, secret: EciesSecret): Promise<Base64String> {
let plaintext = new TextEncoder().encode(source);
plaintext = pkcs7Pad(plaintext);
const ciphertext = await crypto.subtle.encrypt({ name: "AES-CBC", iv: secret.iv }, secret.aesKey, plaintext);
return arrayBufferToBase64(ciphertext);
}
/** 단일 필드 암호화 */
async function encryptField(value: string, secret: EciesSecret): Promise<Base64String> {
try {
return await eciesEncrypt(value, secret);
} catch (error: any) {
console.error(`Encryption failed for value: ${value}`, error);
throw new Error(`Failed to encrypt field: ${error.message}`);
}
}
Epilogue
해당 업무를 진행하며 과거에 아무것도 모르고 그냥 무작정 암기했던 악몽같은 기술들이 새록새록 기억나면서 굉장히 신선했음.
Buffer 처리나 Web Crypto API 핸들링도 좀 더 능숙해졌고, 기본적인 암호화 구조나 개념들과도 좀 더 친해진 것 같음.
피와 살이 되는 경험이였던 것 같음…
참고
구현 Design sequence diagram
« 📦 분산 환경 알고리즘 정리
Gartner 2026 »