지갑을 만들었으니 보안을 잡아야 했다. 핵심 질문은 하나다 — 프라이빗 키를 어디에 보관하고, 어떻게 노출을 최소화할 것인가. “완벽한 보안”은 없다. 가능한 것과 구조적으로 불가능한 것의 경계를 명확히 아는 게 목표였다.
위협 모델
먼저 어떤 공격을 막을 것인가를 정리했다.
| 위협 | 공격 시나리오 | 대응 |
|---|---|---|
| 저장소 탈취 | XSS, 악성 Extension이 localStorage 접근 | chrome.storage.local 격리 |
| 암호문 brute-force | 암호화된 니모닉 파일 획득 → 비밀번호 대입 | PBKDF2 600,000회 |
| 잠금 화면 brute-force | UI에서 비밀번호 무한 대입 | 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 → Background | Background → UI |
|---|---|---|
VALIDATE_PASSWORD | password | success/fail + lockout 정보 |
SIGN_AND_SEND_ETH | password, to, value | txHash |
SIGN_AND_SEND_TOKEN | password, tokenAddr, to, amount | txHash |
SIGN_AND_ANCHOR | password, hash | txHash, blockNumber |
DERIVE_ACCOUNT | password | address, index |
EXPORT_PK | password, index | privateKey (30초 클리어) |
REVEAL_MNEMONIC | password | mnemonic (30초 클리어) |
IMPORT_ACCOUNT | password, privateKey | address |
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줄 |
| WebAssembly | PK가 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 키는 무료 읽기 전용이라 빌드에 포함해도 실제 피해가 없다.
보안 체크리스트
| # | 항목 | 방어 대상 | 상태 |
|---|---|---|---|
| 1 | AES-256-GCM + PBKDF2 600,000회 | 저장소 탈취 | ✅ |
| 2 | chrome.storage.local | XSS, 악성 Extension | ✅ |
| 3 | 매번 잠금으로 시작 | 물리적 접근 | ✅ |
| 4 | Background PK 격리 | DevTools 스니핑 | ✅ |
| 5 | Brute-force + 지수 백오프 | 비밀번호 대입 | ✅ |
| 6 | 30초 자동 클리어 | 화면 노출 | ✅ |
| 7 | Uint8Array .fill(0) | 메모리 덤프 | ✅ (부분) |
회고
프론트엔드에서 보안을 다루면 “막을 수 있는 것”과 “구조적으로 막을 수 없는 것”의 경계를 명확히 알아야 한다. 완벽한 보안은 없지만, 노출 시간과 면적을 최소화하는 게 현실적인 목표다.
세 가지로 정리하면.
- Background Service Worker는 프론트엔드 보안의 현실적 최선 — UI에 PK가 안 올라오니까 DevTools 공격을 차단한다. 하지만 Background 프로세스 메모리 자체는 여전히 덤프 가능하다.
- Brute-force 카운터는 서버(또는 storage)에 저장해야 한다 — 클라이언트 state에 넣으면 앱 재시작으로 리셋된다. chrome.storage는 Extension 수준의 “서버”로 볼 수 있다.
- 프론트엔드 env ≠ 서버 env — Vite define은 빌드 시 인라인된다. “env에 넣었으니까 안전하겠지”는 프론트엔드에서 성립하지 않는다. 민감한 키는 백엔드 또는 사용자 입력으로 처리해야 한다.
시리즈 전체: #1 지갑 SDK · #2 앵커링 · #3 DID/VC · #4 보안
Footnotes
-
JS string immutable — JavaScript 문자열은 생성 후 변경할 수 없다.
str[0] = 'x'를 해도 원본은 바뀌지 않고 새 문자열이 생성된다. 메모리에 있는 원본 문자열을 덮어쓸 방법이 없다. ↩