Web Crypto API로 안전한 해싱 구현하기: SHA-256에서 PBKDF2까지

단순 SHA-256 해싱의 보안 한계를 분석하고, Web Crypto API의 PBKDF2로 업그레이드하는 방법을 다룹니다. Timing Attack 방어를 위한 constant-time 비교와 클라이언트 Rate Limiting 패턴까지 실제 코드와 함께 설명합니다.

1. 문제 상황

요구사항: 클라이언트에서 비밀번호/PIN 보호

브라우저 환경에서 민감한 데이터를 보호하기 위해 비밀번호나 PIN 기반 인증을 구현해야 할 때가 있습니다. 문제는 비밀번호를 어떻게 안전하게 저장하고 검증할 것인가입니다.

잘못된 접근: 평문 저장

// 절대 하면 안 되는 방법!
localStorage.setItem('password', 'mysecret123');

// 검증
if (localStorage.getItem('password') === userInput) {
  // 인증 성공
}

문제점:

  1. 개발자 도구에서 즉시 노출
  2. XSS 공격 시 탈취 가능
  3. 로컬 백업/동기화 시 평문 유출

기본적인 해결: 해시 저장

비밀번호 자체가 아닌 해시값만 저장하면 원본을 알 수 없습니다.

// 해시만 저장
const hash = await sha256(password);
localStorage.setItem('passwordHash', hash);

// 검증: 입력값을 해싱해서 비교
const inputHash = await sha256(userInput);
if (inputHash === storedHash) {
  // 인증 성공
}

하지만 단순 SHA-256 해싱에도 한계가 있습니다.


2. SHA-256 단독 해싱의 한계

SHA-256 기본 구현

async function sha256(message) {
  const encoder = new TextEncoder();
  const data = encoder.encode(message);

  // SHA-256 해시 계산
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);

  // Base64로 변환
  const hashArray = new Uint8Array(hashBuffer);
  return btoa(String.fromCharCode(...hashArray));
}

// 사용
const hash = await sha256('password123');
// "pmWkWSBCL51Bfkhn79xPuKBKHz//H6B+mY6G9/eieuM="

왜 SHA-256만으로는 부족한가?

1. 속도가 너무 빠름

SHA-256은 데이터 무결성 검증용으로 설계되어 매우 빠릅니다. 이것이 비밀번호 해싱에서는 약점이 됩니다.

// 일반 PC에서 SHA-256 성능
// 초당 수백만 ~ 수천만 회 해싱 가능

// 4자리 PIN의 경우: 0000 ~ 9999 = 10,000 가지
// SHA-256으로 전수조사: 1ms 이하에 완료!

2. 레인보우 테이블 공격

미리 계산된 해시-평문 매핑 테이블을 사용한 역추적 가능:

// 같은 비밀번호는 항상 같은 해시
sha256('password123')  // 항상 동일한 해시값
sha256('password123')  // 레인보우 테이블에서 조회 가능!

3. Salt 없는 해싱의 취약점

// 여러 사용자가 같은 비밀번호를 쓰면 해시도 동일
userA.hash === userB.hash  // 둘 다 'password123' 사용 시 true
// → 한 명의 비밀번호가 노출되면 다른 사용자도 위험

3. PBKDF2로 보안 강화하기

PBKDF2란?

PBKDF2 (Password-Based Key Derivation Function 2)는 비밀번호 해싱에 특화된 알고리즘입니다.

핵심 개념: 의도적으로 느리게 만들기

비밀번호 → [SHA-256 × 100,000회 반복] → 최종 해시
  • 한 번 계산에 수백 ms 소요
  • 브루트포스 공격 시간이 기하급수적으로 증가
  • GPU 병렬 공격에도 저항성 있음

Web Crypto API로 PBKDF2 구현

// PBKDF2 설정 상수
const PBKDF2_ITERATIONS = 100000;  // 반복 횟수

async function pbkdf2Hash(password, saltB64, iterations = PBKDF2_ITERATIONS) {
  const enc = new TextEncoder();

  // Base64 salt를 Uint8Array로 변환
  const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));

  // 1. 비밀번호를 키 재료(key material)로 가져오기
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    'PBKDF2',
    false,
    ['deriveBits']
  );

  // 2. PBKDF2로 키 도출
  const bits = await crypto.subtle.deriveBits(
    {
      name: 'PBKDF2',
      salt: salt,
      iterations: iterations,  // 핵심: 반복 횟수
      hash: 'SHA-256',
    },
    keyMaterial,
    256  // 256비트(32바이트) 출력
  );

  // 3. Base64로 인코딩하여 반환
  return btoa(String.fromCharCode(...new Uint8Array(bits)));
}

Salt 생성

function generateSalt() {
  const arr = new Uint8Array(16);  // 128비트
  crypto.getRandomValues(arr);     // 암호학적 난수
  return btoa(String.fromCharCode(...arr));
}

// 사용
const salt = generateSalt();
// "Kx7mNp2qR4sT6vW8yZ1aBC==" (매번 다름)

중요: Math.random() 대신 반드시 crypto.getRandomValues()를 사용해야 합니다.

// 예측 가능한 난수 - 보안 용도 부적합
Math.random()  // PRNG (Pseudo-Random Number Generator)

// 암호학적으로 안전한 난수
crypto.getRandomValues()  // CSPRNG (Cryptographically Secure PRNG)

비밀번호 설정과 검증

// 비밀번호 설정
async function setPassword(password) {
  const salt = generateSalt();
  const hash = await pbkdf2Hash(password, salt);

  // salt와 hash를 함께 저장 (iterations도 저장하면 나중에 업그레이드 가능)
  return {
    saltB64: salt,
    hashB64: hash,
    iterations: PBKDF2_ITERATIONS
  };
}

// 비밀번호 검증
async function verifyPassword(inputPassword, stored) {
  const { saltB64, hashB64, iterations } = stored;

  // 저장된 salt와 iterations로 입력값 해싱
  const inputHash = await pbkdf2Hash(inputPassword, saltB64, iterations);

  // 해시 비교
  return inputHash === hashB64;
}

SHA-256 vs PBKDF2 비교

항목 SHA-256 PBKDF2 (100k iterations)
단일 해싱 시간 ~0.001ms ~100-300ms
4자리 PIN 전수조사 < 1ms ~15-50분
6자리 비밀번호 < 100ms ~수 시간
Salt 내장 X O
목적 데이터 무결성 비밀번호 저장

4. Timing Attack 방어

Timing Attack이란?

문자열 비교 시 실행 시간의 차이를 분석하여 정보를 추출하는 공격입니다.

// 취약한 문자열 비교
function unsafeCompare(a, b) {
  if (a.length !== b.length) return false;

  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) return false;  // 첫 불일치에서 즉시 반환
  }
  return true;
}

문제점:

  • 첫 글자가 틀리면 빠르게 반환
  • 마지막 글자만 틀리면 느리게 반환
  • 이 시간 차이를 측정하여 한 글자씩 추측 가능
"aXXXXXXX" 해시 비교 → 빠름 (첫 글자 불일치)
"AaXXXXXX" 해시 비교 → 조금 느림 (두 번째 글자에서 불일치)
"AaxXXXXX" 해시 비교 → 더 느림 (세 번째 글자에서 불일치)
... 반복하면 전체 해시 추측 가능

Constant-Time 비교 구현

모든 경우에 동일한 시간이 걸리도록 비교합니다:

function constantTimeCompare(a, b) {
  // 타입 체크
  if (typeof a !== 'string' || typeof b !== 'string') {
    return false;
  }

  // 길이가 달라도 전체 비교를 수행
  const maxLen = Math.max(a.length, b.length);
  let result = a.length !== b.length ? 1 : 0;  // 길이 다르면 이미 불일치

  for (let i = 0; i < maxLen; i++) {
    const charA = i < a.length ? a.charCodeAt(i) : 0;
    const charB = i < b.length ? b.charCodeAt(i) : 0;
    result |= charA ^ charB;  // XOR 후 OR (불일치 시 비트가 세팅됨)
  }

  return result === 0;
}

핵심 포인트:

  1. 길이와 무관하게 전체 순회: maxLen까지 항상 반복
  2. 비트 연산으로 결과 누적: result |= charA ^ charB
  3. 조기 반환 없음: 모든 문자를 비교한 후에만 결과 반환

검증 함수에 적용

async function verifyPassword(inputPassword, stored) {
  const { saltB64, hashB64, iterations } = stored;
  const inputHash = await pbkdf2Hash(inputPassword, saltB64, iterations);

  // 일반 비교 대신 constant-time 비교 사용
  return constantTimeCompare(inputHash, hashB64);
}

5. Rate Limiting 구현

클라이언트 Rate Limiting의 필요성

서버 없이 클라이언트만으로 동작하는 환경에서도 브루트포스 공격을 방어해야 합니다.

// Rate Limiting 설정
const MAX_ATTEMPTS = 5;           // 최대 시도 횟수
const LOCKOUT_DURATION_MS = 30000;  // 잠금 시간 (30초)

시도 횟수 추적

// 시도 기록 조회
async function getAttempts() {
  const data = await chrome.storage.session.get('attempts');
  return data.attempts || { count: 0, lockedUntil: 0 };
}

// 시도 기록 저장
async function setAttempts(attempts) {
  await chrome.storage.session.set({ attempts });
}

// 시도 횟수 증가
async function incrementAttempts() {
  const attempts = await getAttempts();
  attempts.count += 1;

  // 최대 시도 횟수 초과 시 잠금
  if (attempts.count >= MAX_ATTEMPTS) {
    attempts.lockedUntil = Date.now() + LOCKOUT_DURATION_MS;
  }

  await setAttempts(attempts);
  return attempts;
}

// 시도 기록 초기화
async function resetAttempts() {
  await setAttempts({ count: 0, lockedUntil: 0 });
}

Rate Limiting이 적용된 검증

async function verifyPasswordWithRateLimit(inputPassword, stored) {
  const attempts = await getAttempts();

  // 1. 잠금 상태 확인
  if (attempts.lockedUntil > Date.now()) {
    const remaining = Math.ceil((attempts.lockedUntil - Date.now()) / 1000);
    return {
      success: false,
      error: 'locked',
      retryAfter: remaining,
      message: `${remaining}초 후에 다시 시도해주세요.`
    };
  }

  // 잠금 시간이 지났으면 초기화
  if (attempts.count >= MAX_ATTEMPTS && attempts.lockedUntil <= Date.now()) {
    await resetAttempts();
  }

  // 2. 비밀번호 검증
  const { saltB64, hashB64, iterations } = stored;
  const inputHash = await pbkdf2Hash(inputPassword, saltB64, iterations);
  const isValid = constantTimeCompare(inputHash, hashB64);

  // 3. 결과에 따른 처리
  if (isValid) {
    await resetAttempts();  // 성공 시 초기화
    return { success: true };
  } else {
    const newAttempts = await incrementAttempts();
    const remaining = MAX_ATTEMPTS - newAttempts.count;

    if (newAttempts.lockedUntil > Date.now()) {
      return {
        success: false,
        error: 'locked',
        retryAfter: Math.ceil(LOCKOUT_DURATION_MS / 1000),
        message: `비밀번호가 틀렸습니다. ${LOCKOUT_DURATION_MS / 1000}초 동안 잠금됩니다.`
      };
    }

    return {
      success: false,
      error: 'invalid',
      remainingAttempts: remaining,
      message: `비밀번호가 틀렸습니다. ${remaining}회 남음.`
    };
  }
}

용도별 Rate Limiting 분리

인증과 비밀번호 변경에 별도의 카운터를 사용하면 더 안전합니다:

// 용도별 키
const ATTEMPT_KEYS = {
  unlock: 'unlockAttempts',
  change: 'changeAttempts'
};

async function getAttempts(purpose = 'unlock') {
  const key = ATTEMPT_KEYS[purpose] || ATTEMPT_KEYS.unlock;
  const data = await chrome.storage.session.get(key);
  return data[key] || { count: 0, lockedUntil: 0 };
}

async function setAttempts(attempts, purpose = 'unlock') {
  const key = ATTEMPT_KEYS[purpose] || ATTEMPT_KEYS.unlock;
  await chrome.storage.session.set({ [key]: attempts });
}

분리의 이점:

  • 잠금 해제 시도가 비밀번호 변경에 영향 없음
  • 각 용도별로 독립적인 보안 정책 적용 가능
  • 공격 벡터 분리

6. 완전한 구현 예시

핵심 유틸리티

// ============================================
// Constants
// ============================================
const PBKDF2_ITERATIONS = 100000;
const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION_MS = 30 * 1000;  // 30초

// ============================================
// Cryptographic Utilities
// ============================================

// 암호학적으로 안전한 Salt 생성
function generateSalt() {
  const arr = new Uint8Array(16);
  crypto.getRandomValues(arr);
  return btoa(String.fromCharCode(...arr));
}

// PBKDF2 해싱
async function pbkdf2Hash(password, saltB64, iterations = PBKDF2_ITERATIONS) {
  const enc = new TextEncoder();
  const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));

  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    'PBKDF2',
    false,
    ['deriveBits']
  );

  const bits = await crypto.subtle.deriveBits(
    {
      name: 'PBKDF2',
      salt: salt,
      iterations: iterations,
      hash: 'SHA-256',
    },
    keyMaterial,
    256
  );

  return btoa(String.fromCharCode(...new Uint8Array(bits)));
}

// Constant-time 문자열 비교
function constantTimeCompare(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;

  const maxLen = Math.max(a.length, b.length);
  let result = a.length !== b.length ? 1 : 0;

  for (let i = 0; i < maxLen; i++) {
    const charA = i < a.length ? a.charCodeAt(i) : 0;
    const charB = i < b.length ? b.charCodeAt(i) : 0;
    result |= charA ^ charB;
  }

  return result === 0;
}

비밀번호 관리 API

// ============================================
// Password Management API
// ============================================

// 비밀번호 설정
async function setPassword(password) {
  const saltB64 = generateSalt();
  const hashB64 = await pbkdf2Hash(password, saltB64);

  const meta = {
    saltB64,
    hashB64,
    iterations: PBKDF2_ITERATIONS,
    createdAt: Date.now()
  };

  await chrome.storage.local.set({ passwordMeta: meta });
  return { success: true };
}

// 비밀번호 변경
async function changePassword(currentPassword, newPassword) {
  // 1. 현재 비밀번호 검증
  const verifyResult = await verifyPasswordWithRateLimit(
    currentPassword,
    await getPasswordMeta(),
    'change'  // 별도 rate limit
  );

  if (!verifyResult.success) {
    return verifyResult;
  }

  // 2. 새 비밀번호 설정 (새 salt 생성)
  return await setPassword(newPassword);
}

// 비밀번호 검증 (Rate Limiting 포함)
async function verifyPasswordWithRateLimit(password, meta, purpose = 'unlock') {
  if (!meta) {
    return { success: false, error: 'no-password-set' };
  }

  const attempts = await getAttempts(purpose);

  // 잠금 상태 확인
  if (attempts.lockedUntil > Date.now()) {
    const remaining = Math.ceil((attempts.lockedUntil - Date.now()) / 1000);
    return {
      success: false,
      error: 'locked',
      retryAfter: remaining
    };
  }

  // 잠금 해제
  if (attempts.count >= MAX_ATTEMPTS) {
    await resetAttempts(purpose);
  }

  // 검증
  const inputHash = await pbkdf2Hash(password, meta.saltB64, meta.iterations);
  const isValid = constantTimeCompare(inputHash, meta.hashB64);

  if (isValid) {
    await resetAttempts(purpose);
    return { success: true };
  } else {
    const newAttempts = await incrementAttempts(purpose);
    return {
      success: false,
      error: newAttempts.lockedUntil > Date.now() ? 'locked' : 'invalid',
      remainingAttempts: Math.max(0, MAX_ATTEMPTS - newAttempts.count),
      retryAfter: newAttempts.lockedUntil > Date.now()
        ? Math.ceil(LOCKOUT_DURATION_MS / 1000)
        : null
    };
  }
}

7. Before/After 비교

Before: 취약한 구현

// 취약점 1: 단순 SHA-256 (빠른 브루트포스 가능)
async function hashPassword(password) {
  const hash = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(password)
  );
  return btoa(String.fromCharCode(...new Uint8Array(hash)));
}

// 취약점 2: Salt 없음 (레인보우 테이블 공격 가능)
const stored = {
  hash: await hashPassword(password)
  // salt 없음!
};

// 취약점 3: 일반 문자열 비교 (Timing Attack 가능)
function verify(input) {
  const inputHash = await hashPassword(input);
  return inputHash === stored.hash;  // 취약!
}

// 취약점 4: Rate Limiting 없음 (무제한 시도 가능)

After: 안전한 구현

// 개선 1: PBKDF2 (100,000회 반복으로 브루트포스 방어)
const hash = await pbkdf2Hash(password, salt, 100000);

// 개선 2: 랜덤 Salt (레인보우 테이블 무력화)
const stored = {
  saltB64: generateSalt(),
  hashB64: hash,
  iterations: 100000
};

// 개선 3: Constant-time 비교 (Timing Attack 방어)
const isValid = constantTimeCompare(inputHash, stored.hashB64);

// 개선 4: Rate Limiting (5회 실패 시 30초 잠금)
const result = await verifyPasswordWithRateLimit(input, stored);

8. 보안 체크리스트

필수 구현 사항

항목 설명 상태
PBKDF2 해싱 단순 SHA-256 대신 PBKDF2 사용 필수
Salt 사용 crypto.getRandomValues()로 생성 필수
Constant-time 비교 Timing Attack 방어 필수
Rate Limiting 시도 횟수 제한 필수
Iterations 저장 나중에 업그레이드 가능하도록 권장

저장 데이터 구조

// 권장 저장 형식
{
  "passwordMeta": {
    "saltB64": "Kx7mNp2qR4sT6vW8yZ1aBC==",
    "hashB64": "A6xnQhbz4Vx2HuGl4lXwZ5U2I8iziLRFnhP5eNfIRvQ=",
    "iterations": 100000,
    "createdAt": 1706000000000
  }
}

// Session 저장 (브라우저 종료 시 삭제)
{
  "unlockAttempts": { "count": 2, "lockedUntil": 0 },
  "changeAttempts": { "count": 0, "lockedUntil": 0 }
}

9. FAQ

Q: PBKDF2 iterations는 얼마가 적당한가요?

A: OWASP 권장은 최소 310,000회 (SHA-256 기준, 2023년)입니다. 하지만 클라이언트 환경과 UX를 고려해 100,000회 정도가 현실적인 선택입니다. 중요한 것은 iterations 값을 저장해두어 나중에 업그레이드할 수 있게 하는 것입니다.

// 저장된 iterations 값을 사용하여 검증
const hash = await pbkdf2Hash(password, stored.saltB64, stored.iterations);

Q: bcrypt나 Argon2를 쓰면 안 되나요?

A: bcrypt와 Argon2는 더 강력하지만, 브라우저 네이티브 Web Crypto API에서 지원하지 않습니다. 외부 라이브러리를 추가해야 하며, 번들 크기와 유지보수 부담이 생깁니다. Web Crypto API의 PBKDF2가 브라우저 환경에서 가장 현실적인 선택입니다.

Q: constant-time 비교가 정말 필요한가요?

A: 네트워크를 통한 원격 Timing Attack은 노이즈가 많아 어렵지만, 같은 기기에서의 로컬 공격이나 사이드 채널 공격에는 여전히 취약할 수 있습니다. 구현 비용이 낮으므로 항상 적용하는 것이 좋습니다.

Q: 클라이언트 Rate Limiting은 우회 가능하지 않나요?

A: 맞습니다. 로컬 스토리지를 직접 조작하면 우회 가능합니다. 하지만:

  • 일반 사용자의 실수로 인한 잠금 방지
  • 자동화된 스크립트 공격 지연
  • Defense in depth (다층 방어)의 한 레이어

서버가 있다면 서버 측 Rate Limiting과 함께 사용해야 합니다.

Q: crypto.subtle은 HTTPS에서만 동작하나요?

A: 일반 웹페이지에서는 HTTPS 또는 localhost에서만 동작합니다. 하지만 크롬 확장이나 Electron 앱에서는 chrome-extension:// 프로토콜이 보안 컨텍스트로 간주되어 정상 동작합니다.


10. 참고 자료


시리즈 목차

  1. JavaScript URL 비교와 정규화
  2. Web Crypto API로 안전한 해싱 구현하기 ← 현재 글
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기