하이브리드 암호화

ByeolGyu·2025년 10월 15일

하이브리드 암호화 구조 (AES + RSA)

클라이언트에서 서버로 민감 데이터를 전송할 때
HTTPS만으로는 충분하지 않은 경우가 있음.

이에 따라 RSA + AES 하이브리드 암호화 구조를
WebCrypto와 CryptoJS를 이용해 직접 구현함.

사전 주의사항

본 구현은 브라우저의 WebCrypto API(crypto.subtle)를 사용함.
WebCrypto는 HTTPS(secure context) 환경에서만 정상 동작함.

HTTP 환경에서는 동작하지 않음
내부망 환경에서도 HTTPS 설정이 필요함

1. 구현 목표

서버의 공개키(Public Key)로 AES 키를 암호화함 (RSA-OAEP + SHA-256)
AES 키로 payload를 암호화함 (AES-256-ECB + PKCS7)
서버 공개키를 IndexedDB에 캐싱함
암호화 실패 시 최신 PEM 재요청 후 자동 재시도하도록 구성함

2. 하이브리드 암호화 구조 개요

클라이언트

• 서버 공개키(PK) 조회
• AES 난수 키 생성 (32바이트)
• payload(JSON)를 AES로 암호화하여 load(Base64) 생성
• AES 키를 RSA-OAEP로 암호화하여 ek(Base64) 생성
• 서버로 { ek, load } 전송

전송 데이터 형태

{
“ek”: “RSA로 암호화된 AES 키 (Base64)”,
“load”: “AES로 암호화된 payload 본문 (Base64)”
}

3. 구성 요약

1) 목적

서버로 전송할 민감 데이터(payload)를
하이브리드 암호화 방식으로 처리하여 { ek, load } 형태로 생성함

ek
RSA-OAEP(SHA-256)로 암호화한 AES RAW 키 (Base64)

load
AES-256-ECB(PKCS7)로 암호화한 payload 본문 (Base64)

2) 암호화 단계 요약

  1. 서버 공개키(PEM, SPKI)를 조회하여 WebCrypto 공개키로 import함
  2. 난수 AES 키(32바이트 = AES-256)를 생성함
  3. payload(JSON 문자열)를 AES-ECB(PKCS7)로 암호화하여 load를 생성함
  4. AES RAW 키를 RSA-OAEP(SHA-256)로 암호화하여 ek를 생성함

AES-ECB는 IV를 사용하지 않아 동일한 평문 블록이 동일한 암호문으로 변환됨
이로 인해 패턴 노출 위험이 존재함

다만 서버 레거시 암호화 로직 및
C/C++, Java 등 타 언어 구현과의 호환성을 위해
ECB + PKCS7 방식을 사용함

서버 구조 변경이 가능하다면 AES-GCM 사용을 권장함

4. 핵심 코드 설명

1) 서버 공개키 PEM 정규화

서버에서 내려오는 PEM 문자열은
\n escape, CRLF 혼합 등으로 인해 WebCrypto import에 실패할 수 있음
이를 방지하기 위해 PEM 문자열을 정규화함

function normalizePem(pem) {
  if (!pem) return ‘’;
  let s = String(pem);
  if (s.includes(’\n’)) s = s.replace(/\n/g, ‘\n’);
  s = s.replace(/\r\n/g, ‘\n’).trim();
  return s;
}

2) PEM(SPKI) → ArrayBuffer 변환

WebCrypto의 importKey(‘spki’)는
SPKI 형식의 DER 바이너리(ArrayBuffer)만 허용함

function pemToArrayBuffer(pem) {
  const normalized = normalizePem(pem);

  if (!/—–BEGIN PUBLIC KEY—–/.test(normalized)) {
  	throw new Error(‘서버 공개키 형식이 올바르지 않음 (SPKI 필요));
  }

  const b64 = normalized
  .replace(/—–BEGIN PUBLIC KEY—–/g, ‘’)
  .replace(/—–END PUBLIC KEY—–/g, ‘’)
  .replace(/\s+/g, ‘’);

  const bin = atob(b64);
  const buf = new ArrayBuffer(bin.length);
  const view = new Uint8Array(buf);

  for (let i = 0; i < bin.length; i++) {
  	view[i] = bin.charCodeAt(i);
  }

  return buf;
}

PEM은 헤더/푸터와 Base64로 인코딩된 DER 바이너리로 구성됨
헤더 제거 → Base64 디코딩 → ArrayBuffer 변환 과정을 거침

3) IndexedDB를 이용한 공개키 캐싱

공개키는 자주 변경되지 않으므로 IndexedDB에 캐싱함으로써
불필요한 네트워크 요청을 줄이고 성능을 개선함

DB_NAME = ‘crypto-cache’
STORE_NAME = ‘server-key-store’
SERVER_KEY_ID = ‘server-public-pem’

idbGetServerPem()
idbSetServerPem(pem)

공개키는 노출돼도 보안상 문제가 없는 정보이므로
IndexedDB 저장이 가능함

4) AES-256 난수 키 생성

function randomAesKeyBytes(length = 32) {
const key = new Uint8Array(length);
crypto.getRandomValues(key);
return key;
}

32바이트를 사용하여 AES-256 키를 생성함
crypto.getRandomValues를 사용하여 암호학적으로 안전한 난수를 생성함

5) AES 키 포맷 변환

동일한 AES 키를 두 가지 용도로 사용함

aesRaw (Uint8Array) : RSA 암호화용
aesKeyB64 (Base64 문자열) : CryptoJS AES 암호화용

aesRaw = randomAesKeyBytes(32)
aesKeyB64 = btoa(String.fromCharCode(…aesRaw))

CryptoJS는 Base64 기반 키를 사용하므로
RAW 바이트를 Base64 문자열로 변환하여 사용함

4-6. payload → AES-ECB(PKCS7) 암호화

function aesEncryptECB(plaintext, keyBase64) {
  const secretKey = CryptoJS.enc.Base64.parse(keyBase64);
  const encrypted = CryptoJS.AES.encrypt(
  plaintext,
  secretKey,
  { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }
  );
  return encrypted.toString();
}

7) RSA 공개키 import

function importServerPublicKeyFromPem(pem) {
  const spkiBuf = pemToArrayBuffer(pem);
  return crypto.subtle.importKey(
  ‘spki’,
  spkiBuf,
  { name:RSA-OAEP, hash:SHA-256},
  false,
  [‘encrypt’]
  );
}

SPKI 형식의 공개키를
WebCrypto에서 사용할 수 있는 RSA 공개키 객체로 변환함

8) AES 키 → RSA-OAEP 암호화

ekBuf = crypto.subtle.encrypt(
{ name:RSA-OAEP, hash:SHA-256},
serverPublicKey,
aesRaw
)

RSA 암호화 결과는 ArrayBuffer 형태이므로
API 전송을 위해 Base64 문자열로 변환함

function arrayBufferToB64(buf) {
  const bytes = new Uint8Array(buf);
  let bin = ‘’;
  for (let i = 0; i < bytes.byteLength; i++) {
  	bin += String.fromCharCode(bytes[i]);
  }
  return btoa(bin);
}

5. 전체 동작 함수

function encryptPayloadWithServerKey({ payload, forceRefresh = false }) {
  let pem = null;

  if (!forceRefresh) {
      pem = idbGetServerPem();
  }

  if (!pem) {
    const { output } = getPublicKeyApi();
    pem = normalizePem(output);
    idbSetServerPem(pem);
  }

  try {
    return buildEkLoad({ payload, serverPublicKeyPem: pem });
    } 
  	catch (e) {
      const { output } = getPublicKeyApi();
      const freshPem = normalizePem(output);
      idbSetServerPem(freshPem);
      return buildEkLoad({ payload, serverPublicKeyPem: freshPem });
    }						
}

암호화 실패는 서버 공개키 교체, 캐시된 PEM 손상,
WebCrypto import/encrypt 실패 등으로 발생할 수 있음
이에 따라 최신 PEM 재요청 후 1회 재시도하도록 구성함

6. 사용 예시

result = encryptPayloadWithServerKey({
	payload: { userId: ‘admin’, password:1234}
})

console.log(result) // { ek: "...Base64...", load: "...Base64..." }

7. 주요 포인트 정리

구분내용
암호화 알고리즘RSA-OAEP (SHA-256), AES-ECB (PKCS7)
AES 키 길이32 bytes (AES-256)
인코딩Base64
공개키 캐싱IndexedDB
실패 처리최신 PEM 재요청 후 재시도
브라우저 제약WebCrypto는 HTTPS 환경에서만 동작함
profile
ByeolGyu

0개의 댓글