Croot Blog

Home About Tech Hobby Archive

⚠️

이 블로그의 모든 포스트는 Notion 데이터베이스를 자동 변환하여 작성 되었습니다.
따라서 문서에 따라 깨져 보일 수 있습니다.
더 많은 내용이 궁금하시다면 👀 Notion 보러가기

ECIES 암호화 적용 기록

Prologue

그리스 내 교통시스템 프로젝트를 진행하면서 AMKA, Passport 등 민감한 개인정보를 입력받아 전송해야 하는 기능이 필요

초기에는 RSA와 ECC 중 고민했으나, 보다 가벼운 ECC를 사용하기로 결정함.

보안 요구사항 (하이브리드 암호화)

  1. 서버에서 비대칭 키쌍(공개키/개인키) 생성
  2. 공개키를 API로 클라이언트에 전달
  3. 클라이언트는 대칭키(AES 등)를 생성해 폼 데이터 암호화
  4. 대칭키를 공개키로 암호화
  5. 암호화된 데이터 + 암호화된 대칭키를 서버로 전송
  6. 서버는 개인키로 대칭키 복호화, 이후 데이터 복호화 및 처리

👉 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)
    • 설명: 공개키 암호화 + 대칭키 암호화를 결합한 하이브리드 방식
    • 특징: 공개키로 대칭키 암호화 + 데이터는 대칭키로 암호화
    • 용도: 데이터 암호화, 안전한 메시지 전송

구현 과정

라이브러리 적용

라이브러리를 이용하여 빠르게 해결하도록 시도.

하지만… 라이브러리마다 계산 방식, 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 흐름

0image.png

  1. 송신자
    • 수신자의 공개키로 ECDH 수행 → Shared Secret
    • Shared Secret을 KDF로 처리 → EncKey, MacKey
    • EncKey로 메시지 대칭 암호화 (AES)
    • MacKey로 HMAC 생성
    • 최종적으로: Ciphertext + HMAC + 송신자 공개키 전송
  2. 수신자
    • 송신자의 공개키 + 자신의 개인키로 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

1image.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

file