Skip to content
Go back

Wallet SDK 고도화 #1 — Keystore V3와 메모리 보안

이전 시리즈에서 지갑 SDK의 기본 구조를 잡았다. 니모닉 생성, HD 키 파생, AES-256-GCM 암호화까지 — 동작은 했지만 두 가지가 아쉬웠다. 첫째, 암호화 포맷이 자체 규격이라 MetaMask 같은 타 지갑과 호환이 안 됐다. 둘째, 개인키가 메모리에 남아있는 문제를 방치하고 있었다. 이번 고도화에서는 Keystore V3 표준 구현과 메모리 보안을 다룬다.

무엇을 했나

Keystore V3 — 왜 표준을 따랐나

기존에는 PBKDF2 + AES-256-GCM으로 니모닉을 암호화했다. 잘 동작했지만, 이 포맷은 SDK 안에서만 통하는 자체 규격이었다. MetaMask에서 Export한 JSON을 가져오거나, 반대로 내보내는 게 안 됐다.

Web3 Secret Storage Definition1은 이더리움 생태계의 사실상 표준이다. 구조는 이렇다:

비밀번호
  ↓ KDF (Scrypt 또는 PBKDF2) + 랜덤 Salt
파생키 32바이트
  ├── 앞 16바이트 → AES-128-CTR 암호화 키
  └── 뒤 16바이트 → MAC 검증 키

파생키를 반으로 나눠서 앞쪽은 암호화에, 뒤쪽은 무결성 검증에 쓴다. MAC은 keccak256(macKey + ciphertext)로 계산하고, 복호화할 때 이 값이 일치하는지 먼저 확인한다. 비밀번호가 틀리면 MAC 불일치로 즉시 실패한다.

// 암호화 핵심 흐름 (요약)
const salt = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(16));
const derivedKey = await deriveKey(password, salt, kdf);

const encKey = derivedKey.slice(0, 16);   // AES 키
const macKey = derivedKey.slice(16, 32);   // MAC 키

const ciphertext = await aes128ctrEncrypt(encKey, iv, pkBytes);
const mac = keccak256(concat(macKey, ciphertext));

결과물은 MetaMask, geth, MyEtherWallet 등이 읽을 수 있는 표준 JSON이다. version: 3, crypto.cipher: "aes-128-ctr", crypto.kdf: "scrypt" 필드가 있으면 어느 지갑에서든 Import할 수 있다.

KDF 선택 — Scrypt vs PBKDF2

KDF(Key Derivation Function)는 비밀번호를 암호화 키로 변환하는 함수다. 느릴수록 brute-force에 강하다.

ScryptPBKDF2
강점메모리 집약적 — GPU/ASIC 공격에 강함하드웨어 요구사항 낮음, Web Crypto API 네이티브 지원
약점메모리 사용량 높음 (N=262144일 때 ~1GB)메모리 비의존적이라 GPU 병렬 공격에 상대적으로 취약
적합한 환경서버, 데스크톱모바일, 브라우저, IoT

SDK에서는 기본값을 Scrypt으로 두되, 모바일처럼 메모리가 제한된 환경에서는 PBKDF2로 전환할 수 있게 했다.

// KDF 선택은 encryptToKeystore의 세 번째 인자
const keystore = await encryptToKeystore(privateKey, password, 'scrypt');  // 기본값
const keystore = await encryptToKeystore(privateKey, password, 'pbkdf2'); // 모바일용

Scrypt 파라미터는 N=262144, r=8, p=1이다. PBKDF2는 c=262144, prf=hmac-sha256. 둘 다 파생키 길이는 32바이트다.

constant-time MAC — 타이밍 공격 방지

복호화할 때 MAC을 비교해서 비밀번호가 맞는지 확인한다. 여기서 일반적인 === 비교를 쓰면 타이밍 사이드채널 공격에 노출된다.

// ❌ 첫 바이트가 다르면 즉시 false — 걸린 시간으로 정보 유출
if (storedMac === computedMac) { ... }

// ✅ XOR 기반 — 모든 바이트를 항상 비교, 일정한 실행 시간
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a[i]! ^ b[i]!;
  }
  return diff === 0;
}

모든 바이트를 XOR하고 결과를 OR로 누적한다. 한 바이트라도 다르면 diff가 0이 아니지만, 어느 위치에서 달랐는지는 실행 시간으로 알 수 없다.

zeroize — 메모리 위생

개인키, 시드, 파생키 같은 민감 데이터는 사용이 끝나면 메모리에서 지워야 한다. JavaScript는 GC가 언제 동작할지 모르니까, 명시적으로 덮어쓴다.

export function zeroize(buf: Uint8Array | null | undefined): void {
  if (!buf) return;
  crypto.getRandomValues(buf); // 랜덤 데이터로 덮어쓰기
  buf.fill(0);                 // 0으로 초기화
}

랜덤으로 한 번 덮고 0으로 채우는 이유는, 컴파일러 최적화로 “어차피 안 쓰는 메모리니까 0 채우기를 생략하자”는 판단을 방지하기 위해서다. 랜덤 쓰기는 crypto.getRandomValues 호출이라 최적화 대상이 아니다.

실제 적용 위치는 키 파생 함수다:

// shared/derive.ts (요약)
export function deriveKeyFromMnemonic(mnemonic: string, index: number) {
  const seed = mnemonicToSeedSync(mnemonic);
  try {
    const hdKey = HDKey.fromMasterSeed(seed).derive(`${HD_BASE_PATH}/${index}`);
    const privateKeyHex = bytesToHex(hdKey.privateKey!);  // hex 복사본 생성
    const account = privateKeyToAccount(privateKeyHex);
    zeroize(hdKey.privateKey);  // 원본 바이트 삭제 (hex 복사본은 유지)
    return { address: account.address, privateKey: privateKeyHex };
  } finally {
    zeroize(seed);  // seed는 어떤 경우에도 삭제
  }
}

try/finally 패턴으로 예외가 발생해도 seed가 반드시 삭제되게 했다. Keystore 암호화/복호화에서도 derivedKey, encKey, macKey, pkBytes 모두 사용 후 zeroize()를 호출한다.

엔트로피 검증 — 불량 난수 거부

니모닉 생성의 핵심은 난수 품질이다. crypto.getRandomValues()가 정상이면 문제없지만, 만약 RNG에 결함이 있다면 예측 가능한 니모닉이 생성될 수 있다.

const entropy = mnemonicToEntropy(mnemonic, wordlist);
if (entropy.every((b) => b === 0) || entropy.every((b) => b === entropy[0])) {
  throw new KeyringError('Entropy generation failed: insufficient randomness');
}

모든 바이트가 0이거나 전부 같은 값이면 거부한다. 실제로 이런 일이 발생할 확률은 극히 낮지만(2^-120 이하), 발생했을 때의 피해가 치명적이라 방어 코드를 넣었다. NIST SP 800-90A2의 “엔트로피 소스는 반복 검출 테스트를 수행해야 한다”는 권고를 최소한으로 반영한 것이다.

회고

보안은 “동작하면 된다”가 아니라 “실패했을 때 얼마나 안전한가”를 설계하는 것이었다.

기존 AES-256-GCM 암호화도 잘 동작했다. 하지만 타 지갑과 호환이 안 되고, 메모리에 키가 남아있고, 엔트로피를 검증하지 않았다. 기능적으로는 차이가 없지만 보안 관점에서는 전혀 다른 수준이다. Keystore V3 표준을 따르면서 constant-time MAC, zeroize, 엔트로피 검증을 추가한 것은 “잘 동작하는 코드”를 “안전한 코드”로 바꾸는 과정이었다.


다음 편: Wallet SDK 고도화 #2 — 메타 트랜잭션으로 가스비 대납

Footnotes

  1. Web3 Secret Storage Definition — Ethereum의 프라이빗 키 암호화 표준. Scrypt/PBKDF2 + AES-128-CTR + keccak256 MAC 구조. https://ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage/

  2. NIST SP 800-90A — 난수 생성기 권고안. 엔트로피 소스의 건강 검사(health test)를 요구한다. https://csrc.nist.gov/publications/detail/sp/800-90a/rev-1/final


Share this post on:

Comments


Previous Post
Wallet SDK 고도화 #2 — 메타 트랜잭션으로 가스비 대납
Next Post
Confluence 일일 스크럼 페이지 자동 생성기 만들기