본문으로 건너뛰기
HexaTransfer
블로그로 돌아가기
암호화 및 보안

클라이언트 사이드 암호화 튜토리얼: 처음부터 만들기

웹 앱에 클라이언트 사이드 암호화를 구현하는 단계별 튜토리얼. 사용자 기기를 떠나기 전에 브라우저에서 파일을 암호화하세요.

브라우저에서의 클라이언트 사이드 파일 암호화는 Web Crypto API를 사용한 약 80줄의 JavaScript로 구현됩니다. 패턴은 이렇습니다. 브라우저에서 AES-256-GCM 키를 생성하고, 무작위 96비트 논스로 파일을 암호화하고, HTTPS/TLS 1.3을 통해 암호문을 업로드하며, 결과 URL에 프래그먼트 식별자(#key=...)에 키를 포함하여 공유합니다. 브라우저는 이 프래그먼트를 서버로 전송하지 않습니다. 수신자는 해당 프래그먼트를 사용하여 브라우저에서 복호화합니다. 이 튜토리얼은 대형 파일을 위한 청킹, 600,000회 반복의 PBKDF2를 통한 비밀번호 파생 키, 그리고 첫 시도를 망가뜨리는 함정을 포함한 실용적 구현을 다룹니다.

아키텍처 한 눈에 보기

[발신자 브라우저]                    [서버]                   [수신자 브라우저]
  파일 읽기 → AES 키(무작위)         POST 수락                암호문 GET
  AES-256-GCM으로 암호화             암호문 블롭 저장          URL #프래그먼트에서 키 파싱
  암호문 POST                         키 없음, 평문 없음        브라우저에서 복호화
  #key=...가 있는 URL 생성            다운로드 URL 반환         파일을 디스크에 저장

서버는 단순한 블롭 저장소입니다. 암호문만 보고 복호화할 수 없습니다. 복호화 키는 URL 프래그먼트에 있으며, 브라우저는 이를 특별하게 처리합니다. HTTP 요청 행에서 전송되지 않습니다. 이것이 HexaTransfer를 포함한 모든 제로 지식 파일 전송 서비스의 기반입니다.

1단계: 대칭 키 생성

async function generateKey() {
  return await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 },
    true, // URL로 내보낼 수 있도록 추출 가능
    ["encrypt", "decrypt"]
  );
}

extractable: true 플래그는 키를 URL 프래그먼트에 직렬화해야 하기 때문에 필요합니다. 키가 메모리에만 있는 흐름(예: 붙여넣기 후 전송 도구)을 구축하는 경우 false로 설정하세요.

2단계: 파일을 ArrayBuffer로 읽기

async function readFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = () => reject(reader.error);
    reader.readAsArrayBuffer(file);
  });
}

이것은 전체 파일을 메모리에 로드합니다. 500MB 미만 파일에 적합합니다. 더 큰 파일은 스트리밍 섹션으로 건너뛰세요.

3단계: 버퍼 암호화

async function encryptFile(key, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    plaintext
  );
  // 수신자가 추출할 수 있도록 IV를 암호문 앞에 붙임
  const combined = new Uint8Array(iv.length + ciphertext.byteLength);
  combined.set(iv, 0);
  combined.set(new Uint8Array(ciphertext), iv.length);
  return combined.buffer;
}

논스(IV)는 NIST SP 800-38D에 따라 96비트(12바이트)입니다. 비밀은 아니지만 키당 고유해야 합니다. 파일당 새 키를 생성하기 때문에 무작위 논스가 안전합니다. IV를 암호문 앞에 붙이는 것은 일반적인 관례입니다. 수신자는 복호화 전에 다시 분리합니다.

4단계: 암호문 업로드

async function uploadCiphertext(ciphertext) {
  const response = await fetch("/api/upload", {
    method: "POST",
    body: ciphertext,
    headers: { "Content-Type": "application/octet-stream" },
  });
  const { fileId } = await response.json();
  return fileId;
}

서버는 바이너리 블롭을 수신하고, ID를 할당하고, 저장하고, ID를 반환합니다. 헤더에 파일 이름이 없고, 쿼리 파라미터에 키가 없습니다. 내일 서버의 하드 드라이브가 도난당해도 공격자는 의미 없는 데이터만 봅니다.

5단계: 프래그먼트에 키가 있는 공유 URL 구성

async function buildShareURL(fileId, key) {
  const rawKey = await crypto.subtle.exportKey("raw", key);
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${location.origin}/f/${fileId}#${keyBase64}`;
}

base64url 인코딩(+/ 대신 -_)은 URL 이스케이프 문제를 피합니다. = 패딩은 미관상 제거합니다.

프래그먼트(#...)는 여기서 핵심입니다. 수신자가 URL을 로드할 때 브라우저는 프래그먼트를 클라이언트 사이드에 유지합니다. /f/{fileId}에 대한 HTTP GET은 요청 행에 #keyBase64를 포함하지 않으므로 서버는 키를 알 수 없습니다. 프래그먼트가 포함된 URL에서 브라우저 개발자 도구를 열고 네트워크 탭을 확인하여 직접 검증해 보세요.

6단계: 수신자 측 복호화

async function downloadAndDecrypt() {
  const fileId = location.pathname.split("/").pop();
  const keyBase64 = location.hash.slice(1);
  const rawKey = Uint8Array.from(
    atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  const key = await crypto.subtle.importKey(
    "raw", rawKey, "AES-GCM", false, ["decrypt"]
  );
  const response = await fetch(`/api/download/${fileId}`);
  const combined = new Uint8Array(await response.arrayBuffer());
  const iv = combined.slice(0, 12);
  const ciphertext = combined.slice(12);
  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, ciphertext
  );
  const blob = new Blob([plaintext]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "downloaded-file";
  a.click();
}

GCM의 인증 태그는 decrypt() 중에 검사됩니다. 암호문이 변조되면 호출이 OperationError를 발생시킵니다. 깔끔한 실패 모드입니다.

PBKDF2를 통한 비밀번호 파생 키

사용자가 무작위 키 대신 비밀번호를 제공하는 경우 PBKDF2를 통해 AES 키를 파생하세요:

async function deriveKey(password, salt) {
  const passwordKey = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(password),
    "PBKDF2", false, ["deriveKey"]
  );
  return await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: 600000,
      hash: "SHA-256",
    },
    passwordKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

600,000회 반복의 PBKDF2-SHA-256은 OWASP의 2023 기준선입니다. 솔트는 16개의 무작위 바이트이어야 하며 암호문과 함께 저장해야 합니다(비밀이 아니고 고유해야 함). 새 코드에서는 GPU 공격에 훨씬 더 잘 저항하는 argon2-browser 같은 라이브러리를 통한 Argon2id를 고려하세요.

대형 파일 스트리밍

500MB 이상 파일은 청킹해야 합니다. File.stream()으로 읽고, 각 청크를 암호화하고, 순차적으로 업로드하세요:

async function encryptStream(file, key) {
  const reader = file.stream().getReader();
  const chunks = [];
  let chunkIndex = 0;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const iv = new Uint8Array(12);
    // 청크 인덱스를 논스에 인코딩하여 고유성 보장
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, value
    );
    chunks.push({ iv, ct });
  }
  return chunks;
}

청크 인덱스에서 논스를 파생하면 상태 추적 없이 고유성이 보장됩니다. 수신자 측에서 재조립은 청크를 순서대로 복호화하고 연결합니다.

진정한 스트리밍 AEAD를 위해서는 libsodium의 crypto_secretstream_xchacha20poly1305(libsodium.js를 통해)가 더 깔끔하고 잘림 공격을 감지합니다. Web Crypto에는 2026년에 동등한 프리미티브가 없습니다.

테스트와 함정

피해야 할 흔한 실수들:

  • 키나 논스에 Math.random() 사용: 항상 crypto.getRandomValues()를 사용하세요.
  • 같은 키로 논스 재사용: GCM 보안을 깨뜨립니다. 파일당 무작위 키가 이를 안전하게 만들지만, 청크 흐름은 청크당 고유한 논스가 필요합니다.
  • HTTPS 확인 안 함: crypto.subtle은 안전하지 않은 출처에서 undefined입니다. 개발 중 localhost나 자체 서명 인증서로 테스트하세요.
  • localStorage에 키 저장: 출처의 모든 XSS가 읽을 수 있습니다. 대신 URL 프래그먼트 패턴이나 비추출 가능 키를 사용하세요.
  • 암호문에 IV 포함 잊기: 유용한 오류 없이 복호화가 실패합니다. 항상 앞에 붙이거나 함께 직렬화하세요.
  • 프래그먼트 잘못 처리: 제3자 서비스에 URL(프래그먼트 포함)을 실수로 게시하지 마세요. 프래그먼트가 민감하다면 엔드투엔드 채널을 통해서만 공유하세요.

서버 측 책임

클라이언트 사이드 암호화 아키텍처에서 서버의 역할은 작습니다. POST 수락, 블롭 저장, ID 반환, 블롭에 대한 GET 제공, 만료 시 삭제. 암호화 없음. 저장소 외에 서버가 해야 할 것:

  • 파일 크기 제한 적용 (남용 방지)
  • 업로드와 다운로드 속도 제한
  • 짧은 보존 설정 (HexaTransfer처럼 7일이 합리적인 기본값)
  • 필요한 것만 로그 (업로드 타임스탬프, 프라이버시 우선이면 IP 없음)
  • HSTS를 사용한 TLS 1.3 위에서 제공
  • API가 자체 도메인에서만 호출되는 경우 출처를 제한하는 CORS 헤더

모두 합치기

최소한의 작동 앱은 하나의 HTML 파일과 50줄짜리 Express 백엔드에 맞습니다. 전체 의존성: 클라이언트에는 없음(Web Crypto는 기본 제공), 서버에는 Express와 multer. 암호화는 AES-256-GCM 프리미티브만큼 강력합니다. 틀릴 비밀 알고리즘이 없고, 올바르게 사용해야 할 프리미티브만 있습니다.

가장 어려운 부분은 엣지 케이스입니다. 대형 파일, 비밀번호에서 키로의 흐름, 복호화가 실패할 때의 수신자 UX, 만료된 링크의 우아한 처리. 핵심 암호화는 간단합니다.

hexatransfer.com에서 사용해 보세요 — 무료, 계정 불필요, 최대 10GB.

엔드투엔드 암호화로 대용량 파일을 안전하게 전송

엔드투엔드 암호화로 최대 10GB의 파일을 무료로 전송하세요. 계정이 필요하지 않습니다. 업로드 전에 브라우저에서 파일이 암호화되어 다른 사람은 읽을 수 없습니다.

파일 보내기