헬스케어 앱을 위한 클라이언트 측 암호화

발행: (2025년 12월 7일 오후 05:11 GMT+9)
9 min read
원문: Dev.to

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));
}

저장 고려 사항

옵션단점
localStorageXSS에 취약
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)를 사용하며, 모든 작업을 로컬에 기록함으로써, 소환장, 침해, 그리고 환자의 개인 이야기를 무기로 삼으려는 어떤 당사자에게도 건강 데이터를 보호할 수 있습니다. 이 아키텍처는 데이터가 자신에게 불리하게 사용될 수 있는 사람들—장애 청구자, 만성 통증 환자, 그리고 이미 시스템에 의해 불신당한 모든 사람—을 위해 설계되었습니다.

Back to Blog

관련 글

더 보기 »