헬스케어 앱을 위한 클라이언트 측 암호화
Source: Dev.to
소개
법정에서 내 건강 데이터가 증거로 사용된 적이 있습니다—실제 변호사와 실제 판사, 그리고 내 통증 플레어 일지 항목이 불안정성의 증거로 재구성되었습니다. 그 경험이 아래 모든 보안 결정의 원동력입니다: 150 000 PBKDF2 반복, AES‑256‑GCM, 그리고 절대 디바이스를 떠나지 않는 키. 이것은 튜토리얼이 아니라, 건강 데이터를 발견 절차, 양육권 분쟁, 그리고 보험 사기 조사에서 보호하는 아키텍처입니다.
위협 모델
-
전통적인 모델:
User → Server → Database.- 서버가 처리를 위해 복호화합니다.
- 데이터가 기업 인프라를 통과하고 직원이 접근 가능하며, 소환장을 통해 제공될 수 있습니다.
- 침해, 비즈니스 모델 수익화, 그리고 법적 요청이 데이터를 노출합니다.
-
제로-지식 / 로컬-퍼스트 모델:
- 서버는 암호문(또는 아무것도)만 보게 됩니다.
- 데이터가 사용자의 디바이스를 떠나지 않아 도난, 악성코드, 공유 컴퓨터, 포렌식 분석으로부터 보호됩니다.
- 하드웨어가 압수되더라도 공격자는 잡음만 얻게 됩니다.
Web Crypto API 사용
Web Crypto API는 최신 브라우저에 내장되어 있으며, 하드웨어 가속을 지원하고 외부 공급망 의존성이 없습니다.
const cryptoAPI = globalThis.crypto ?? window.crypto;
const subtle = cryptoAPI.subtle;
키 생성
// AES‑GCM encryption key (256‑bit)
async function generateEncryptionKey(): Promise {
return subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
// HMAC‑SHA‑256 key for integrity
async function generateHMACKey(): Promise {
return subtle.generateKey(
{ name: 'HMAC', hash: 'SHA-256' },
true,
['sign', 'verify']
);
}
암호화
interface EncryptedPayload {
ciphertext: string;
iv: string;
hmac: string;
algorithm: string;
version: string;
timestamp: string;
}
/**
* Encrypts arbitrary data with AES‑GCM and signs the ciphertext with HMAC.
*/
async function encrypt(
data: T,
encryptionKey: CryptoKey,
hmacKey: CryptoKey
): Promise {
const plaintext = JSON.stringify(data);
const plaintextBytes = new TextEncoder().encode(plaintext);
// Fresh IV for every encryption
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertextBuffer = await subtle.encrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
plaintextBytes
);
const hmacSignature = await subtle.sign('HMAC', hmacKey, ciphertextBuffer);
return {
ciphertext: arrayBufferToBase64(ciphertextBuffer),
iv: arrayBufferToBase64(iv.buffer),
hmac: arrayBufferToBase64(hmacSignature),
algorithm: 'AES-256-GCM',
version: '2.0.0',
timestamp: new Date().toISOString(),
};
}
왜 HMAC인가?
AES‑GCM이 인증을 제공하지만, 추가적인 HMAC 레이어는 “벨트와 서스펜더” 보호를 제공합니다. 검증이 복호화 전에 이루어져 변조된 페이로드를 빠르게 거부할 수 있습니다.
복호화
/**
* Decrypts a payload after verifying its HMAC.
*/
async function decrypt(
payload: EncryptedPayload,
encryptionKey: CryptoKey,
hmacKey: CryptoKey
): Promise {
const ciphertextBuffer = base64ToArrayBuffer(payload.ciphertext);
const iv = base64ToArrayBuffer(payload.iv);
const expectedHmac = base64ToArrayBuffer(payload.hmac);
const isValid = await subtle.verify(
'HMAC',
hmacKey,
expectedHmac,
ciphertextBuffer
);
if (!isValid) {
throw new Error('Integrity check failed');
}
const plaintextBuffer = await subtle.decrypt(
{ name: 'AES-GCM', iv },
encryptionKey,
ciphertextBuffer
);
return JSON.parse(new TextDecoder().decode(plaintextBuffer));
}
저장 고려 사항
| 옵션 | 단점 |
|---|---|
localStorage | XSS에 취약 |
sessionStorage | 탭을 닫으면 사라짐 |
IndexedDB | 여전히 XSS에 취약 |
| Password‑derived key (사용자가 매번 입력) | UX가 좋지 않음 |
extractable: false (내보낼 수 없는 키) | 백업이나 마이그레이션 불가 |
계층형 키
- 마스터 키가 각 엔트리 데이터 키를 감쌉니다.
- 비밀번호 파생 키가 백업을 감싸며, 안전한 마이그레이션을 가능하게 합니다.
/**
* Wraps a CryptoKey for storage (e.g., in IndexedDB).
*/
async function wrapKeyForStorage(
keyToWrap: CryptoKey,
wrappingKey: CryptoKey
): Promise {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await subtle.wrapKey(
'raw',
keyToWrap,
wrappingKey,
{ name: 'AES-GCM', iv }
);
return JSON.stringify({
wrapped: arrayBufferToBase64(wrapped),
iv: arrayBufferToBase64(iv.buffer),
});
}
비밀번호 파생 키
/**
* Derives an AES‑GCM key from a password using PBKDF2.
* Default: 150 000 iterations (hard‑coded for legal‑risk scenarios).
*/
async function deriveKeyFromPassword(
password: string,
salt: Uint8Array,
iterations: number = 150_000
): Promise {
const passwordBuffer = new TextEncoder().encode(password);
const baseKey = await subtle.importKey(
'raw',
passwordBuffer,
'PBKDF2',
false,
['deriveBits', 'deriveKey']
);
return subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-256',
},
baseKey,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
- Salt는 백업과 함께 저장됩니다(비밀이 아님).
- Iteration count도 저장되어, 기존 백업을 깨뜨리지 않고 향후 증가시킬 수 있습니다.
키 회전
정기적인 회전은 단순히 모범 사례가 아니라, 디바이스 분실, 손상, 혹은 법적 압력에 대한 대응입니다.
async function rotateKey(keyId: string): Promise {
const newBundle = await generateKeyBundle(); // new enc + HMAC keys
const allEntries = await loadAllEncryptedEntries(); // fetch all stored payloads
const oldKey = await getKey(keyId); // retrieve old bundle
for (const entry of allEntries) {
const plaintext = await decrypt(entry.data, oldKey.enc, oldKey.hmac);
const newCiphertext = await encrypt(plaintext, newBundle.encryptionKey, newBundle.hmacKey);
await updateEntry(entry.id, newCiphertext);
}
await archiveKey(keyId, oldKey); // keep for recovery if needed
await storeKey(keyId, newBundle);
await logSecurityEvent({
type: 'key_rotation',
keyId,
timestamp: new Date(),
});
}
감사 로그
모든 암호화 작업은 로컬에 기록되며, 서버로 전송되지 않습니다.
interface SecurityEvent {
type: string;
keyId?: string;
timestamp: Date;
[prop: string]: any;
}
/**
* Stores a bounded audit log in localStorage.
*/
function logSecurityEvent(event: SecurityEvent): void {
const auditLog = JSON.parse(localStorage.getItem('security_audit') ?? '[]');
auditLog.push({ ...event, timestamp: event.timestamp.toISOString() });
// Keep the most recent 1 000 entries
if (auditLog.length > 1000) {
auditLog.splice(0, auditLog.length - 1000);
}
localStorage.setItem('security_audit', JSON.stringify(auditLog));
}
이 로그는 백엔드와 연동하지 않고도 사용자나 감사인에게 제공할 수 있습니다.
구현 시 주의 사항
- 프라이빗 브라우징:
localStorage가 없을 수 있으니, 메모리 저장소로 대체하고 데이터가 지속되지 않음을 사용자에게 경고합니다. - 테스트: Web Crypto는 Vitest에서 동작이 다를 수 있습니다; API를 모킹하거나
@peculiar/webcrypto를 사용합니다. - Base64 처리: Node.js와 브라우저가 다릅니다. 양쪽에서 동작하는 헬퍼를 사용합니다.
function arrayBufferToBase64(buffer: ArrayBuffer): string {
if (typeof Buffer !== 'undefined') {
// Node.js environment
return Buffer.from(buffer).toString('base64');
}
// Browser environment
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToArrayBuffer(base64: string): ArrayBuffer {
if (typeof Buffer !== 'undefined') {
// Node.js
return Buffer.from(base64, 'base64').buffer;
}
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
결론
암호화를 완전히 클라이언트에서 수행하고, 강력한 기본 요소(AES‑256‑GCM, HMAC‑SHA‑256, 150 k 반복 PBKDF2)를 사용하며, 모든 작업을 로컬에 기록함으로써, 소환장, 침해, 그리고 환자의 개인 이야기를 무기로 삼으려는 어떤 당사자에게도 건강 데이터를 보호할 수 있습니다. 이 아키텍처는 데이터가 자신에게 불리하게 사용될 수 있는 사람들—장애 청구자, 만성 통증 환자, 그리고 이미 시스템에 의해 불신당한 모든 사람—을 위해 설계되었습니다.