Skip to content
Go back

Web3 신뢰 인프라 #4 — 보안, PK는 어디에 있어야 하는가

지갑을 만들었으니 보안을 잡아야 했다. 핵심 질문은 하나다 — 프라이빗 키를 어디에 보관하고, 어떻게 노출을 최소화할 것인가. “완벽한 보안”은 없다. 가능한 것과 구조적으로 불가능한 것의 경계를 명확히 아는 게 목표였다.

위협 모델

먼저 어떤 공격을 막을 것인가를 정리했다.

위협공격 시나리오대응
저장소 탈취XSS, 악성 Extension이 localStorage 접근chrome.storage.local 격리
암호문 brute-force암호화된 니모닉 파일 획득 → 비밀번호 대입PBKDF2 600,000회
잠금 화면 brute-forceUI에서 비밀번호 무한 대입5회 실패 → 잠금 + 지수 백오프
물리적 접근잠금 해제 상태로 자리 비움매번 잠금으로 시작
UI 메모리 스니핑React DevTools로 state 열람Background Service Worker 격리
메모리 덤프프로세스 메모리에서 PK 추출Uint8Array .fill(0)

Background Service Worker — PK를 UI에서 완전히 제거

이전까지는 UI(React 컴포넌트)에서 비밀번호 입력 → 니모닉 복호화 → PK 파생 → 서명까지 전부 처리했다. React DevTools의 Components 탭에서 state를 열면 복호화된 PK가 보인다.

Before:
  UI → decryptKey(password) → exportPrivateKey() → sendETH()
  → PK가 React state에 존재 → DevTools에서 보임

After:
  UI → sendMessage({ type: 'SIGN_AND_SEND_ETH', password, ... })
  → Background에서 전부 처리 → txHash만 반환
  → PK가 UI 프로세스에 한 번도 안 올라옴

메시지 타입 설계

8개 메시지 타입을 정의했다.

메시지UI → BackgroundBackground → UI
VALIDATE_PASSWORDpasswordsuccess/fail + lockout 정보
SIGN_AND_SEND_ETHpassword, to, valuetxHash
SIGN_AND_SEND_TOKENpassword, tokenAddr, to, amounttxHash
SIGN_AND_ANCHORpassword, hashtxHash, blockNumber
DERIVE_ACCOUNTpasswordaddress, index
EXPORT_PKpassword, indexprivateKey (30초 클리어)
REVEAL_MNEMONICpasswordmnemonic (30초 클리어)
IMPORT_ACCOUNTpassword, privateKeyaddress

Background에서 chrome.storage.local의 암호화된 니모닉을 직접 읽고, 복호화 → PK 파생 → 서명 → 결과 반환까지 전부 처리한다. UI에는 결과만 간다.

검증

실제로 chrome.storage.local에 저장된 데이터를 Service Worker DevTools에서 확인했다.

{
  "accounts": [{ "address": "0xf1e003...", "index": 0 }],
  "encryptedMnemonic": "9fe272e2fd40...(암호문)...",
  "encryptedKeys": {}
}

평문 PK나 니모닉이 어디에도 없다.

Brute-force 방어 — chrome.storage 영속화

첫 번째 시도 — React useState

const [attempts, setAttempts] = useState(0);
const [lockoutEnd, setLockoutEnd] = useState(0);

문제: 사이드패널 닫았다 열면 state 리셋 → 카운터 초기화 → 무한 시도 가능.

두 번째 시도 — Background chrome.storage

attempts와 lockoutEnd를 Background에서 chrome.storage.local에 저장하도록 변경했다.

// Background
const LOCKOUT_STORAGE_KEY = 'brute-force-lockout';

async function getLockout(): Promise<LockoutInfo> {
  return new Promise(resolve => {
    chrome.storage.local.get(LOCKOUT_STORAGE_KEY, result => {
      resolve(result[LOCKOUT_STORAGE_KEY] ?? { attempts: 0, lockoutEnd: 0 });
    });
  });
}

// VALIDATE_PASSWORD 핸들러
case 'VALIDATE_PASSWORD': {
  const lockout = await getLockout();
  if (Date.now() < lockout.lockoutEnd) {
    throw new LockoutError('잠금 상태', lockout);
  }
  try {
    await decryptMnemonic(request.password);
    await setLockout({ attempts: 0, lockoutEnd: 0 }); // 성공 시 초기화
    return { valid: true };
  } catch {
    const newAttempts = lockout.attempts + 1;
    // 5회 이상이면 매번 lockout
    if (newAttempts >= MAX_ATTEMPTS) {
      const multiplier = Math.floor((newAttempts - MAX_ATTEMPTS) / MAX_ATTEMPTS);
      const lockoutMs = 30 * Math.pow(2, multiplier) * 1000;
      lockoutEnd = Date.now() + lockoutMs;
    }
    await setLockout({ attempts: newAttempts, lockoutEnd });
    throw new LockoutError('비밀번호 틀림', newLockout);
  }
}

패널 마운트 시 GET_LOCKOUT 메시지로 현재 lockout 상태를 조회하고, lockout 중이면 즉시 카운트다운을 표시한다.

5회 실패 → 30초 대기
10회 실패 → 60초 대기
15회 실패 → 120초 대기 (지수 백오프)
패널 껐다 켜도 → 카운터 유지
성공하면 → 0으로 초기화

트러블 슈팅 — lockout 중간 실패 누락

5회 배수(newAttempts % MAX_ATTEMPTS === 0)일 때만 lockout을 걸었더니 6, 7회 실패에서 lockout이 안 걸리고 "비밀번호가 올바르지 않습니다. (7/5)" 같은 이상한 메시지가 떴다. 조건을 newAttempts >= MAX_ATTEMPTS로 변경해서 5회 이상이면 매 실패마다 lockout이 걸리도록 수정했다.

메모리 제로화

할 수 있는 것

decryptKey()가 반환하는 Uint8Array는 사용 후 .fill(0)으로 즉시 덮어쓸 수 있다. 3곳에 적용했다.

// decryptMnemonic — 복호화 바이트 제로화
const bytes = await decryptKey(encryptedMnemonic, password);
const mnemonic = new TextDecoder().decode(bytes);
bytes.fill(0); // ✅ 바이트 배열은 제로화 가능

// getPrivateKey (imported) — PK 바이트 제로화
const pkBytes = await decryptKey(encryptedPk, password);
const hex = '0x' + Array.from(pkBytes).map(b => ...).join('');
pkBytes.fill(0); // ✅

// IMPORT_ACCOUNT — 암호화 후 제로화
const encryptedPk = await encryptKey(pkBytes, password);
pkBytes.fill(0); // ✅

할 수 없는 것

PK hex string(0x4c08...)은 JS string이다. JS string은 immutable1이라 .fill(0) 같은 게 없다. pk = ''을 해봐야 원본 문자열 객체는 GC가 수거할 때까지 메모리에 남는다.

Uint8Array: .fill(0)으로 즉시 제로화 가능 ✅
JS string:  GC 의존, 제로화 불가 ❌

viem의 privateKeyToAccount()0x${string} 타입을 요구하기 때문에, hex string으로 변환하는 순간부터는 GC 의존이다.

궁극적 해결 — 하지만 PoC 범위 밖

방법효과비용
Uint8Array .fill(0)바이트 배열 제로화 (string은 잔존)코드 3줄
WebAssemblyPK가 JS 메모리에 안 올라옴viem 대체 필요
하드웨어 월렛PK가 PC에 아예 없음하드웨어 필요

WebAssembly로 서명 로직을 Rust/C로 작성하면 WASM 메모리 안에서 PK 생성 → 서명 → 제로화까지 끝낼 수 있지만, secp256k1 서명을 직접 구현해야 해서 별도 PoC 규모다.

결국 소프트웨어 지갑의 메모리 제로화는 노출 시간을 최소화하는 것이 현실적 목표다. 완벽한 해결은 하드웨어 월렛 — PK가 전용 기기 안에서만 존재하고 PC 메모리에 아예 안 올라온다.

데모 페이지의 보안 — env가 빌드에 박히는 문제

Vercel에 데모를 배포하면서 또 다른 보안 이슈를 만났다. Vite는 define으로 env 변수를 JS에 인라인한다 — 빌드 결과물에 평문으로 박힌다.

vite.config.ts의 define:
  'import.meta.env.DEPLOYER_PRIVATE_KEY': JSON.stringify(env.DEPLOYER_PRIVATE_KEY)

빌드 결과물 (dist/assets/index-xxx.js):
  const pk = "0x73e42dae3539cde..."  ← 평문으로 노출

이건 Vite뿐 아니라 모든 프론트엔드 빌더(Webpack, Next.js, CRA)가 동일하다. 프론트엔드 빌드는 서버가 아니라 브라우저에서 실행되니까, 빌드에 포함된 건 전부 노출된다.

Google Maps API 키 같은 건 왜 괜찮냐면 — 도메인 제한이 걸려있어서다. 키가 노출되어도 허용된 도메인에서만 동작한다. PK는 도메인 제한을 걸 수 없으니 프론트에 넣으면 안 된다.

결국 PK는 사용자가 직접 입력하는 방식으로 변경했다. Etherscan API 키는 무료 읽기 전용이라 빌드에 포함해도 실제 피해가 없다.

보안 체크리스트

#항목방어 대상상태
1AES-256-GCM + PBKDF2 600,000회저장소 탈취
2chrome.storage.localXSS, 악성 Extension
3매번 잠금으로 시작물리적 접근
4Background PK 격리DevTools 스니핑
5Brute-force + 지수 백오프비밀번호 대입
630초 자동 클리어화면 노출
7Uint8Array .fill(0)메모리 덤프✅ (부분)

회고

프론트엔드에서 보안을 다루면 “막을 수 있는 것”과 “구조적으로 막을 수 없는 것”의 경계를 명확히 알아야 한다. 완벽한 보안은 없지만, 노출 시간과 면적을 최소화하는 게 현실적인 목표다.

세 가지로 정리하면.

  1. Background Service Worker는 프론트엔드 보안의 현실적 최선 — UI에 PK가 안 올라오니까 DevTools 공격을 차단한다. 하지만 Background 프로세스 메모리 자체는 여전히 덤프 가능하다.
  2. Brute-force 카운터는 서버(또는 storage)에 저장해야 한다 — 클라이언트 state에 넣으면 앱 재시작으로 리셋된다. chrome.storage는 Extension 수준의 “서버”로 볼 수 있다.
  3. 프론트엔드 env ≠ 서버 env — Vite define은 빌드 시 인라인된다. “env에 넣었으니까 안전하겠지”는 프론트엔드에서 성립하지 않는다. 민감한 키는 백엔드 또는 사용자 입력으로 처리해야 한다.

시리즈 전체: #1 지갑 SDK · #2 앵커링 · #3 DID/VC · #4 보안

Footnotes

  1. JS string immutable — JavaScript 문자열은 생성 후 변경할 수 없다. str[0] = 'x'를 해도 원본은 바뀌지 않고 새 문자열이 생성된다. 메모리에 있는 원본 문자열을 덮어쓸 방법이 없다.


Share this post on:

Comments


Previous Post
Web3 신뢰 인프라 #5 — IPFS, 블록체인 바깥의 데이터는 어디에 두나
Next Post
Web3 신뢰 인프라 #3 — DID/VC/VP, 탈중앙 신원 증명을 만들어보다