클라이언트에서 서버로 민감 데이터를 전송할 때
HTTPS만으로는 충분하지 않은 경우가 있음.
이에 따라 RSA + AES 하이브리드 암호화 구조를
WebCrypto와 CryptoJS를 이용해 직접 구현함.
⸻
본 구현은 브라우저의 WebCrypto API(crypto.subtle)를 사용함.
WebCrypto는 HTTPS(secure context) 환경에서만 정상 동작함.
HTTP 환경에서는 동작하지 않음
내부망 환경에서도 HTTPS 설정이 필요함
⸻
서버의 공개키(Public Key)로 AES 키를 암호화함 (RSA-OAEP + SHA-256)
AES 키로 payload를 암호화함 (AES-256-ECB + PKCS7)
서버 공개키를 IndexedDB에 캐싱함
암호화 실패 시 최신 PEM 재요청 후 자동 재시도하도록 구성함
⸻
클라이언트
• 서버 공개키(PK) 조회
• AES 난수 키 생성 (32바이트)
• payload(JSON)를 AES로 암호화하여 load(Base64) 생성
• AES 키를 RSA-OAEP로 암호화하여 ek(Base64) 생성
• 서버로 { ek, load } 전송
전송 데이터 형태
{
“ek”: “RSA로 암호화된 AES 키 (Base64)”,
“load”: “AES로 암호화된 payload 본문 (Base64)”
}
⸻
서버로 전송할 민감 데이터(payload)를
하이브리드 암호화 방식으로 처리하여 { ek, load } 형태로 생성함
ek
RSA-OAEP(SHA-256)로 암호화한 AES RAW 키 (Base64)load
AES-256-ECB(PKCS7)로 암호화한 payload 본문 (Base64)
AES-ECB는 IV를 사용하지 않아 동일한 평문 블록이 동일한 암호문으로 변환됨
이로 인해 패턴 노출 위험이 존재함다만 서버 레거시 암호화 로직 및
C/C++, Java 등 타 언어 구현과의 호환성을 위해
ECB + PKCS7 방식을 사용함서버 구조 변경이 가능하다면 AES-GCM 사용을 권장함
⸻
서버에서 내려오는 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;
}
⸻
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 변환 과정을 거침
⸻
공개키는 자주 변경되지 않으므로 IndexedDB에 캐싱함으로써
불필요한 네트워크 요청을 줄이고 성능을 개선함
DB_NAME = ‘crypto-cache’
STORE_NAME = ‘server-key-store’
SERVER_KEY_ID = ‘server-public-pem’
idbGetServerPem()
idbSetServerPem(pem)
공개키는 노출돼도 보안상 문제가 없는 정보이므로
IndexedDB 저장이 가능함
⸻
function randomAesKeyBytes(length = 32) {
const key = new Uint8Array(length);
crypto.getRandomValues(key);
return key;
}
32바이트를 사용하여 AES-256 키를 생성함
crypto.getRandomValues를 사용하여 암호학적으로 안전한 난수를 생성함
⸻
동일한 AES 키를 두 가지 용도로 사용함
aesRaw (Uint8Array) : RSA 암호화용
aesKeyB64 (Base64 문자열) : CryptoJS AES 암호화용
aesRaw = randomAesKeyBytes(32)
aesKeyB64 = btoa(String.fromCharCode(…aesRaw))
CryptoJS는 Base64 기반 키를 사용하므로
RAW 바이트를 Base64 문자열로 변환하여 사용함
⸻
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();
}
⸻
function importServerPublicKeyFromPem(pem) {
const spkiBuf = pemToArrayBuffer(pem);
return crypto.subtle.importKey(
‘spki’,
spkiBuf,
{ name: ‘RSA-OAEP’, hash: ‘SHA-256’ },
false,
[‘encrypt’]
);
}
SPKI 형식의 공개키를
WebCrypto에서 사용할 수 있는 RSA 공개키 객체로 변환함
⸻
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);
}
⸻
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회 재시도하도록 구성함
⸻
result = encryptPayloadWithServerKey({
payload: { userId: ‘admin’, password: ‘1234’ }
})
console.log(result) // { ek: "...Base64...", load: "...Base64..." }
⸻
| 구분 | 내용 |
|---|---|
| 암호화 알고리즘 | RSA-OAEP (SHA-256), AES-ECB (PKCS7) |
| AES 키 길이 | 32 bytes (AES-256) |
| 인코딩 | Base64 |
| 공개키 캐싱 | IndexedDB |
| 실패 처리 | 최신 PEM 재요청 후 재시도 |
| 브라우저 제약 | WebCrypto는 HTTPS 환경에서만 동작함 |