Next.js와 Web Crypto API를 사용해 Zero‑Knowledge Secret Sharer를 만든 방법

발행: (2026년 1월 15일 오후 06:20 GMT+9)
6 min read
원문: Dev.to

Source: Dev.to

Cover image for How I built a Zero-Knowledge Secret Sharer using Next.js and the Web Crypto API

대부분의 “보안” 공유 도구는 서버를 신뢰해야 합니다. 비밀번호를 붙여넣으면 서버가 이를 암호화하고 저장합니다. 하지만 서버가 요청을 로그에 남기거나 데이터베이스가 유출되면 비밀이 사라집니다.

저는 제가 (개발자) 원한다 하더라도 데이터를 읽을 수 없는 도구를 원했습니다.

그래서 저는 Nix (https://nix.jaid.dev)를 만들었습니다. 이는 오픈‑소스이며 제로‑지식 비밀 공유 앱입니다. 아래는 AES‑GCMURL 해시 프래그먼트를 사용하여 작동 방식을 기술적으로 설명한 내용입니다.

Source:

아키텍처

핵심 제약은 Zero Knowledge – 서버가 복호화 키를 절대 받아서는 안 됩니다.

  1. Alice가 브라우저에서 무작위 키를 생성합니다.
  2. Alice가 클라이언트 측에서 데이터를 암호화합니다.
  3. Alice는 암호문만(JSON 래퍼에 감싸서) 서버(Supabase)로 전송합니다.
  4. 브라우저가 링크를 구성합니다: https://nix.jaid.dev/view/[ID]#[KEY].
  5. Bob이 해당 링크를 클릭합니다. 그의 브라우저는 ID를 요청하고, URL에서 #[KEY]를 추출한 뒤(서버에 전송된 적 없는 키) 로컬에서 데이터를 복호화합니다.

스택

  • 프론트엔드: Next.js 16 (App Router)
  • 데이터베이스: Supabase (Postgres)
  • 암호화: Native Web Crypto API (window.crypto.subtle)
  • 스타일링: Tailwind CSS

어려운 부분: Web Crypto API

Web Crypto API는 강력하지만 장황합니다. 아래는 암호화 흐름의 핵심 요소들입니다.

키 생성

암호학적으로 강력한 무작위 키가 필요합니다. SubtleCrypto API는 안전한 생성을 보장합니다.

// Generate a secure AES‑GCM key
const key = await window.crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);

// Export to raw bytes (and then to Base64) for the URL
const exported = await window.crypto.subtle.exportKey("raw", key);

암호화 (AES‑GCM)

AES‑GCM은 기밀성 및 무결성을 모두 제공합니다. 각 암호화 작업마다 고유한 IV를 생성해야 합니다.

async function encrypt(content, key) {
  const encoder = new TextEncoder();
  const encodedContent = encoder.encode(content);

  // 96‑bit IV for GCM
  const iv = window.crypto.getRandomValues(new Uint8Array(12));

  const encryptedContent = await window.crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: iv,
    },
    key,
    encodedContent
  );

  // Return serialized JSON with IV and ciphertext
  return JSON.stringify({
    iv: Array.from(iv),
    data: Array.from(new Uint8Array(encryptedContent)),
  });
}

URL 해시 해킹

브라우저가 example.com/page#secret123을 방문하면 서버는 GET /page만을 확인합니다. # 뒤의 내용은 절대 서버에 도달하지 않으므로, 복호화 키를 안전하게 전달할 수 있습니다.

// On the client (e.g., inside useEffect)
useEffect(() => {
  const hash = window.location.hash; // "#5f3a..."
  if (hash) {
    const keyString = hash.substring(1); // Remove '#'
    // Trigger decryption...
  }
}, []);

데이터베이스 및 만료 (Supabase)

서버는 암호화된 블롭만 저장하므로, Supabase와 Row‑Level Security (RLS)를 이용해 저장을 처리합니다. 클라이언트는 “읽을 때 소멸” 및 만료를 강제합니다:

  1. Fetch: 암호화된 레코드를 가져옵니다.
  2. Check Expiration: expires_at을 현재 시간과 비교하고, 시간이 지났다면 비밀을 만료된 것으로 처리하고 삭제합니다.
  3. Burn on Read: 메타데이터에 비밀이 “읽을 때 소멸”으로 표시되어 있으면, 성공적으로 조회한 직후 Supabase에 삭제 요청을 보냅니다.

배운 교훈

  • Hydration Errors: Next.js 서버 컴포넌트에는 window가 없습니다. window.cryptouseEffect나 이벤트 핸들러 내부에서만 호출하세요.
  • Encoding Hell: ArrayBuffer, Uint8Array, 문자열 간 변환이 번거롭습니다. TextEncoderTextDecoder가 필수입니다.
  • Trust: 투명성은 보안 도구에 있어 중요합니다. 저는 처음부터 이 저장소를 오픈소스로 유지했는데, 그 이유는 제가 직접 폐쇄형 솔루션을 사용하지 않을 것이기 때문입니다.

사용해 보기

암호화 구현 및 사용자 경험에 대한 피드백을 찾고 있습니다.

  • 실시간 데모:
  • 레포: (별을 주시면 감사하겠습니다!)
Back to Blog

관련 글

더 보기 »