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

대부분의 “보안” 공유 도구는 서버를 신뢰해야 합니다. 비밀번호를 붙여넣으면 서버가 이를 암호화하고 저장합니다. 하지만 서버가 요청을 로그에 남기거나 데이터베이스가 유출되면 비밀이 사라집니다.
저는 제가 (개발자) 원한다 하더라도 데이터를 읽을 수 없는 도구를 원했습니다.
그래서 저는 Nix (https://nix.jaid.dev)를 만들었습니다. 이는 오픈‑소스이며 제로‑지식 비밀 공유 앱입니다. 아래는 AES‑GCM와 URL 해시 프래그먼트를 사용하여 작동 방식을 기술적으로 설명한 내용입니다.
Source: …
아키텍처
핵심 제약은 Zero Knowledge – 서버가 복호화 키를 절대 받아서는 안 됩니다.
- Alice가 브라우저에서 무작위 키를 생성합니다.
- Alice가 클라이언트 측에서 데이터를 암호화합니다.
- Alice는 암호문만(JSON 래퍼에 감싸서) 서버(Supabase)로 전송합니다.
- 브라우저가 링크를 구성합니다:
https://nix.jaid.dev/view/[ID]#[KEY]. - 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)를 이용해 저장을 처리합니다. 클라이언트는 “읽을 때 소멸” 및 만료를 강제합니다:
- Fetch: 암호화된 레코드를 가져옵니다.
- Check Expiration:
expires_at을 현재 시간과 비교하고, 시간이 지났다면 비밀을 만료된 것으로 처리하고 삭제합니다. - Burn on Read: 메타데이터에 비밀이 “읽을 때 소멸”으로 표시되어 있으면, 성공적으로 조회한 직후 Supabase에 삭제 요청을 보냅니다.
배운 교훈
- Hydration Errors: Next.js 서버 컴포넌트에는
window가 없습니다.window.crypto는useEffect나 이벤트 핸들러 내부에서만 호출하세요. - Encoding Hell:
ArrayBuffer,Uint8Array, 문자열 간 변환이 번거롭습니다.TextEncoder와TextDecoder가 필수입니다. - Trust: 투명성은 보안 도구에 있어 중요합니다. 저는 처음부터 이 저장소를 오픈소스로 유지했는데, 그 이유는 제가 직접 폐쇄형 솔루션을 사용하지 않을 것이기 때문입니다.
사용해 보기
암호화 구현 및 사용자 경험에 대한 피드백을 찾고 있습니다.
- 실시간 데모:
- 레포: (별을 주시면 감사하겠습니다!)