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

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

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

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. 참고 자료


📚 크롬 확장 개발 시리즈 (9부작)

  1. JavaScript URL 비교와 정규화
  2. Web Crypto API로 안전한 해싱 구현하기 (현재 글)
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기
  5. 크롬 확장 공유 모듈 설계
  6. Chrome Storage로 실시간 상태 동기화
  7. 크롬 확장 다국어(i18n) 구현하기
  8. Chrome Alarms API로 자동 잠금 타이머
  9. 크롬 확장 보안 강화: CSP와 최소 권한